diff --git a/src/components/Pega_Extensions_JsonEditor/Docs.mdx b/src/components/Pega_Extensions_JsonEditor/Docs.mdx new file mode 100644 index 00000000..7741103b --- /dev/null +++ b/src/components/Pega_Extensions_JsonEditor/Docs.mdx @@ -0,0 +1,16 @@ +import { Meta, Story } from '@storybook/addon-docs/blocks'; +import * as Stories from './demo.stories'; + + + +# JSON editor + +`Pega_Extensions_JsonEditor` provides a multiline JSON field with syntax highlighting, validation, formatting, minification, copying, and clearing actions. + +## Example + + + +## Usage + +Use this component for editable JSON object or array values. The component updates the bound field through `getPConnect().getActionsApi()` and supports display-only and labels-left display modes. diff --git a/src/components/Pega_Extensions_JsonEditor/config.json b/src/components/Pega_Extensions_JsonEditor/config.json new file mode 100644 index 00000000..f60af8fd --- /dev/null +++ b/src/components/Pega_Extensions_JsonEditor/config.json @@ -0,0 +1,175 @@ +{ + "name": "Pega_Extensions_JsonEditor", + "label": "JSON editor", + "description": "A multiline editor for JSON values with validation and formatting.", + "organization": "Pega", + "version": "5.0.4", + "library": "Extensions", + "allowedApplications": [], + "componentKey": "Pega_Extensions_JsonEditor", + "type": "Field", + "subtype": "Text-Paragraph", + "subtypeLabel": "Paragraph", + "icon": "images/pz-text-area-active.svg", + "properties": [ + { + "name": "labelOption", + "label": "Label value", + "format": "SELECT", + "defaultValue": "custom", + "source": [ + { + "key": "default", + "value": "Default" + }, + { + "key": "custom", + "value": "Custom" + } + ] + }, + { + "name": "label", + "label": "Custom label value", + "format": "VALUEINPUT", + "required": true, + "ruleType": "Paragraph" + }, + { + "name": "readOnly", + "label": "Edit mode", + "format": "READONLY" + }, + { + "label": "Input settings", + "format": "GROUP", + "visibility": "(!readOnly = true)", + "properties": [ + { + "name": "placeholder", + "label": "Placeholder", + "format": "TEXT", + "defaultValue": "{\n \"title\": \"Constellation\",\n \"count\": 3,\n \"enabled\": true,\n \"notes\": null,\n \"tags\": [\"json\", \"editor\"],\n \"settings\": {\n \"theme\": \"light\"\n }\n}" + }, + { + "name": "helperText", + "label": "Helper text", + "format": "TEXT", + "defaultValue": "Supports objects, arrays, strings, numbers, booleans, and null." + } + ] + }, + { + "label": "Conditions", + "format": "GROUP", + "properties": [ + { + "name": "required", + "label": "Required", + "format": "REQUIRED", + "visibility": "(!readOnly = true)" + }, + { + "name": "disabled", + "label": "Disabled", + "format": "DISABLED", + "visibility": "(!readOnly = true)" + }, + { + "name": "visibility", + "label": "Visibility", + "format": "VISIBILITY" + } + ] + }, + { + "name": "indent", + "label": "Indent spaces", + "format": "TEXT" + }, + { + "label": "Editor actions", + "format": "GROUP", + "collapsible": true, + "properties": [ + { + "name": "showValidate", + "label": "Show Validate action", + "format": "BOOLEAN" + }, + { + "name": "showFormat", + "label": "Show Format action", + "format": "BOOLEAN" + }, + { + "name": "showMinify", + "label": "Show Minify action", + "format": "BOOLEAN" + }, + { + "name": "showCopy", + "label": "Show Copy action", + "format": "BOOLEAN" + }, + { + "name": "showClear", + "label": "Show Clear action", + "format": "BOOLEAN" + }, + { + "name": "showCharacterCount", + "label": "Show character count", + "format": "BOOLEAN" + }, + { + "name": "showLineCount", + "label": "Show line count", + "format": "BOOLEAN" + } + ] + }, + { + "label": "Advanced", + "format": "GROUP", + "collapsible": true, + "properties": [ + { + "name": "testId", + "label": "Test ID", + "format": "TEXT", + "ignorePattern": "[^-_\\p{N}\\p{L}]", + "includeAnnotations": false + } + ] + }, + { + "format": "LABEL", + "label": "About", + "name": "PegaAboutLabel", + "variant": "h3" + }, + { + "format": "LABEL", + "label": "Pega_Extensions 5.0.4", + "name": "PegaAboutData", + "variant": "primary" + } + ], + "defaultConfig": { + "label": "@L $this.label", + "indent": "2", + "showValidate": true, + "showFormat": true, + "showMinify": true, + "showCopy": true, + "showClear": true, + "showCharacterCount": true, + "showLineCount": true, + "detailFVLItem": true, + "isFormWidth": false, + "isContainerWidth": false + }, + "infinityVersion": "26.1.1", + "packageCosmosVersion": "9.25.0" +} diff --git a/src/components/Pega_Extensions_JsonEditor/demo.stories.tsx b/src/components/Pega_Extensions_JsonEditor/demo.stories.tsx new file mode 100644 index 00000000..3bff141b --- /dev/null +++ b/src/components/Pega_Extensions_JsonEditor/demo.stories.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import type { Meta, StoryFn } from '@storybook/react-webpack5'; + +import { configProps, stateProps } from './mock'; +import { PegaExtensionsJsonEditor } from './index'; + +const meta: Meta = { + title: 'Fields/JSON editor', + component: PegaExtensionsJsonEditor, + excludeStories: /.*Data$/, + argTypes: { + getPConnect: { + table: { + disable: true, + }, + }, + displayMode: { + options: ['', 'DISPLAY_ONLY', 'LABELS_LEFT'], + control: { + type: 'select', + }, + }, + }, +}; + +export default meta; + +export const BaseJsonEditor: StoryFn = (args) => { + const [value, setValue] = useState(configProps.value); + + const props = { + value, + getPConnect: () => + ({ + getStateProps: () => stateProps, + getActionsApi: () => ({ + updateFieldValue: (_propName: string, nextValue: string) => setValue(nextValue), + }), + getLocalizedValue: (text: string) => text, + }) as unknown as typeof PConnect, + }; + + return ; +}; + +BaseJsonEditor.args = { + label: configProps.label, + value: configProps.value, + helperText: configProps.helperText, + placeholder: configProps.placeholder, + testId: configProps.testId, + readOnly: configProps.readOnly, + disabled: configProps.disabled, + required: configProps.required, + status: configProps.status, + hideLabel: configProps.hideLabel, + displayMode: configProps.displayMode, + validatemessage: configProps.validatemessage, +}; diff --git a/src/components/Pega_Extensions_JsonEditor/demo.test.tsx b/src/components/Pega_Extensions_JsonEditor/demo.test.tsx new file mode 100644 index 00000000..4d974b67 --- /dev/null +++ b/src/components/Pega_Extensions_JsonEditor/demo.test.tsx @@ -0,0 +1,118 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { composeStories } from '@storybook/react-webpack5'; +import '@testing-library/jest-dom'; + +import * as DemoStories from './demo.stories'; +import { configProps } from './mock'; + +const { BaseJsonEditor } = composeStories(DemoStories); + +test('renders JsonEditor', async () => { + render(); + + expect(await screen.findByRole('textbox', { name: 'JSON value' })).toHaveValue(configProps.value); + expect(screen.getByRole('button', { name: 'Format JSON' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Validate JSON' })).toContainElement( + screen.getByTestId('JsonEditor-12345678:action:validate').querySelector('[data-icon-name="check"]'), + ); + expect(screen.getByTestId('JsonEditor-12345678:action:format')).toContainElement( + screen.getByTestId('JsonEditor-12345678:action:format').querySelector('[data-icon-name="align-left"]'), + ); + expect(screen.getByTestId('JsonEditor-12345678:action:minify')).toContainElement( + screen.getByTestId('JsonEditor-12345678:action:minify').querySelector('[data-icon-name="code"]'), + ); + expect(screen.getByTestId('JsonEditor-12345678:action:copy')).toContainElement( + screen.getByTestId('JsonEditor-12345678:action:copy').querySelector('[data-icon-name="copy"]'), + ); + expect(screen.getByTestId('JsonEditor-12345678:action:clear')).toContainElement( + screen.getByTestId('JsonEditor-12345678:action:clear').querySelector('[data-icon-name="trash"]'), + ); +}); + +test('shows a placeholder covering all JSON value types', async () => { + render(); + + expect(await screen.findByRole('textbox', { name: 'JSON value' })).toHaveAttribute( + 'placeholder', + configProps.placeholder, + ); +}); + +test('formats valid JSON', async () => { + render(); + + const editor = await screen.findByRole('textbox', { name: 'JSON value' }); + fireEvent.change(editor, { target: { value: '{"name":"Pega"}' } }); + fireEvent.click(screen.getByRole('button', { name: 'Format JSON' })); + + expect(editor).toHaveValue('{\n "name": "Pega"\n}'); +}); + +test('honors authoring visibility settings for actions and counters', async () => { + render(); + + expect(screen.queryByRole('button', { name: 'Validate JSON' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Copy JSON' })).not.toBeInTheDocument(); + expect(screen.queryByText(/characters|lines/)).not.toBeInTheDocument(); +}); + +test('renders syntax colors and line numbers', async () => { + render(); + + const editor = await screen.findByRole('textbox', { name: 'JSON value' }); + fireEvent.change(editor, { + target: { value: '{\n "name": "Pega",\n "enabled": true\n}' }, + }); + + expect(screen.getByTestId('JsonEditor-12345678:line-numbers').querySelectorAll('span')).toHaveLength(4); + expect(screen.getByTestId('JsonEditor-12345678:highlight').querySelectorAll('.json-key')).toHaveLength(2); + expect(screen.getByTestId('JsonEditor-12345678:highlight').querySelector('.json-literal')).toHaveTextContent('true'); +}); + +test('reports invalid JSON and disables JSON transforms', async () => { + render(); + + const editor = await screen.findByRole('textbox', { name: 'JSON value' }); + fireEvent.change(editor, { target: { value: '{invalid' } }); + + expect(screen.getByTestId('JsonEditor-12345678:status')).toHaveTextContent('Invalid JSON'); + expect(screen.getByRole('button', { name: 'Format JSON' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Minify JSON' })).toBeDisabled(); +}); + +test('reports the invalid JSON line and column', async () => { + render(); + + const editor = await screen.findByRole('textbox', { name: 'JSON value' }); + fireEvent.change(editor, { target: { value: '{\n "name": "Pega",\n}' } }); + + expect(screen.getByTestId('JsonEditor-12345678:error')).toHaveTextContent('Line 3'); + expect(screen.getByTestId('JsonEditor-12345678:error')).toHaveTextContent('column'); +}); + +test('minifies and clears JSON', async () => { + render(); + + const editor = await screen.findByRole('textbox', { name: 'JSON value' }); + fireEvent.change(editor, { target: { value: '{\n "name": "Pega"\n}' } }); + fireEvent.click(screen.getByRole('button', { name: 'Minify JSON' })); + expect(editor).toHaveValue('{"name":"Pega"}'); + + fireEvent.click(screen.getByRole('button', { name: 'Clear JSON' })); + expect(editor).toHaveValue(''); + expect(screen.queryByTestId('JsonEditor-12345678:status')).not.toBeInTheDocument(); +}); + +test('copies JSON and reports copy success', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Copy JSON' })); + + expect(await screen.findByRole('button', { name: 'Copied' })).toBeVisible(); + expect(writeText).toHaveBeenCalledWith(configProps.value); +}); diff --git a/src/components/Pega_Extensions_JsonEditor/index.tsx b/src/components/Pega_Extensions_JsonEditor/index.tsx new file mode 100644 index 00000000..618f748a --- /dev/null +++ b/src/components/Pega_Extensions_JsonEditor/index.tsx @@ -0,0 +1,350 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { ChangeEvent, KeyboardEvent, UIEvent } from 'react'; +import { FieldValueList, Icon, registerIcon, Text, TextArea, withConfiguration } from '@pega/cosmos-react-core'; +import * as alignLeftIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/align-left.icon'; +import * as checkIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/check.icon'; +import * as clipboardCheckIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/clipboard-check.icon'; +import * as codeIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/code.icon'; +import * as copyIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/copy.icon'; +import * as informationIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/information.icon'; +import * as trashIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/trash.icon'; + +import '../shared/create-nonce'; + +import StyledJsonEditorWrapper, { + StyledJsonEditorCopyError, + StyledJsonEditorCode, + StyledJsonEditorButton, + StyledJsonEditorError, + StyledJsonEditorFooter, + StyledJsonEditorHighlight, + StyledJsonEditorLineNumbers, + StyledJsonEditorMeta, + StyledJsonEditorSurface, + StyledJsonEditorStatus, + StyledJsonEditorToolbar, +} from './styles'; +import { + copyText, + getLineCount, + getValidationLabel, + getValidationStatus, + highlightJson, + type CopyState, + validateJson, +} from './utils'; + +registerIcon(alignLeftIcon, checkIcon, clipboardCheckIcon, codeIcon, copyIcon, informationIcon, trashIcon); + +export interface PegaExtensionsJsonEditorProps { + getPConnect: () => typeof PConnect; + label: string; + value?: string; + placeholder?: string; + disabled?: boolean; + displayMode?: '' | 'DISPLAY_ONLY' | 'LABELS_LEFT'; + readOnly?: boolean; + required?: boolean; + hideLabel?: boolean; + testId?: string; + helperText?: string; + validatemessage?: string; + status?: 'error' | 'success' | 'warning' | 'pending'; + indent?: number | string; + showValidate?: boolean; + showFormat?: boolean; + showMinify?: boolean; + showCopy?: boolean; + showClear?: boolean; + showCharacterCount?: boolean; + showLineCount?: boolean; +} + +interface StateProps { + value: string; +} + +export function PegaExtensionsJsonEditor(props: PegaExtensionsJsonEditorProps) { + const { + getPConnect, + value = '', + placeholder, + disabled: disabledProp = false, + displayMode, + readOnly: readOnlyProp = false, + required: requiredProp = false, + label, + hideLabel = false, + testId, + helperText, + validatemessage, + status, + indent = 2, + showValidate = true, + showFormat = true, + showMinify = true, + showCopy = true, + showClear = true, + showCharacterCount = true, + showLineCount = true, + } = props; + + const pConn = getPConnect(); + const localize = (text: string) => pConn.getLocalizedValue?.(text) || text; + const actions = pConn.getActionsApi(); + const stateProps = pConn.getStateProps() as StateProps; + const propName = stateProps.value; + let readOnly = readOnlyProp; + let required = requiredProp; + let disabled = disabledProp; + [readOnly, required, disabled] = [readOnly, required, disabled].map( + (prop) => prop === true || (typeof prop === 'string' && prop === 'true'), + ); + const [draft, setDraft] = useState(value); + const [validation, setValidation] = useState(() => validateJson(value, indent, required)); + const [copyState, setCopyState] = useState('idle'); + const [scrollOffset, setScrollOffset] = useState({ top: 0, left: 0 }); + const copyStateTimeoutRef = useRef>(); + const lineCount = useMemo(() => getLineCount(draft), [draft]); + const highlightedDraft = useMemo(() => highlightJson(draft), [draft]); + const lineNumbers = useMemo( + () => Array.from({ length: Math.max(1, lineCount) }, (_, index) => index + 1), + [lineCount], + ); + + const updateValue = (nextValue: string) => { + setDraft(nextValue); + setValidation(validateJson(nextValue, indent, required)); + setCopyState('idle'); + actions.updateFieldValue(propName, nextValue); + }; + + useEffect(() => { + setDraft(value); + setValidation(validateJson(value, indent, required)); + setCopyState('idle'); + }, [indent, required, value]); + + useEffect(() => { + return () => { + if (copyStateTimeoutRef.current) { + clearTimeout(copyStateTimeoutRef.current); + } + }; + }, []); + + const handleOnChange = (event: ChangeEvent) => { + updateValue(event.target.value); + }; + + const handleFormat = () => { + if (validation.isValid && !validation.isEmpty) { + updateValue(validation.formattedValue); + } + }; + + const handleValidate = () => { + setValidation(validateJson(draft, indent, required)); + }; + + const handleMinify = () => { + if (validation.isValid && !validation.isEmpty) { + updateValue(validation.minifiedValue); + } + }; + + const handleCopy = async () => { + try { + await copyText(draft); + setCopyState('copied'); + if (copyStateTimeoutRef.current) { + clearTimeout(copyStateTimeoutRef.current); + } + copyStateTimeoutRef.current = setTimeout(() => setCopyState('idle'), 2000); + } catch { + setCopyState('failed'); + } + }; + + const handleClear = () => { + updateValue(''); + }; + + const handleEditorKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { + event.preventDefault(); + handleValidate(); + } + }; + + const handleEditorScroll = (event: UIEvent) => { + setScrollOffset({ + top: event.currentTarget.scrollTop, + left: event.currentTarget.scrollLeft, + }); + }; + + const errorMessage = validation.message ? localize(validation.message) : validatemessage; + const errorLocation = + validation.errorLine && validation.errorColumn + ? `${localize('Line')} ${validation.errorLine}, ${localize('column')} ${validation.errorColumn}` + : undefined; + const fieldStatus = !validation.isValid ? 'error' : status; + const editorTestId = testId ? `${testId}:editor` : undefined; + const statusTestId = testId ? `${testId}:status` : undefined; + const highlightTestId = testId ? `${testId}:highlight` : undefined; + const lineNumbersTestId = testId ? `${testId}:line-numbers` : undefined; + const actionTestId = (action: string) => (testId ? `${testId}:action:${action}` : undefined); + const hasToolbarActions = showValidate || showFormat || showMinify || showCopy || showClear; + + if (displayMode === 'LABELS_LEFT' || displayMode === 'DISPLAY_ONLY') { + let displayValue = draft || ; + if (validation.isValid && !validation.isEmpty) { + displayValue = validation.formattedValue; + } + + return displayMode === 'DISPLAY_ONLY' ? ( + + {displayValue} + + ) : ( + + + + ); + } + + return ( + + {hasToolbarActions && ( + + {showValidate && ( + + + + )} + {showFormat && ( + + + + )} + {showMinify && ( + + + + )} + {showCopy && ( + + + + )} + {showClear && ( + + + + )} + + )} + + +