1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 15:59:41 +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:
Ali 2025-05-13 22:15:04 +12:00 committed by GitHub
parent dfa32b6755
commit 4ee349bd6b
117 changed files with 4161 additions and 696 deletions

View file

@ -18,6 +18,7 @@ export default {
'dangerSecondary',
'warnSecondary',
'infoSecondary',
'muted',
],
},
},
@ -35,6 +36,7 @@ function Template({ type = 'success' }: Props) {
dangerSecondary: 'dangerSecondary badge',
warnSecondary: 'warnSecondary badge',
infoSecondary: 'infoSecondary badge',
muted: 'muted badge',
};
return <Badge type={type}>{message[type]}</Badge>;
}

View file

@ -9,7 +9,8 @@ export type BadgeType =
| 'successSecondary'
| 'dangerSecondary'
| 'warnSecondary'
| 'infoSecondary';
| 'infoSecondary'
| 'muted';
// the classes are typed in full because tailwind doesn't render the interpolated classes
const typeClasses: Record<BadgeType, string> = {
@ -54,6 +55,11 @@ const typeClasses: Record<BadgeType, string> = {
'th-dark:text-blue-3 th-dark:bg-blue-9',
'th-highcontrast:text-blue-3 th-highcontrast:bg-blue-9'
),
muted: clsx(
'text-gray-9 bg-gray-3',
'th-dark:text-gray-3 th-dark:bg-gray-9',
'th-highcontrast:text-gray-3 th-highcontrast:bg-gray-9'
),
};
export interface Props {

View file

@ -0,0 +1,75 @@
import type { Meta, StoryObj } from '@storybook/react';
import { localizeDate } from '@/react/common/date-utils';
import { Badge } from '@@/Badge';
import { BlocklistItem } from './BlocklistItem';
const meta: Meta<typeof BlocklistItem> = {
title: 'Components/Blocklist/BlocklistItem',
component: BlocklistItem,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="blocklist">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof BlocklistItem>;
export const Default: Story = {
args: {
children: 'Default Blocklist Item',
},
};
export const Selected: Story = {
args: {
children: 'Selected Blocklist Item',
isSelected: true,
},
};
export const AsDiv: Story = {
args: {
children: 'Blocklist Item as div',
as: 'div',
},
};
export const WithCustomContent: Story = {
args: {
children: (
<div className="flex flex-col gap-2 w-full">
<div className="flex flex-wrap gap-1 justify-between">
<Badge type="success">Deployed</Badge>
<span className="text-xs text-muted">Revision #4</span>
</div>
<div className="flex flex-wrap gap-1 justify-between">
<span className="text-xs text-muted">my-app-1.0.0</span>
<span className="text-xs text-muted">
{localizeDate(new Date('2000-01-01'))}
</span>
</div>
</div>
),
},
};
export const MultipleItems: Story = {
render: () => (
<div className="blocklist">
<BlocklistItem>First Item</BlocklistItem>
<BlocklistItem isSelected>Second Item (Selected)</BlocklistItem>
<BlocklistItem>Third Item</BlocklistItem>
</div>
),
};

View file

@ -10,7 +10,7 @@ export function Card({ className, children }: PropsWithChildren<Props>) {
<div
className={clsx(
className,
'rounded border border-solid border-gray-5 bg-gray-neutral-3 p-5 th-highcontrast:border-white th-highcontrast:bg-black th-dark:border-legacy-grey-3 th-dark:bg-gray-iron-11'
'rounded-lg border border-solid border-gray-5 bg-gray-neutral-3 p-5 th-highcontrast:border-white th-highcontrast:bg-black th-dark:border-legacy-grey-3 th-dark:bg-gray-iron-11'
)}
>
{children}

View file

@ -1,228 +0,0 @@
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';
import { CopyButton } from '@@/buttons/CopyButton';
import { useDebounce } from '../hooks/useDebounce';
import styles from './CodeEditor.module.css';
import { TextTip } from './Tip/TextTip';
import { StackVersionSelector } from './StackVersionSelector';
type Type = 'yaml' | 'shell' | 'dockerfile';
interface Props extends AutomationTestingProps {
id: string;
placeholder?: string;
type?: Type;
readonly?: boolean;
onChange?: (value: string) => void;
value: string;
height?: string;
versions?: number[];
onVersionChange?: (version: number) => void;
schema?: JSONSchema7;
}
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)',
gutterBackground: 'var(--bg-codemirror-gutters-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)',
},
],
});
// 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: 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;
}
completions.validFor = /^\w*$/;
return completions;
},
],
}),
stateExtensions,
yamlIndentExtension,
syntaxHighlighting(oneDarkHighlightStyle),
lintGutter(),
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
];
}
export function CodeEditor({
id,
onChange = () => {},
placeholder,
readonly,
value,
versions,
onVersionChange,
height = '500px',
type,
schema,
'data-cy': dataCy,
}: Props) {
const [isRollback, setIsRollback] = useState(false);
const extensions = useMemo(() => {
if (!type || !docTypeExtensionMap[type]) {
return [];
}
// YAML-specific schema validation
if (schema && type === 'yaml') {
return schemaValidationExtensions(schema);
}
// Default language support
return [docTypeExtensionMap[type]];
}, [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">
{!!placeholder && <TextTip color="blue">{placeholder}</TextTip>}
</div>
<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 to clipboard
</CopyButton>
</div>
</div>
{versions && (
<div className="mt-2 flex">
<div className="ml-auto mr-2">
<StackVersionSelector
versions={versions}
onChange={handleVersionChange}
/>
</div>
</div>
)}
</div>
<CodeMirror
className={styles.root}
theme={theme}
value={debouncedValue}
onChange={debouncedOnChange}
readOnly={readonly || isRollback}
id={id}
extensions={extensions}
height={height}
basicSetup={{
highlightSelectionMatches: false,
autocompletion: !!schema,
}}
data-cy={dataCy}
/>
</>
);
}

View file

@ -47,17 +47,33 @@
.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;
}
.root :global(.cm-editor),
.root :global(.cm-editor .cm-scroller) {
.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 */
@ -162,3 +178,29 @@
.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: '';
}

View file

@ -1,9 +1,21 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Extension } from '@codemirror/state';
import { CodeEditor } from './CodeEditor';
vi.mock('yaml-schema', () => ({}));
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',
@ -24,7 +36,7 @@ test('should render with basic props', () => {
test('should display placeholder when provided', async () => {
const placeholder = 'Enter your code here';
const { findByText } = render(
<CodeEditor {...defaultProps} placeholder={placeholder} />
<CodeEditor {...defaultProps} textTip={placeholder} />
);
const placeholderText = await findByText(placeholder);
@ -44,7 +56,7 @@ test('should show copy button and copy content', async () => {
clipboard: mockClipboard,
});
const copyButton = await findByText('Copy to clipboard');
const copyButton = await findByText('Copy');
expect(copyButton).toBeVisible();
await userEvent.click(copyButton);
@ -113,3 +125,14 @@ test('should apply custom height', async () => {
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();
});

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

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

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

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

View file

@ -0,0 +1 @@
export * from './CodeEditor';

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

View file

@ -13,21 +13,21 @@ export type Props = {
};
const sizeStyles: Record<Size, string> = {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-md',
xs: 'text-xs gap-1',
sm: 'text-sm gap-2',
md: 'text-md gap-2',
};
export function InlineLoader({ children, className, size = 'sm' }: Props) {
return (
<div
className={clsx(
'text-muted flex items-center gap-2',
'text-muted flex items-center',
className,
sizeStyles[size]
)}
>
<Icon icon={Loader2} className="animate-spin-slow" />
<Icon icon={Loader2} className="animate-spin-slow flex-none" />
{children}
</div>
);

View file

@ -1,10 +1,19 @@
import { Option } from '@@/form-components/PortainerSelect';
import { ReactNode } from 'react';
// allow custom labels
export interface RadioGroupOption<TValue> {
value: TValue;
label: ReactNode;
disabled?: boolean;
}
interface Props<T extends string | number> {
options: Array<Option<T>> | ReadonlyArray<Option<T>>;
options: Array<RadioGroupOption<T>> | ReadonlyArray<RadioGroupOption<T>>;
selectedOption: T;
name: string;
onOptionChange: (value: T) => void;
groupClassName?: string;
itemClassName?: string;
}
export function RadioGroup<T extends string | number = string>({
@ -12,13 +21,18 @@ export function RadioGroup<T extends string | number = string>({
selectedOption,
name,
onOptionChange,
groupClassName,
itemClassName,
}: Props<T>) {
return (
<div className="flex flex-wrap gap-x-2 gap-y-1">
<div className={groupClassName ?? 'flex flex-wrap gap-x-2 gap-y-1'}>
{options.map((option) => (
<label
key={option.value}
className="col-sm-3 col-lg-2 control-label !p-0 text-left font-normal"
className={
itemClassName ??
'col-sm-3 col-lg-2 control-label !p-0 text-left font-normal'
}
>
<input
type="radio"
@ -28,6 +42,7 @@ export function RadioGroup<T extends string | number = string>({
onChange={() => onOptionChange(option.value)}
style={{ margin: '0 4px 0 0' }}
data-cy={`radio-${option.value}`}
disabled={option.disabled}
/>
{option.label}
</label>

View file

@ -0,0 +1,159 @@
import {
ComponentPropsWithoutRef,
forwardRef,
ElementRef,
PropsWithChildren,
} from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva, type VariantProps } from 'class-variance-authority';
import clsx from 'clsx';
import { RefreshCw, X } from 'lucide-react';
import { Button } from './buttons';
// modified from shadcn sheet component
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetDescription = SheetPrimitive.Description;
type SheetTitleProps = {
title: string;
onReload?(): Promise<void> | void;
};
// similar to the PageHeader component with simplified props and no breadcrumbs
function SheetHeader({
onReload,
title,
children,
}: PropsWithChildren<SheetTitleProps>) {
return (
<div className="row">
<div className="col-sm-12 pt-3 flex gap-2 justify-between">
<div className="flex items-center gap-2">
<SheetPrimitive.DialogTitle className="m-0 text-2xl font-medium text-gray-11 th-highcontrast:text-white th-dark:text-white">
{title}
</SheetPrimitive.DialogTitle>
{onReload ? (
<Button
color="none"
size="large"
onClick={onReload}
className="m-0 p-0 focus:text-inherit"
title="Refresh drawer content"
data-cy="sheet-refreshButton"
>
<RefreshCw className="icon" />
</Button>
) : null}
</div>
{children}
</div>
</div>
);
}
const SheetOverlay = forwardRef<
ElementRef<typeof SheetPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={clsx(
'fixed inset-0 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed gap-4 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-[70vw] lg:w-[50vw] border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left max-w-2xl',
right:
'inset-y-0 right-0 h-full w-[70vw] lg:w-[50vw] border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right max-w-2xl',
},
},
defaultVariants: {
side: 'right',
},
}
);
interface SheetContentProps
extends ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {
showCloseButton?: boolean;
}
const SheetContent = forwardRef<
ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(
(
{
side = 'right',
className,
children,
title,
showCloseButton = true,
...props
},
ref
) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={clsx(sheetVariants({ side }), className)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
>
{title ? <SheetHeader title={title} /> : null}
{children}
{showCloseButton && (
<SheetPrimitive.Close
asChild
className="absolute close-button right-9 top-8 disabled:pointer-events-none"
>
<Button
icon={X}
color="none"
className="btn-only-icon"
size="medium"
data-cy="sheet-closeButton"
>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
};

View file

@ -74,6 +74,7 @@ export function WebEditorForm({
children,
error,
schema,
textTip,
...props
}: PropsWithChildren<Props>) {
return (
@ -99,6 +100,7 @@ export function WebEditorForm({
id={id}
type="yaml"
schema={schema as JSONSchema7}
textTip={textTip}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>

View file

@ -0,0 +1,11 @@
import { ReactNode } from 'react';
import { Icon } from '@@/Icon';
export function WidgetIcon({ icon }: { icon: ReactNode }) {
return (
<div className="text-lg inline-flex items-center rounded-full bg-blue-3 text-blue-8 th-dark:bg-gray-9 th-dark:text-blue-3 p-2">
<Icon icon={icon} />
</div>
);
}

View file

@ -1,9 +1,7 @@
import clsx from 'clsx';
import { PropsWithChildren, ReactNode } from 'react';
import { Icon } from '@/react/components/Icon';
import { useWidgetContext } from './Widget';
import { WidgetIcon } from './WidgetIcon';
interface Props {
title: ReactNode;
@ -17,16 +15,12 @@ export function WidgetTitle({
className,
children,
}: PropsWithChildren<Props>) {
useWidgetContext();
return (
<div className="widget-header">
<div className="row">
<span className={clsx('pull-left vertical-center', className)}>
<div className="widget-icon">
<Icon icon={icon} className="space-right" />
</div>
<h2 className="text-base m-0">{title}</h2>
<WidgetIcon icon={icon} />
<h2 className="text-base m-0 ml-1">{title}</h2>
</span>
<span className={clsx('pull-right', className)}>{children}</span>
</div>

View file

@ -26,13 +26,13 @@ function Template({
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
children: 'Copy to clipboard',
children: 'Copy',
copyText: 'this will be copied to clipboard',
};
export const NoCopyText: Story<PropsWithChildren<Props>> = Template.bind({});
NoCopyText.args = {
children: 'Copy to clipboard without copied text',
children: 'Copy without copied text',
copyText: 'clipboard override',
displayText: '',
};

View file

@ -58,7 +58,7 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
getRowId?(row: D): string;
isRowSelectable?(row: Row<D>): boolean;
emptyContentLabel?: string;
title?: string;
title?: React.ReactNode;
titleIcon?: IconProps['icon'];
titleId?: string;
initialTableState?: Partial<TableState>;
@ -71,6 +71,8 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
noWidget?: boolean;
extendTableOptions?: (options: TableOptions<D>) => TableOptions<D>;
includeSearch?: boolean;
ariaLabel?: string;
id?: string;
}
export function Datatable<D extends DefaultType>({
@ -100,6 +102,8 @@ export function Datatable<D extends DefaultType>({
isServerSidePagination = false,
extendTableOptions = (value) => value,
includeSearch,
ariaLabel,
id,
}: Props<D> & PaginationProps) {
const pageCount = useMemo(
() => Math.ceil(totalCount / settings.pageSize),
@ -181,9 +185,14 @@ export function Datatable<D extends DefaultType>({
() => _.difference(selectedItems, filteredItems),
[selectedItems, filteredItems]
);
const { titleAriaLabel, contentAriaLabel } = getAriaLabels(
ariaLabel,
title,
titleId
);
return (
<Table.Container noWidget={noWidget} aria-label={title}>
<Table.Container noWidget={noWidget} aria-label={titleAriaLabel} id={id}>
<DatatableHeader
onSearchChange={handleSearchBarChange}
searchValue={settings.search}
@ -204,7 +213,7 @@ export function Datatable<D extends DefaultType>({
isLoading={isLoading}
onSortChange={handleSortChange}
data-cy={dataCy}
aria-label={`${title} table`}
aria-label={contentAriaLabel}
/>
<DatatableFooter
@ -239,6 +248,23 @@ export function Datatable<D extends DefaultType>({
}
}
function getAriaLabels(
titleAriaLabel?: string,
title?: ReactNode,
titleId?: string
) {
if (titleAriaLabel) {
return { titleAriaLabel, contentAriaLabel: `${titleAriaLabel} table` };
}
if (typeof title === 'string') {
return { titleAriaLabel: title, contentAriaLabel: `${title} table` };
}
if (titleId) {
return { titleAriaLabel: titleId, contentAriaLabel: `${titleId} table` };
}
return { titleAriaLabel: 'table', contentAriaLabel: 'table' };
}
function defaultRenderRow<D extends DefaultType>(
row: Row<D>,
highlightedItemId?: string

View file

@ -8,7 +8,7 @@ import { SearchBar } from './SearchBar';
import { Table } from './Table';
type Props = {
title?: string;
title?: React.ReactNode;
titleIcon?: IconProps['icon'];
searchValue: string;
onSearchChange(value: string): void;
@ -52,7 +52,7 @@ export function DatatableHeader({
return (
<Table.Title
id={titleId}
label={title ?? ''}
label={title}
icon={titleIcon}
description={description}
data-cy={dataCy}

View file

@ -6,12 +6,14 @@ interface Props {
// workaround to remove the widget, ideally we should have a different component to wrap the table with a widget
noWidget?: boolean;
'aria-label'?: string;
id?: string;
}
export function TableContainer({
children,
noWidget = false,
'aria-label': ariaLabel,
id,
}: PropsWithChildren<Props>) {
if (noWidget) {
return (
@ -25,7 +27,7 @@ export function TableContainer({
<div className="row">
<div className="col-sm-12">
<div className="datatable">
<Widget aria-label={ariaLabel}>
<Widget aria-label={ariaLabel} id={id}>
<WidgetBody className="no-padding">{children}</WidgetBody>
</Widget>
</div>

View file

@ -5,7 +5,7 @@ import { Icon } from '@@/Icon';
interface Props {
icon?: ReactNode | ComponentType<unknown>;
label: string;
label: React.ReactNode;
description?: ReactNode;
className?: string;
id?: string;

View file

@ -43,7 +43,7 @@ export function AdvancedMode({
id="environment-variables-editor"
value={editorValue}
onChange={handleEditorChange}
placeholder="e.g. key=value"
textTip="e.g. key=value"
data-cy={dataCy}
/>
</>

View file

@ -10,14 +10,8 @@
.modal-content {
background-color: var(--bg-modal-content-color);
padding: 20px;
position: relative;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 6px;
outline: 0;
box-shadow: 0 5px 15px rgb(0 0 0 / 50%);
}

View file

@ -39,7 +39,7 @@ export function Modal({
isOpen
className={clsx(
styles.overlay,
'z-50 flex items-center justify-center'
'flex items-center justify-center z-50'
)}
onDismiss={onDismiss}
role="dialog"
@ -56,7 +56,13 @@ export function Modal({
}
)}
>
<div className={clsx(styles.modalContent, 'relative', className)}>
<div
className={clsx(
styles.modalContent,
'relative overflow-y-auto p-5 rounded-lg',
className
)}
>
{children}
{onDismiss && <CloseButton onClose={onDismiss} />}
</div>