diff --git a/frontend/src/components/Topics/Topic/SendMessage/SendMessage.styled.tsx b/frontend/src/components/Topics/Topic/SendMessage/SendMessage.styled.tsx index d2750abf7..e2b769b6b 100644 --- a/frontend/src/components/Topics/Topic/SendMessage/SendMessage.styled.tsx +++ b/frontend/src/components/Topics/Topic/SendMessage/SendMessage.styled.tsx @@ -1,4 +1,5 @@ import styled from 'styled-components'; +import { Button } from 'components/common/Button/Button'; export const Wrapper = styled.div` display: block; @@ -17,6 +18,7 @@ export const Columns = styled.div` display: flex; } `; + export const Flex = styled.div` display: flex; flex-direction: row; @@ -25,6 +27,7 @@ export const Flex = styled.div` flex-direction: column; } `; + export const FlexItem = styled.div` width: 18rem; @media screen and (max-width: 1450px) { @@ -34,3 +37,144 @@ export const FlexItem = styled.div` width: 100%; } `; + +// New styled components for JSON formatting functionality +export const ValidationSection = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +`; + +export const FieldGroup = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +`; + +export const FieldHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +`; + +export const FormatButton = styled(Button)` + font-size: 12px; + padding: 4px 8px; + height: 24px; + min-width: auto; + border-radius: 3px; + + &:focus-visible { + outline: 2px solid ${({ theme }) => theme.button.primary.backgroundColor.normal}; + outline-offset: 2px; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + // Ensure proper contrast and accessibility + &[aria-pressed="true"] { + background: ${({ theme }) => theme.button.primary.backgroundColor.active}; + color: ${({ theme }) => theme.button.primary.color.active}; + } +`; + +export const ResizableEditorWrapper = styled.div` + .ace_editor { + resize: vertical !important; + min-height: 40px !important; + max-height: 200px !important; + overflow: auto; + border: 1px solid ${({ theme }) => theme.input?.borderColor?.normal || '#ddd'}; + border-radius: 4px; + + &:focus-within { + border-color: ${({ theme }) => theme.input?.borderColor?.focus || theme.button.primary.backgroundColor.normal}; + box-shadow: 0 0 0 2px ${({ theme }) => theme.button.primary.backgroundColor.normal}33; + } + } + + .ace_content { + cursor: text; + } + + .ace_scrollbar-v { + right: 0 !important; + } + + .ace_scrollbar-h { + bottom: 0 !important; + } + + // Enhanced resize handle visibility + .ace_editor::after { + content: ''; + position: absolute; + bottom: 0; + right: 0; + width: 12px; + height: 12px; + background: linear-gradient( + 135deg, + transparent 0%, + transparent 46%, + ${({ theme }) => theme.input?.borderColor?.normal || '#ddd'} 46%, + ${({ theme }) => theme.input?.borderColor?.normal || '#ddd'} 50%, + transparent 50%, + transparent 56%, + ${({ theme }) => theme.input?.borderColor?.normal || '#ddd'} 56%, + ${({ theme }) => theme.input?.borderColor?.normal || '#ddd'} 60%, + transparent 60% + ); + cursor: nw-resize; + z-index: 10; + } +`; + +// Error state styling for validation feedback +export const ValidationError = styled.div` + color: ${({ theme }) => theme.button.danger.backgroundColor.normal}; + font-size: 12px; + margin-top: 4px; + display: flex; + align-items: center; + gap: 4px; + + &::before { + content: '⚠'; + font-size: 14px; + } +`; + +// Success state styling for formatting feedback +export const FormatSuccess = styled.div` + color: ${({ theme }) => theme.button.primary.backgroundColor.normal}; + font-size: 12px; + margin-top: 4px; + display: flex; + align-items: center; + gap: 4px; + + &::before { + content: '✓'; + font-size: 14px; + font-weight: bold; + } +`; + +// Accessibility improvements for screen readers +export const ScreenReaderOnly = styled.span` + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +`; \ No newline at end of file diff --git a/frontend/src/components/Topics/Topic/SendMessage/SendMessage.tsx b/frontend/src/components/Topics/Topic/SendMessage/SendMessage.tsx index 20241efc6..adafe7de5 100644 --- a/frontend/src/components/Topics/Topic/SendMessage/SendMessage.tsx +++ b/frontend/src/components/Topics/Topic/SendMessage/SendMessage.tsx @@ -26,6 +26,34 @@ interface SendMessageProps { messageData?: Partial | null; } +// JSON formatting utility with comprehensive error handling +const formatJsonString = (input: string): { formatted: string; error: string | null } => { + if (!input || input.trim() === '') { + return { formatted: input, error: null }; + } + + try { + const parsed = JSON.parse(input); + const formatted = JSON.stringify(parsed, null, 2); + return { formatted, error: null }; + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Invalid JSON format'; + return { formatted: input, error: errorMessage }; + } +}; + +// JSON validation utility for optional validation +const validateJsonField = (value: string, fieldName: string, validateJson: boolean): boolean | string => { + if (!validateJson || !value || value.trim() === '') return true; + + try { + JSON.parse(value); + return true; + } catch (e) { + return `Invalid JSON in ${fieldName} field: ${e instanceof Error ? e.message : 'Parse error'}`; + } +}; + const SendMessage: React.FC = ({ closeSidebar, messageData = null, @@ -38,6 +66,13 @@ const SendMessage: React.FC = ({ use: SerdeUsage.SERIALIZE, }); const sendMessage = useSendMessage({ clusterName, topicName }); + + // Formatting state management + const [formatKey, setFormatKey] = React.useState(false); + const [formatValue, setFormatValue] = React.useState(false); + const [formatHeaders, setFormatHeaders] = React.useState(false); + const [validateJson, setValidateJson] = React.useState(false); + const defaultValues = React.useMemo(() => getDefaultValues(serdes), [serdes]); const partitionOptions = React.useMemo( () => getPartitionOptions(topic?.partitions || []), @@ -59,11 +94,40 @@ const SendMessage: React.FC = ({ formState: { isSubmitting }, control, setValue, + watch, } = useForm({ mode: 'onChange', defaultValues: formDefaults, }); + // Format toggle handler with error handling and user feedback + const handleFormatToggle = React.useCallback((field: 'key' | 'content' | 'headers') => { + const currentValue = watch(field) || ''; + const { formatted, error } = formatJsonString(currentValue); + + if (error) { + showAlert('error', { + id: `format-error-${field}`, + title: 'Format Error', + message: `Cannot format ${field}: ${error}`, + }); + } else { + setValue(field, formatted); + // Update formatting state + switch (field) { + case 'key': + setFormatKey(true); + break; + case 'content': + setFormatValue(true); + break; + case 'headers': + setFormatHeaders(true); + break; + } + } + }, [watch, setValue]); + const submit = async ({ keySerde, valueSerde, @@ -75,9 +139,21 @@ const SendMessage: React.FC = ({ }: MessageFormData) => { let errors: string[] = []; + // JSON validation if enabled + if (validateJson) { + const keyValidation = validateJsonField(key || '', 'key', validateJson); + const contentValidation = validateJsonField(content || '', 'content', validateJson); + const headersValidation = validateJsonField(headers || '', 'headers', validateJson); + + if (typeof keyValidation === 'string') errors.push(keyValidation); + if (typeof contentValidation === 'string') errors.push(contentValidation); + if (typeof headersValidation === 'string') errors.push(headersValidation); + } + + // Existing schema validation if (keySerde) { const selectedKeySerde = serdes.key?.find((k) => k.name === keySerde); - errors = validateBySchema(key, selectedKeySerde?.schema, 'key'); + errors = [...errors, ...validateBySchema(key, selectedKeySerde?.schema, 'key')]; } if (valueSerde) { @@ -111,6 +187,7 @@ const SendMessage: React.FC = ({ }); return; } + try { await sendMessage.mutateAsync({ key: key || null, @@ -190,6 +267,14 @@ const SendMessage: React.FC = ({ /> + + + Validate JSON before submission +
= ({ Keep contents
+ -
- Key + + + Key + handleFormatToggle('key')} + aria-label="Format JSON for key field" + type="button" + disabled={isSubmitting} + > + Format JSON + + = ({ { + onChange(newValue); + // Reset format state when user manually edits + if (formatKey && newValue !== value) { + setFormatKey(false); + } + }} value={value} height="40px" + mode={formatKey ? "json5" : undefined} + setOptions={{ + showLineNumbers: formatKey, + tabSize: 2, + useWorker: false + }} /> )} /> -
-
- Value + + + + + Value + handleFormatToggle('content')} + aria-label="Format JSON for value field" + type="button" + disabled={isSubmitting} + > + Format JSON + + = ({ { + onChange(newValue); + // Reset format state when user manually edits + if (formatValue && newValue !== value) { + setFormatValue(false); + } + }} value={value} height="280px" + mode={formatValue ? "json5" : undefined} + setOptions={{ + showLineNumbers: formatValue, + tabSize: 2, + useWorker: false + }} /> )} /> -
+
+ -
- Headers - ( - - )} - /> -
+ + + Headers + handleFormatToggle('headers')} + aria-label="Format JSON for headers field" + type="button" + disabled={isSubmitting} + > + Format JSON + + + + ( + { + onChange(newValue); + // Reset format state when user manually edits + if (formatHeaders && newValue !== value) { + setFormatHeaders(false); + } + }} + value={value || '{}'} + height="40px" + mode={formatHeaders ? "json5" : undefined} + setOptions={{ + showLineNumbers: formatHeaders, + tabSize: 2, + useWorker: false + }} + /> + )} + /> + +
+