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:
parent
dfa32b6755
commit
4ee349bd6b
117 changed files with 4161 additions and 696 deletions
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
75
app/react/components/Blocklist/BlocklistItem.stories.tsx
Normal file
75
app/react/components/Blocklist/BlocklistItem.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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: '';
|
||||
}
|
|
@ -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();
|
||||
});
|
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]);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
159
app/react/components/Sheet.tsx
Normal file
159
app/react/components/Sheet.tsx
Normal 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,
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
|
|
11
app/react/components/Widget/WidgetIcon.tsx
Normal file
11
app/react/components/Widget/WidgetIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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: '',
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -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%);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue