@scality/core-ui 0.200.0 → 0.201.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,295 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { ThemeProvider } from 'styled-components';
3
+ import { EditorView } from '@codemirror/view';
4
+ import { EditorState } from '@codemirror/state';
5
+ import { coreUIAvailableThemes } from '../../style/theme';
6
+ import { createEditorTheme, isDarkBackground } from './editorTheme';
7
+ import {
8
+ Editor,
9
+ isEditAttempt,
10
+ createReadOnlyTooltipExtension,
11
+ } from './Editor.component';
12
+ import React from "react";
13
+
14
+ const mockJsonSchema = jest.fn(() => []);
15
+
16
+ jest.mock('@uiw/react-codemirror', () => {
17
+ const MockCodeMirror = (props: {
18
+ value?: string;
19
+ readOnly?: boolean;
20
+ height?: string;
21
+ width?: string;
22
+ onChange?: (value: string) => void;
23
+ }) => (
24
+ <textarea
25
+ data-testid="codemirror-editor"
26
+ value={props.value}
27
+ readOnly={props.readOnly}
28
+ style={{ height: props.height, width: props.width }}
29
+ onChange={(e) => props.onChange?.(e.target.value)}
30
+ />
31
+ );
32
+ MockCodeMirror.displayName = 'MockCodeMirror';
33
+ return { __esModule: true, default: MockCodeMirror };
34
+ });
35
+
36
+ jest.mock('codemirror-json-schema', () => ({
37
+ jsonSchema: (...args: unknown[]) => (mockJsonSchema as (...a: unknown[]) => unknown).apply(null, args),
38
+ }));
39
+
40
+ const darkTheme = coreUIAvailableThemes.darkRebrand;
41
+ const lightTheme = coreUIAvailableThemes.artescaLight;
42
+
43
+ const renderWithTheme = (ui: React.ReactElement, theme = darkTheme) =>
44
+ render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
45
+
46
+ beforeEach(() => {
47
+ mockJsonSchema.mockClear();
48
+ });
49
+
50
+ describe('isDarkBackground', () => {
51
+ it('returns true for dark themes', () => {
52
+ expect(isDarkBackground(darkTheme)).toBe(true);
53
+ expect(isDarkBackground(coreUIAvailableThemes.ring9dark)).toBe(true);
54
+ expect(isDarkBackground(coreUIAvailableThemes['G-Dark'])).toBe(true);
55
+ });
56
+
57
+ it('returns false for light themes', () => {
58
+ expect(isDarkBackground(lightTheme)).toBe(false);
59
+ });
60
+ });
61
+
62
+ describe('Editor', () => {
63
+ it('renders with dark theme', () => {
64
+ renderWithTheme(<Editor value='{"key": "value"}' />, darkTheme);
65
+ expect(screen.getByTestId('codemirror-editor')).toBeInTheDocument();
66
+ });
67
+
68
+ it('renders with light theme', () => {
69
+ renderWithTheme(<Editor value='{"key": "value"}' />, lightTheme);
70
+ expect(screen.getByTestId('codemirror-editor')).toBeInTheDocument();
71
+ });
72
+
73
+ it('renders in read-only mode', () => {
74
+ renderWithTheme(<Editor value='{"key": "value"}' readOnly />);
75
+ expect(screen.getByTestId('codemirror-editor')).toHaveAttribute('readonly');
76
+ });
77
+
78
+ it('displays the provided value', () => {
79
+ const json = '{"hello": "world"}';
80
+ renderWithTheme(<Editor value={json} />);
81
+ expect(screen.getByTestId('codemirror-editor')).toHaveValue(json);
82
+ });
83
+
84
+ it('renders with custom dimensions', () => {
85
+ renderWithTheme(<Editor value="" height="200px" width="500px" />);
86
+ const editor = screen.getByTestId('codemirror-editor');
87
+ expect(editor).toHaveStyle({ height: '200px', width: '500px' });
88
+ });
89
+
90
+ it('accepts language string shorthand', () => {
91
+ renderWithTheme(<Editor value='{"key": "value"}' language="json" />);
92
+ expect(screen.getByTestId('codemirror-editor')).toBeInTheDocument();
93
+ });
94
+
95
+ it('calls jsonSchema extension when schema is provided', () => {
96
+ const schema = {
97
+ type: 'object' as const,
98
+ properties: { name: { type: 'string' as const } },
99
+ };
100
+ renderWithTheme(
101
+ <Editor value='{"name": "test"}' language={{ name: 'json', schema }} />,
102
+ );
103
+ expect(mockJsonSchema).toHaveBeenCalledWith(schema);
104
+ });
105
+
106
+ it('does not call jsonSchema extension without schema', () => {
107
+ renderWithTheme(<Editor value='{}' language="json" />);
108
+ expect(mockJsonSchema).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it('triggers onChange callback', () => {
112
+ const handleChange = jest.fn();
113
+ renderWithTheme(<Editor value='{"a": 1}' onChange={handleChange} />);
114
+ fireEvent.change(screen.getByTestId('codemirror-editor'), {
115
+ target: { value: '{"a": 2}' },
116
+ });
117
+ expect(handleChange).toHaveBeenCalledWith('{"a": 2}');
118
+ });
119
+ });
120
+
121
+ describe('isEditAttempt', () => {
122
+ const makeEvent = (overrides: Partial<KeyboardEvent> = {}): KeyboardEvent =>
123
+ ({
124
+ ctrlKey: false,
125
+ metaKey: false,
126
+ altKey: false,
127
+ ...overrides,
128
+ }) as unknown as KeyboardEvent;
129
+
130
+ it('detects regular character typing', () => {
131
+ expect(isEditAttempt(makeEvent({ key: 'a' }))).toBe(true);
132
+ expect(isEditAttempt(makeEvent({ key: '1' }))).toBe(true);
133
+ expect(isEditAttempt(makeEvent({ key: ' ' }))).toBe(true);
134
+ expect(isEditAttempt(makeEvent({ key: '{' }))).toBe(true);
135
+ });
136
+
137
+ it('detects edit keys (Backspace, Delete, Enter, Tab)', () => {
138
+ expect(isEditAttempt(makeEvent({ key: 'Backspace' }))).toBe(true);
139
+ expect(isEditAttempt(makeEvent({ key: 'Delete' }))).toBe(true);
140
+ expect(isEditAttempt(makeEvent({ key: 'Enter' }))).toBe(true);
141
+ expect(isEditAttempt(makeEvent({ key: 'Tab' }))).toBe(true);
142
+ });
143
+
144
+ it('detects cut and paste shortcuts', () => {
145
+ expect(isEditAttempt(makeEvent({ key: 'x', ctrlKey: true }))).toBe(true);
146
+ expect(isEditAttempt(makeEvent({ key: 'v', ctrlKey: true }))).toBe(true);
147
+ expect(isEditAttempt(makeEvent({ key: 'x', metaKey: true }))).toBe(true);
148
+ expect(isEditAttempt(makeEvent({ key: 'v', metaKey: true }))).toBe(true);
149
+ });
150
+
151
+ it('ignores copy, select-all, and undo shortcuts', () => {
152
+ expect(isEditAttempt(makeEvent({ key: 'c', ctrlKey: true }))).toBe(false);
153
+ expect(isEditAttempt(makeEvent({ key: 'a', ctrlKey: true }))).toBe(false);
154
+ expect(isEditAttempt(makeEvent({ key: 'z', ctrlKey: true }))).toBe(false);
155
+ });
156
+
157
+ it('ignores navigation and modifier keys', () => {
158
+ expect(isEditAttempt(makeEvent({ key: 'ArrowLeft' }))).toBe(false);
159
+ expect(isEditAttempt(makeEvent({ key: 'ArrowUp' }))).toBe(false);
160
+ expect(isEditAttempt(makeEvent({ key: 'Escape' }))).toBe(false);
161
+ expect(isEditAttempt(makeEvent({ key: 'Shift' }))).toBe(false);
162
+ expect(isEditAttempt(makeEvent({ key: 'Control' }))).toBe(false);
163
+ expect(isEditAttempt(makeEvent({ key: 'F1' }))).toBe(false);
164
+ });
165
+ });
166
+
167
+ describe('createEditorTheme', () => {
168
+ it('returns two extensions for dark theme', () => {
169
+ const result = createEditorTheme(darkTheme);
170
+ expect(Array.isArray(result)).toBe(true);
171
+ expect(result).toHaveLength(2);
172
+ });
173
+
174
+ it('returns two extensions for light theme', () => {
175
+ const result = createEditorTheme(lightTheme);
176
+ expect(Array.isArray(result)).toBe(true);
177
+ expect(result).toHaveLength(2);
178
+ });
179
+
180
+ it('handles all available themes without errors', () => {
181
+ Object.values(coreUIAvailableThemes).forEach((theme) => {
182
+ expect(() => createEditorTheme(theme)).not.toThrow();
183
+ });
184
+ });
185
+ });
186
+
187
+ describe('createReadOnlyTooltipExtension', () => {
188
+ let parent: HTMLDivElement;
189
+
190
+ beforeEach(() => {
191
+ parent = document.createElement('div');
192
+ document.body.appendChild(parent);
193
+ });
194
+
195
+ afterEach(() => {
196
+ parent.remove();
197
+ });
198
+
199
+ it('shows tooltip with ARIA attributes on edit attempt', () => {
200
+ const view = new EditorView({
201
+ state: EditorState.create({
202
+ doc: 'hello',
203
+ extensions: [createReadOnlyTooltipExtension()],
204
+ }),
205
+ parent,
206
+ });
207
+
208
+ jest
209
+ .spyOn(view, 'coordsAtPos')
210
+ .mockReturnValue({ top: 10, bottom: 20, left: 30, right: 40 });
211
+
212
+ view.dom.dispatchEvent(
213
+ new KeyboardEvent('keydown', { key: 'a', bubbles: true }),
214
+ );
215
+
216
+ const tooltip = parent.querySelector('.cm-readonly-tooltip');
217
+ expect(tooltip).not.toBeNull();
218
+ expect(tooltip?.getAttribute('role')).toBe('status');
219
+ expect(tooltip?.getAttribute('aria-live')).toBe('polite');
220
+ expect(tooltip?.textContent).toBe('Cannot edit in read-only editor');
221
+
222
+ view.destroy();
223
+ });
224
+
225
+ it('dismisses tooltip after 2 seconds', () => {
226
+ jest.useFakeTimers();
227
+
228
+ const view = new EditorView({
229
+ state: EditorState.create({
230
+ doc: 'hello',
231
+ extensions: [createReadOnlyTooltipExtension()],
232
+ }),
233
+ parent,
234
+ });
235
+
236
+ jest
237
+ .spyOn(view, 'coordsAtPos')
238
+ .mockReturnValue({ top: 10, bottom: 20, left: 30, right: 40 });
239
+
240
+ view.dom.dispatchEvent(
241
+ new KeyboardEvent('keydown', { key: 'a', bubbles: true }),
242
+ );
243
+ expect(parent.querySelector('.cm-readonly-tooltip')).not.toBeNull();
244
+
245
+ jest.advanceTimersByTime(2000);
246
+ expect(parent.querySelector('.cm-readonly-tooltip')).toBeNull();
247
+
248
+ view.destroy();
249
+ jest.useRealTimers();
250
+ });
251
+
252
+ it('cleans up DOM elements on destroy', () => {
253
+ const view = new EditorView({
254
+ state: EditorState.create({
255
+ doc: 'hello',
256
+ extensions: [createReadOnlyTooltipExtension()],
257
+ }),
258
+ parent,
259
+ });
260
+
261
+ jest
262
+ .spyOn(view, 'coordsAtPos')
263
+ .mockReturnValue({ top: 10, bottom: 20, left: 30, right: 40 });
264
+
265
+ view.dom.dispatchEvent(
266
+ new KeyboardEvent('keydown', { key: 'a', bubbles: true }),
267
+ );
268
+ expect(parent.querySelector('.cm-readonly-tooltip')).not.toBeNull();
269
+
270
+ view.destroy();
271
+ expect(parent.querySelector('.cm-readonly-tooltip')).toBeNull();
272
+ });
273
+
274
+ it('does not show tooltip for navigation keys', () => {
275
+ const view = new EditorView({
276
+ state: EditorState.create({
277
+ doc: 'hello',
278
+ extensions: [createReadOnlyTooltipExtension()],
279
+ }),
280
+ parent,
281
+ });
282
+
283
+ jest
284
+ .spyOn(view, 'coordsAtPos')
285
+ .mockReturnValue({ top: 10, bottom: 20, left: 30, right: 40 });
286
+
287
+ view.dom.dispatchEvent(
288
+ new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }),
289
+ );
290
+
291
+ expect(parent.querySelector('.cm-readonly-tooltip')).toBeNull();
292
+
293
+ view.destroy();
294
+ });
295
+ });
@@ -0,0 +1,126 @@
1
+ import { EditorView } from '@codemirror/view';
2
+ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
3
+ import { tags } from '@lezer/highlight';
4
+ import { getLuminance } from 'polished';
5
+ import type { Extension } from '@codemirror/state';
6
+ import type { CoreUITheme } from '../../style/theme';
7
+ import { lineColor5 } from '../../style/theme';
8
+
9
+ export function isDarkBackground(theme: CoreUITheme): boolean {
10
+ try {
11
+ return getLuminance(theme.backgroundLevel1) < 0.5;
12
+ } catch {
13
+ return true;
14
+ }
15
+ }
16
+
17
+ function getSyntaxColors(theme: CoreUITheme, isDark: boolean) {
18
+ if (isDark) {
19
+ return {
20
+ property: theme.statusHealthy,
21
+ string: lineColor5,
22
+ number: lineColor5,
23
+ boolean: lineColor5,
24
+ null: theme.textSecondary,
25
+ bracket: theme.textPrimary,
26
+ punctuation: theme.textPrimary,
27
+ };
28
+ }
29
+ return {
30
+ property: '#4078F2',
31
+ string: '#50A14F',
32
+ number: '#986801',
33
+ boolean: '#0184BC',
34
+ null: '#0184BC',
35
+ bracket: '#383A42',
36
+ punctuation: '#383A42',
37
+ };
38
+ }
39
+
40
+ export function createEditorTheme(theme: CoreUITheme): Extension {
41
+ const isDark = isDarkBackground(theme);
42
+ const syntax = getSyntaxColors(theme, isDark);
43
+
44
+ const editorViewTheme = EditorView.theme(
45
+ {
46
+ '&': {
47
+ backgroundColor: theme.backgroundLevel1,
48
+ color: theme.textPrimary,
49
+ },
50
+ '.cm-content': {
51
+ caretColor: theme.textPrimary,
52
+ fontFamily: "'Courier New', monospace",
53
+ fontSize: '12px',
54
+ },
55
+ '.cm-cursor, .cm-dropCursor': {
56
+ borderLeftColor: theme.textPrimary,
57
+ },
58
+ '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
59
+ {
60
+ backgroundColor: theme.highlight,
61
+ },
62
+ '.cm-gutters': {
63
+ backgroundColor: theme.backgroundLevel1,
64
+ color: theme.textSecondary,
65
+ border: 'none',
66
+ borderRight: `1px solid ${theme.border}`,
67
+ },
68
+ '.cm-activeLineGutter': {
69
+ backgroundColor: theme.backgroundLevel3,
70
+ },
71
+ '.cm-activeLine': {
72
+ backgroundColor: theme.backgroundLevel3,
73
+ },
74
+ '.cm-foldPlaceholder': {
75
+ backgroundColor: 'transparent',
76
+ border: 'none',
77
+ color: theme.textSecondary,
78
+ },
79
+ '.cm-diagnostic-error': {
80
+ borderLeftColor: theme.statusCritical,
81
+ },
82
+ '.cm-diagnostic-warning': {
83
+ borderLeftColor: theme.statusWarning,
84
+ },
85
+ '.cm-lintRange-error': {
86
+ backgroundImage: 'none',
87
+ textDecoration: `underline wavy ${theme.statusCritical}`,
88
+ },
89
+ '.cm-lintRange-warning': {
90
+ backgroundImage: 'none',
91
+ textDecoration: `underline wavy ${theme.statusWarning}`,
92
+ },
93
+ '.cm-tooltip': {
94
+ backgroundColor: theme.backgroundLevel3,
95
+ color: theme.textPrimary,
96
+ border: `1px solid ${theme.border}`,
97
+ },
98
+ '.cm-tooltip-lint': {
99
+ backgroundColor: theme.backgroundLevel3,
100
+ },
101
+ '.cm-panels': {
102
+ backgroundColor: theme.backgroundLevel4,
103
+ color: theme.textPrimary,
104
+ },
105
+ '.cm-readonly-tooltip': {
106
+ backgroundColor: theme.backgroundLevel3,
107
+ color: theme.textPrimary,
108
+ border: `1px solid ${theme.border}`,
109
+ },
110
+ },
111
+ { dark: isDark },
112
+ );
113
+
114
+ const highlightStyle = HighlightStyle.define([
115
+ { tag: tags.propertyName, color: syntax.property },
116
+ { tag: tags.string, color: syntax.string },
117
+ { tag: tags.number, color: syntax.number },
118
+ { tag: tags.bool, color: syntax.boolean },
119
+ { tag: tags.null, color: syntax.null },
120
+ { tag: tags.punctuation, color: syntax.punctuation },
121
+ { tag: tags.brace, color: syntax.bracket },
122
+ { tag: tags.squareBracket, color: syntax.bracket },
123
+ ]);
124
+
125
+ return [editorViewTheme, syntaxHighlighting(highlightStyle)];
126
+ }
@@ -0,0 +1,2 @@
1
+ export { Editor } from './Editor.component';
2
+ export type { EditorProps } from './Editor.component';
@@ -23,7 +23,6 @@ const HelpButton = styled.button`
23
23
  border: none;
24
24
  padding: 0;
25
25
  margin: 0;
26
- cursor: pointer;
27
26
  color: inherit;
28
27
  font: inherit; /* Inherit font sizing */
29
28
  vertical-align: text-bottom; /* Align with text */
package/src/lib/next.ts CHANGED
@@ -17,6 +17,8 @@ export { CoreUiThemeProvider } from './components/coreuithemeprovider/CoreUiThem
17
17
  export { Box } from './components/box/Box';
18
18
  export { Input } from './components/inputv2/inputv2';
19
19
  export { Accordion } from './components/accordion/Accordion.component';
20
+ export { Editor } from './components/editor';
21
+ export type { EditorProps } from './components/editor';
20
22
 
21
23
  // Export all chart components from the consolidated charts folder
22
24
  export {
@@ -0,0 +1,132 @@
1
+ import React, { useState } from 'react';
2
+ import { Editor, EditorProps } from '../src/lib/next';
3
+
4
+ const sampleJson = JSON.stringify(
5
+ {
6
+ Version: '2012-10-17',
7
+ Statement: [
8
+ {
9
+ Sid: 'ExampleStatement',
10
+ Effect: 'Allow',
11
+ Principal: '*',
12
+ Action: 's3:GetObject',
13
+ Resource: 'arn:aws:s3:::my-bucket/*',
14
+ },
15
+ ],
16
+ },
17
+ null,
18
+ 2,
19
+ );
20
+
21
+ const invalidJson = JSON.stringify(
22
+ {
23
+ Version: 'invalid-version',
24
+ Statement: [
25
+ {
26
+ Sid: 'Test',
27
+ Effect: 'InvalidEffect',
28
+ Principal: '*',
29
+ Action: 's3:GetObject',
30
+ Resource: 'arn:aws:s3:::my-bucket/*',
31
+ },
32
+ ],
33
+ },
34
+ null,
35
+ 2,
36
+ );
37
+
38
+ const policySchema = {
39
+ type: 'object' as const,
40
+ required: ['Version', 'Statement'],
41
+ properties: {
42
+ Version: {
43
+ type: 'string' as const,
44
+ enum: ['2012-10-17', '2008-10-17'],
45
+ },
46
+ Statement: {
47
+ type: 'array' as const,
48
+ minItems: 1,
49
+ items: {
50
+ type: 'object' as const,
51
+ required: ['Effect', 'Principal', 'Action', 'Resource'],
52
+ properties: {
53
+ Sid: { type: 'string' as const },
54
+ Effect: { type: 'string' as const, enum: ['Allow', 'Deny'] },
55
+ Principal: { type: 'string' as const },
56
+ Action: { type: 'string' as const },
57
+ Resource: { type: 'string' as const },
58
+ },
59
+ additionalProperties: false,
60
+ },
61
+ },
62
+ },
63
+ };
64
+
65
+ export default {
66
+ title: 'Components/Inputs/Editor',
67
+ component: Editor,
68
+ argTypes: {
69
+ readOnly: { control: 'boolean' },
70
+ height: { control: 'text' },
71
+ width: { control: 'text' },
72
+ },
73
+ };
74
+
75
+ const EditorWithState = (args: EditorProps) => {
76
+ const [value, setValue] = useState(args.value || sampleJson);
77
+ return <Editor {...args} value={value} onChange={setValue} />;
78
+ };
79
+
80
+ export const Default = {
81
+ render: (args: EditorProps) => <EditorWithState {...args} />,
82
+ args: {
83
+ value: sampleJson,
84
+ height: '400px',
85
+ width: '100%',
86
+ },
87
+ };
88
+
89
+ export const ReadOnly = {
90
+ render: (args: EditorProps) => <EditorWithState {...args} />,
91
+ args: {
92
+ value: sampleJson,
93
+ readOnly: true,
94
+ height: '400px',
95
+ },
96
+ };
97
+
98
+ /**
99
+ * Pass `language` as an object with a `schema` to enable
100
+ * validation, autocompletion, and hover tooltips from the schema.
101
+ *
102
+ * Hover over the red-underlined values to see error messages.
103
+ */
104
+ export const WithSchemaValidation = {
105
+ render: (args: EditorProps) => <EditorWithState {...args} />,
106
+ args: {
107
+ value: invalidJson,
108
+ language: { name: 'json', schema: policySchema },
109
+ height: '400px',
110
+ },
111
+ };
112
+
113
+ /**
114
+ * Valid JSON with schema — no errors shown, but autocompletion is active.
115
+ */
116
+ export const ValidWithSchema = {
117
+ render: (args: EditorProps) => <EditorWithState {...args} />,
118
+ args: {
119
+ value: sampleJson,
120
+ language: { name: 'json', schema: policySchema },
121
+ height: '400px',
122
+ },
123
+ };
124
+
125
+ export const CustomDimensions = {
126
+ render: (args: EditorProps) => <EditorWithState {...args} />,
127
+ args: {
128
+ value: '{\n "compact": true\n}',
129
+ height: '200px',
130
+ width: '400px',
131
+ },
132
+ };