mirror of
https://github.com/portainer/portainer.git
synced 2025-07-23 15:29:42 +02:00
feat(helm): helm actions [r8s-259] (#715)
Co-authored-by: James Player <james.player@portainer.io> Co-authored-by: Cara Ryan <cara.ryan@portainer.io> Co-authored-by: stevensbkang <skan070@gmail.com>
This commit is contained in:
parent
dfa32b6755
commit
4ee349bd6b
117 changed files with 4161 additions and 696 deletions
206
app/react/components/CodeEditor/CodeEditor.module.css
Normal file
206
app/react/components/CodeEditor/CodeEditor.module.css
Normal file
|
@ -0,0 +1,206 @@
|
|||
.root {
|
||||
--text-cm-default-color: var(--blue-1);
|
||||
--text-cm-meta-color: var(--black-color);
|
||||
--text-cm-string-color: var(--red-3);
|
||||
--text-cm-number-color: var(--green-1);
|
||||
--text-cm-keyword-color: var(--ui-blue-dark-9);
|
||||
--text-cm-comment-color: var(--ui-orange-6);
|
||||
--text-cm-variable-name-color: var(--ui-green-8);
|
||||
--text-codemirror-color: var(--black-color);
|
||||
--bg-codemirror-color: var(--white-color);
|
||||
--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 {
|
||||
--text-cm-default-color: var(--blue-10);
|
||||
--text-cm-meta-color: var(--white-color);
|
||||
--text-cm-string-color: var(--red-5);
|
||||
--text-cm-number-color: var(--green-2);
|
||||
--text-cm-keyword-color: var(--ui-purple-6);
|
||||
--text-codemirror-color: var(--white-color);
|
||||
--bg-codemirror-color: var(--grey-2);
|
||||
--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 {
|
||||
--text-cm-default-color: var(--blue-9);
|
||||
--text-cm-meta-color: var(--white-color);
|
||||
--text-cm-string-color: var(--red-7);
|
||||
--text-cm-number-color: var(--green-2);
|
||||
--text-cm-keyword-color: var(--ui-purple-6);
|
||||
--text-codemirror-color: var(--white-color);
|
||||
--bg-codemirror-color: var(--black-color);
|
||||
--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) {
|
||||
border-right: 0px;
|
||||
@apply bg-gray-2 th-dark:bg-gray-10 th-highcontrast:bg-black;
|
||||
}
|
||||
|
||||
.root :global(.cm-merge-b) {
|
||||
@apply border-0 border-l border-solid border-l-gray-5 th-dark:border-l-gray-7 th-highcontrast:border-l-gray-2;
|
||||
}
|
||||
|
||||
.root :global(.cm-editor .cm-gutters .cm-lineNumbers .cm-gutterElement) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.codeEditor :global(.cm-editor),
|
||||
.codeEditor :global(.cm-editor .cm-scroller) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* code mirror merge side-by-side editor */
|
||||
.root :global(.cm-merge-a),
|
||||
.root :global(.cm-merge-a .cm-scroller) {
|
||||
@apply !rounded-r-none;
|
||||
}
|
||||
|
||||
.root :global(.cm-merge-b),
|
||||
.root :global(.cm-merge-b .cm-scroller) {
|
||||
@apply !rounded-l-none;
|
||||
}
|
||||
|
||||
/* Search Panel */
|
||||
/* Ideally we would use a react component for that, but this is the easy solution for onw */
|
||||
|
||||
.root :global(.cm-panels.cm-panels-bottom) {
|
||||
background-color: var(--bg-codemirror-gutters-color);
|
||||
border-top-color: transparent;
|
||||
color: var(--text-codemirror-color);
|
||||
}
|
||||
|
||||
.root :global(.cm-button) {
|
||||
background-image: none;
|
||||
border-radius: 4px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.root :global(.cm-button[name='next']),
|
||||
.root :global(.cm-button[name='replace']) {
|
||||
@apply border-blue-8 bg-blue-8 text-white;
|
||||
@apply hover:border-blue-9 hover:bg-blue-9 hover:text-white;
|
||||
@apply th-dark:hover:border-blue-7 th-dark:hover:bg-blue-7;
|
||||
}
|
||||
|
||||
.root :global(.cm-button[name='prev']),
|
||||
.root :global(.cm-button[name='replaceAll']) {
|
||||
@apply border border-solid;
|
||||
|
||||
@apply border-blue-8 bg-blue-2 text-blue-9;
|
||||
@apply hover:bg-blue-3;
|
||||
|
||||
@apply th-dark:border-blue-7 th-dark:bg-gray-10 th-dark:text-blue-3;
|
||||
@apply th-dark:hover:bg-blue-11;
|
||||
}
|
||||
|
||||
.root :global(.cm-button[name='select']) {
|
||||
@apply border-gray-5 bg-white text-gray-9;
|
||||
@apply hover:border-gray-5 hover:bg-gray-3 hover:text-gray-10;
|
||||
|
||||
/* dark mode */
|
||||
@apply th-dark:border-gray-warm-7 th-dark:bg-gray-iron-10 th-dark:text-gray-warm-4;
|
||||
@apply th-dark:hover:border-gray-6 th-dark:hover:bg-gray-iron-9 th-dark:hover:text-gray-warm-4;
|
||||
|
||||
@apply th-highcontrast:border-gray-2 th-highcontrast:bg-black th-highcontrast:text-white;
|
||||
@apply th-highcontrast:hover:border-gray-6 th-highcontrast:hover:bg-gray-9 th-highcontrast:hover:text-gray-warm-4;
|
||||
}
|
||||
|
||||
.root :global(.cm-search) label {
|
||||
font-weight: 400;
|
||||
@apply text-gray-7;
|
||||
@apply th-dark:text-gray-warm-3;
|
||||
@apply th-highcontrast:text-white;
|
||||
}
|
||||
|
||||
.root :global(.cm-search) input {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.root :global(.cm-textfield) {
|
||||
border: 1px solid var(--border-form-control-color);
|
||||
background-color: var(--bg-inputbox);
|
||||
color: var(--text-form-control-color);
|
||||
}
|
||||
|
||||
.root :global(.cm-content[contenteditable='true']) {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.root :global(.cm-content[aria-readonly='true']) {
|
||||
@apply bg-gray-3;
|
||||
@apply th-dark:bg-gray-iron-10;
|
||||
@apply th-highcontrast:bg-black;
|
||||
}
|
||||
|
||||
.root :global(.cm-textfield) {
|
||||
border: 1px solid var(--border-form-control-color);
|
||||
background-color: var(--bg-inputbox);
|
||||
color: var(--text-form-control-color);
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.root :global(.cm-button) {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Collapsed lines gutter styles for all themes */
|
||||
.root :global(.cm-editor .cm-collapsedLines) {
|
||||
/* inherit bg, instead of using styles from library */
|
||||
background: inherit;
|
||||
@apply bg-blue-2 th-dark:bg-blue-10 th-highcontrast:bg-white th-dark:text-white th-highcontrast:text-black;
|
||||
}
|
||||
.root :global(.cm-editor .cm-collapsedLines):hover {
|
||||
@apply bg-blue-3 th-dark:bg-blue-9 th-highcontrast:bg-white th-dark:text-white th-highcontrast:text-black;
|
||||
}
|
||||
|
||||
.root :global(.cm-editor .cm-collapsedLines:before) {
|
||||
content: '↧ Expand all';
|
||||
background: var(--bg-tooltip-color);
|
||||
color: var(--text-tooltip-color);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
margin-left: 4px;
|
||||
}
|
||||
/* override the default content */
|
||||
.root :global(.cm-editor .cm-collapsedLines:after) {
|
||||
content: '';
|
||||
}
|
138
app/react/components/CodeEditor/CodeEditor.test.tsx
Normal file
138
app/react/components/CodeEditor/CodeEditor.test.tsx
Normal file
|
@ -0,0 +1,138 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Extension } from '@codemirror/state';
|
||||
|
||||
import { CodeEditor } from './CodeEditor';
|
||||
|
||||
const mockExtension: Extension = { extension: [] };
|
||||
vi.mock('yaml-schema', () => ({
|
||||
// yamlSchema has 5 return values (all extensions)
|
||||
yamlSchema: () => [
|
||||
mockExtension,
|
||||
mockExtension,
|
||||
mockExtension,
|
||||
mockExtension,
|
||||
mockExtension,
|
||||
],
|
||||
yamlCompletion: () => () => ({}),
|
||||
}));
|
||||
|
||||
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} textTip={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');
|
||||
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 });
|
||||
});
|
||||
|
||||
test('should render with file name header when provided', async () => {
|
||||
const fileName = 'example.yaml';
|
||||
const testValue = 'file content';
|
||||
const { findByText } = render(
|
||||
<CodeEditor {...defaultProps} fileName={fileName} value={testValue} />
|
||||
);
|
||||
|
||||
expect(await findByText(fileName)).toBeInTheDocument();
|
||||
expect(await findByText(testValue)).toBeInTheDocument();
|
||||
});
|
158
app/react/components/CodeEditor/CodeEditor.tsx
Normal file
158
app/react/components/CodeEditor/CodeEditor.tsx
Normal file
|
@ -0,0 +1,158 @@
|
|||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { createTheme } from '@uiw/codemirror-themes';
|
||||
import { tags as highlightTags } from '@lezer/highlight';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { CopyButton } from '@@/buttons/CopyButton';
|
||||
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { TextTip } from '../Tip/TextTip';
|
||||
import { StackVersionSelector } from '../StackVersionSelector';
|
||||
|
||||
import styles from './CodeEditor.module.css';
|
||||
import {
|
||||
useCodeEditorExtensions,
|
||||
CodeEditorType,
|
||||
} from './useCodeEditorExtensions';
|
||||
import { FileNameHeader, FileNameHeaderRow } from './FileNameHeader';
|
||||
|
||||
interface Props extends AutomationTestingProps {
|
||||
id: string;
|
||||
textTip?: string;
|
||||
type?: CodeEditorType;
|
||||
readonly?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
value: string;
|
||||
height?: string;
|
||||
versions?: number[];
|
||||
onVersionChange?: (version: number) => void;
|
||||
schema?: JSONSchema7;
|
||||
fileName?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const theme = createTheme({
|
||||
theme: 'light',
|
||||
settings: {
|
||||
background: 'var(--bg-codemirror-color)',
|
||||
foreground: 'var(--text-codemirror-color)',
|
||||
caret: 'var(--border-codemirror-cursor-color)',
|
||||
selection: 'var(--bg-codemirror-selected-color)',
|
||||
selectionMatch: 'var(--bg-codemirror-selected-color)',
|
||||
},
|
||||
styles: [
|
||||
{ tag: highlightTags.atom, color: 'var(--text-cm-default-color)' },
|
||||
{ tag: highlightTags.meta, color: 'var(--text-cm-meta-color)' },
|
||||
{
|
||||
tag: [highlightTags.string, highlightTags.special(highlightTags.brace)],
|
||||
color: 'var(--text-cm-string-color)',
|
||||
},
|
||||
{ tag: highlightTags.number, color: 'var(--text-cm-number-color)' },
|
||||
{ tag: highlightTags.keyword, color: 'var(--text-cm-keyword-color)' },
|
||||
{ tag: highlightTags.comment, color: 'var(--text-cm-comment-color)' },
|
||||
{
|
||||
tag: highlightTags.variableName,
|
||||
color: 'var(--text-cm-variable-name-color)',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export function CodeEditor({
|
||||
id,
|
||||
onChange = () => {},
|
||||
textTip,
|
||||
readonly,
|
||||
value,
|
||||
versions,
|
||||
onVersionChange,
|
||||
height = '500px',
|
||||
type,
|
||||
schema,
|
||||
'data-cy': dataCy,
|
||||
fileName,
|
||||
placeholder,
|
||||
}: Props) {
|
||||
const [isRollback, setIsRollback] = useState(false);
|
||||
|
||||
const extensions = useCodeEditorExtensions(type, schema);
|
||||
|
||||
const handleVersionChange = useCallback(
|
||||
(version: number) => {
|
||||
if (versions && versions.length > 1) {
|
||||
setIsRollback(version < versions[0]);
|
||||
}
|
||||
onVersionChange?.(version);
|
||||
},
|
||||
[onVersionChange, versions]
|
||||
);
|
||||
|
||||
const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{!!textTip && <TextTip color="blue">{textTip}</TextTip>}
|
||||
</div>
|
||||
{/* the copy button is in the file name header, when fileName is provided */}
|
||||
{!fileName && (
|
||||
<div className="flex-2 ml-auto mr-2 flex items-center gap-x-2">
|
||||
<CopyButton
|
||||
data-cy={`copy-code-button-${id}`}
|
||||
fadeDelay={2500}
|
||||
copyText={value}
|
||||
color="link"
|
||||
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
|
||||
indicatorPosition="left"
|
||||
>
|
||||
Copy
|
||||
</CopyButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{versions && (
|
||||
<div className="mt-2 flex">
|
||||
<div className="ml-auto mr-2">
|
||||
<StackVersionSelector
|
||||
versions={versions}
|
||||
onChange={handleVersionChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-solid border-gray-5 th-dark:border-gray-7 th-highcontrast:border-gray-2">
|
||||
{fileName && (
|
||||
<FileNameHeaderRow>
|
||||
<FileNameHeader
|
||||
fileName={fileName}
|
||||
copyText={value}
|
||||
data-cy={`copy-code-button-${id}`}
|
||||
/>
|
||||
</FileNameHeaderRow>
|
||||
)}
|
||||
<CodeMirror
|
||||
className={clsx(styles.root, styles.codeEditor)}
|
||||
theme={theme}
|
||||
value={debouncedValue}
|
||||
onChange={debouncedOnChange}
|
||||
readOnly={readonly || isRollback}
|
||||
id={id}
|
||||
extensions={extensions}
|
||||
height={height}
|
||||
basicSetup={{
|
||||
highlightSelectionMatches: false,
|
||||
autocompletion: !!schema,
|
||||
}}
|
||||
data-cy={dataCy}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
101
app/react/components/CodeEditor/DiffViewer.test.tsx
Normal file
101
app/react/components/CodeEditor/DiffViewer.test.tsx
Normal file
|
@ -0,0 +1,101 @@
|
|||
import { render } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import { DiffViewer } from './DiffViewer';
|
||||
|
||||
// Mock CodeMirror
|
||||
vi.mock('@uiw/react-codemirror', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-cy="mock-editor" />,
|
||||
oneDarkHighlightStyle: {},
|
||||
keymap: {
|
||||
of: () => ({}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-codemirror-merge
|
||||
vi.mock('react-codemirror-merge', () => {
|
||||
function CodeMirrorMerge({ children }: { children: React.ReactNode }) {
|
||||
return <div data-cy="mock-code-mirror-merge">{children}</div>;
|
||||
}
|
||||
function Original({ value }: { value: string }) {
|
||||
return <div data-cy="mock-original">{value}</div>;
|
||||
}
|
||||
function Modified({ value }: { value: string }) {
|
||||
return <div data-cy="mock-modified">{value}</div>;
|
||||
}
|
||||
|
||||
CodeMirrorMerge.Original = Original;
|
||||
CodeMirrorMerge.Modified = Modified;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: CodeMirrorMerge,
|
||||
CodeMirrorMerge,
|
||||
};
|
||||
});
|
||||
|
||||
describe('DiffViewer', () => {
|
||||
beforeEach(() => {
|
||||
// Clear any mocks or state before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render with basic props', () => {
|
||||
const { getByText } = render(
|
||||
<DiffViewer
|
||||
originalCode="Original text"
|
||||
newCode="New text"
|
||||
id="test-diff-viewer"
|
||||
data-cy="test-diff-viewer"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if the component renders with the expected content
|
||||
expect(getByText('Original text')).toBeInTheDocument();
|
||||
expect(getByText('New text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with file name headers when provided', () => {
|
||||
const { getByText } = render(
|
||||
<DiffViewer
|
||||
originalCode="Original text"
|
||||
newCode="New text"
|
||||
id="test-diff-viewer"
|
||||
data-cy="test-diff-viewer"
|
||||
fileNames={{
|
||||
original: 'Original File',
|
||||
modified: 'Modified File',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Look for elements with the expected class structure
|
||||
const headerOriginal = getByText('Original File');
|
||||
const headerModified = getByText('Modified File');
|
||||
expect(headerOriginal).toBeInTheDocument();
|
||||
expect(headerModified).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom height when provided', () => {
|
||||
const customHeight = '800px';
|
||||
const { container } = render(
|
||||
<DiffViewer
|
||||
originalCode="Original text"
|
||||
newCode="New text"
|
||||
id="test-diff-viewer"
|
||||
data-cy="test-diff-viewer"
|
||||
height={customHeight}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the element with the style containing the height
|
||||
const divWithStyle = container.querySelector('[style*="height"]');
|
||||
expect(divWithStyle).toBeInTheDocument();
|
||||
|
||||
// Check that the style contains the expected height
|
||||
expect(divWithStyle?.getAttribute('style')).toContain(
|
||||
`height: ${customHeight}`
|
||||
);
|
||||
});
|
||||
});
|
138
app/react/components/CodeEditor/DiffViewer.tsx
Normal file
138
app/react/components/CodeEditor/DiffViewer.tsx
Normal file
|
@ -0,0 +1,138 @@
|
|||
import CodeMirrorMerge from 'react-codemirror-merge';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { FileNameHeader, FileNameHeaderRow } from './FileNameHeader';
|
||||
import styles from './CodeEditor.module.css';
|
||||
import {
|
||||
CodeEditorType,
|
||||
useCodeEditorExtensions,
|
||||
} from './useCodeEditorExtensions';
|
||||
import { theme } from './CodeEditor';
|
||||
|
||||
const { Original } = CodeMirrorMerge;
|
||||
const { Modified } = CodeMirrorMerge;
|
||||
|
||||
type Props = {
|
||||
originalCode: string;
|
||||
newCode: string;
|
||||
id: string;
|
||||
type?: CodeEditorType;
|
||||
placeholder?: string;
|
||||
height?: string;
|
||||
fileNames?: {
|
||||
original: string;
|
||||
modified: string;
|
||||
};
|
||||
className?: string;
|
||||
} & AutomationTestingProps;
|
||||
|
||||
const defaultCollapseUnchanged = {
|
||||
margin: 10,
|
||||
minSize: 10,
|
||||
};
|
||||
|
||||
export function DiffViewer({
|
||||
originalCode,
|
||||
newCode,
|
||||
id,
|
||||
'data-cy': dataCy,
|
||||
type,
|
||||
placeholder = 'No values found',
|
||||
|
||||
height = '500px',
|
||||
fileNames,
|
||||
className,
|
||||
}: Props) {
|
||||
const extensions = useCodeEditorExtensions(type);
|
||||
const hasFileNames = !!fileNames?.original && !!fileNames?.modified;
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'overflow-hidden rounded-lg border border-solid border-gray-5 th-dark:border-gray-7 th-highcontrast:border-gray-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{hasFileNames && (
|
||||
<DiffFileNameHeaders
|
||||
originalCopyText={originalCode}
|
||||
modifiedCopyText={newCode}
|
||||
originalFileName={fileNames.original}
|
||||
modifiedFileName={fileNames.modified}
|
||||
/>
|
||||
)}
|
||||
{/* additional div, so that the scroll gutter doesn't overlap with the rounded border, and always show scrollbar, so that the file name headers align */}
|
||||
<div
|
||||
style={
|
||||
{
|
||||
// tailwind doesn't like dynamic class names, so use a custom css variable for the height
|
||||
// https://v3.tailwindcss.com/docs/content-configuration#dynamic-class-names
|
||||
'--editor-min-height': height,
|
||||
height,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className="h-full [scrollbar-gutter:stable] overflow-y-scroll"
|
||||
>
|
||||
<CodeMirrorMerge
|
||||
theme={theme}
|
||||
className={clsx(
|
||||
styles.root,
|
||||
// to give similar sizing to CodeEditor
|
||||
'[&_.cm-content]:!min-h-[var(--editor-min-height)] [&_.cm-gutters]:!min-h-[var(--editor-min-height)] [&_.cm-editor>.cm-scroller]:!min-h-[var(--editor-min-height)]'
|
||||
)}
|
||||
id={id}
|
||||
data-cy={dataCy}
|
||||
collapseUnchanged={defaultCollapseUnchanged}
|
||||
>
|
||||
<Original
|
||||
value={originalCode}
|
||||
extensions={extensions}
|
||||
readOnly
|
||||
editable={false}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<Modified
|
||||
value={newCode}
|
||||
extensions={extensions}
|
||||
readOnly
|
||||
editable={false}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</CodeMirrorMerge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffFileNameHeaders({
|
||||
originalCopyText,
|
||||
modifiedCopyText,
|
||||
originalFileName,
|
||||
modifiedFileName,
|
||||
}: {
|
||||
originalCopyText: string;
|
||||
modifiedCopyText: string;
|
||||
originalFileName: string;
|
||||
modifiedFileName: string;
|
||||
}) {
|
||||
return (
|
||||
<FileNameHeaderRow>
|
||||
<div className="w-1/2">
|
||||
<FileNameHeader
|
||||
fileName={originalFileName}
|
||||
copyText={originalCopyText}
|
||||
data-cy="original"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-px bg-gray-5 th-dark:bg-gray-7 th-highcontrast:bg-gray-2" />
|
||||
<div className="flex-1">
|
||||
<FileNameHeader
|
||||
fileName={modifiedFileName}
|
||||
copyText={modifiedCopyText}
|
||||
data-cy="modified"
|
||||
/>
|
||||
</div>
|
||||
</FileNameHeaderRow>
|
||||
);
|
||||
}
|
71
app/react/components/CodeEditor/FileNameHeader.tsx
Normal file
71
app/react/components/CodeEditor/FileNameHeader.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { CopyButton } from '@@/buttons/CopyButton';
|
||||
|
||||
type FileNameHeaderProps = {
|
||||
fileName: string;
|
||||
copyText: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
} & AutomationTestingProps;
|
||||
|
||||
/**
|
||||
* FileNameHeaderRow: Outer container for file name headers (single or multiple columns).
|
||||
* Use this to wrap one or more <FileNameHeader> components (and optional dividers).
|
||||
*/
|
||||
export function FileNameHeaderRow({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex w-full text-sm text-muted border-0 border-b border-solid border-b-gray-5 th-dark:border-b-gray-7 th-highcontrast:border-b-gray-2 bg-gray-2 th-dark:bg-gray-10 th-highcontrast:bg-black [scrollbar-gutter:stable] overflow-auto',
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FileNameHeader: Renders a file name with a copy button, styled for use above a code editor or diff viewer.
|
||||
* Should be used inside FileNameHeaderRow.
|
||||
*/
|
||||
export function FileNameHeader({
|
||||
fileName,
|
||||
copyText,
|
||||
className = '',
|
||||
style,
|
||||
'data-cy': dataCy,
|
||||
}: FileNameHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-full overflow-auto flex justify-between items-center gap-x-2 px-4 py-1 text-sm text-muted',
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{fileName}
|
||||
<CopyButton
|
||||
data-cy={dataCy}
|
||||
copyText={copyText}
|
||||
color="link"
|
||||
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
|
||||
indicatorPosition="left"
|
||||
>
|
||||
Copy
|
||||
</CopyButton>
|
||||
</div>
|
||||
);
|
||||
}
|
1
app/react/components/CodeEditor/index.ts
Normal file
1
app/react/components/CodeEditor/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './CodeEditor';
|
91
app/react/components/CodeEditor/useCodeEditorExtensions.ts
Normal file
91
app/react/components/CodeEditor/useCodeEditorExtensions.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
import { useMemo } from 'react';
|
||||
import {
|
||||
StreamLanguage,
|
||||
LanguageSupport,
|
||||
syntaxHighlighting,
|
||||
indentService,
|
||||
} from '@codemirror/language';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import {
|
||||
oneDarkHighlightStyle,
|
||||
keymap,
|
||||
Extension,
|
||||
} from '@uiw/react-codemirror';
|
||||
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 { compact } from 'lodash';
|
||||
import { lineNumbers } from '@codemirror/view';
|
||||
|
||||
export type CodeEditorType = 'yaml' | 'shell' | 'dockerfile';
|
||||
|
||||
// Custom indentation service for YAML
|
||||
const yamlIndentExtension = indentService.of((context, pos) => {
|
||||
const prevLine = context.lineAt(pos, -1);
|
||||
const prevIndent = /^\s*/.exec(prevLine.text)?.[0].length || 0;
|
||||
if (/:\s*$/.test(prevLine.text)) {
|
||||
return prevIndent + 2;
|
||||
}
|
||||
return prevIndent;
|
||||
});
|
||||
|
||||
const dockerFileLanguage = new LanguageSupport(
|
||||
StreamLanguage.define(dockerFile)
|
||||
);
|
||||
const shellLanguage = new LanguageSupport(StreamLanguage.define(shell));
|
||||
|
||||
function yamlLanguage(schema?: JSONSchema7) {
|
||||
const [yaml, linter, , , stateExtensions] = yamlSchema(schema);
|
||||
|
||||
return compact([
|
||||
yaml,
|
||||
linter,
|
||||
stateExtensions,
|
||||
yamlIndentExtension,
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
// explicitly setting lineNumbers() as an extension ensures that the gutter order is the same between the diff viewer and the code editor
|
||||
lineNumbers(),
|
||||
lintGutter(),
|
||||
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
|
||||
// only show completions when a schema is provided
|
||||
!!schema &&
|
||||
autocompletion({
|
||||
icons: false,
|
||||
activateOnTypingDelay: 300,
|
||||
selectOnOpen: true,
|
||||
activateOnTyping: true,
|
||||
override: [
|
||||
(ctx) => {
|
||||
const getCompletions = yamlCompletion();
|
||||
const completions = getCompletions(ctx);
|
||||
if (Array.isArray(completions)) {
|
||||
return null;
|
||||
}
|
||||
completions.validFor = /^\w*$/;
|
||||
return completions;
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export function useCodeEditorExtensions(
|
||||
type?: CodeEditorType,
|
||||
schema?: JSONSchema7
|
||||
): Extension[] {
|
||||
return useMemo(() => {
|
||||
switch (type) {
|
||||
case 'dockerfile':
|
||||
return [dockerFileLanguage];
|
||||
case 'shell':
|
||||
return [shellLanguage];
|
||||
case 'yaml':
|
||||
return yamlLanguage(schema);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}, [type, schema]);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue