import React, { useState, useEffect, useCallback } from 'react'; import AceEditor from 'react-ace'; import 'ace-builds/webpack-resolver'; import { addCompleter } from 'ace-builds/src-noconflict/ext-language_tools'; import type { IMarker } from 'react-ace'; import { useTranslation } from 'react-i18next'; import { ActionableNotification } from '@carbon/react'; import { useStandardFormSchema } from '@hooks/useStandardFormSchema'; import Ajv from 'ajv'; import debounce from 'lodash-es/debounce'; import { ChevronRight, ChevronLeft } from '@carbon/react/icons'; import styles from './schema-editor.scss'; interface MarkerProps extends IMarker { text: string; } interface SchemaEditorProps { isLoading?: boolean; validationOn: boolean; onSchemaChange: (stringifiedSchema: string) => void; stringifiedSchema: string; errors: Array; setErrors: (errors: Array) => void; setValidationOn: (validationStatus: boolean) => void; } const SchemaEditor: React.FC = ({ onSchemaChange, stringifiedSchema, setErrors, errors, validationOn, setValidationOn, }) => { const { schema, schemaProperties } = useStandardFormSchema(); const { t } = useTranslation(); const [autocompleteSuggestions, setAutocompleteSuggestions] = useState< Array<{ name: string; type: string; path: string }> >([]); const [currentIndex, setCurrentIndex] = useState(0); // Enable autocompletion in the schema const generateAutocompleteSuggestions = useCallback(() => { const suggestions: Array<{ name: string; type: string; path: string }> = []; const traverseSchema = (schemaProps: unknown, path: string) => { if (schemaProps) { if (schemaProps && typeof schemaProps === 'object') { Object.entries(schemaProps).forEach(([propertyName, property]) => { if (propertyName === '$schema') { return; } const currentPath = path ? `${path}.${propertyName}` : propertyName; const typedProperty = property as { type?: string; properties?: Array<{ type: string }>; items?: { type?: string; properties?: Array<{ type: string }> }; oneOf?: Array<{ type: string }>; }; if (typeof property === 'object') { if (typedProperty.type === 'array' && typedProperty.items && typedProperty.items.properties) { traverseSchema(typedProperty.items.properties, currentPath); } else if (typedProperty.properties) { traverseSchema(typedProperty.properties, currentPath); } else if (typedProperty.oneOf) { const types = typedProperty?.oneOf?.map((item: { type: string }) => item.type).join(' | '); suggestions.push({ name: propertyName, type: types || 'any', path: currentPath }); } } suggestions.push({ name: propertyName, type: typedProperty.type || 'any', path: currentPath }); }); } } }; traverseSchema(schemaProperties, ''); return suggestions; }, [schemaProperties]); useEffect(() => { // Generate autocomplete suggestions when schema changes const suggestions = generateAutocompleteSuggestions(); setAutocompleteSuggestions(suggestions.flat()); }, [schemaProperties, generateAutocompleteSuggestions]); useEffect(() => { addCompleter({ getCompletions: function (editor, session, pos, prefix, callback) { callback( null, autocompleteSuggestions.map(function (word) { return { caption: word.name, value: word.name, meta: word.type, docText: `Path: ${word.path}`, }; }), ); }, }); }, [autocompleteSuggestions]); // Validate JSON schema const validateSchema = (content: string, schema) => { try { const trimmedContent = content.replace(/\s/g, ''); // Check if the content is an empty object if (trimmedContent.trim() === '{}') { // Reset errors since the JSON is considered valid setErrors([]); return; } const ajv = new Ajv({ allErrors: true, jsPropertySyntax: true, strict: false }); const validate = ajv.compile(schema); const parsedContent = JSON.parse(content); const isValid = validate(parsedContent); const jsonLines = content.split('\n'); const traverse = (schemaPath) => { const pathSegments = schemaPath.split('/').filter((segment) => segment !== '' || segment !== 'type'); let lineNumber = -1; for (const segment of pathSegments) { if (segment === 'properties' || segment === 'items') continue; // Skip 'properties' and 'items' const match = segment.match(/^([^[\]]+)/); // Extract property key if (match) { const propertyName: string = pathSegments[pathSegments.length - 2]; // Get property key lineNumber = jsonLines.findIndex((line) => line.includes(propertyName)); } if (lineNumber !== -1) break; } return lineNumber; }; if (!isValid) { const errorMarkers = validate.errors.map((error) => { const schemaPath = error.schemaPath.replace(/^#\//, ''); // Remove leading '#/' const lineNumber = traverse(schemaPath); const pathSegments = error.instancePath.split('.'); // Split the path into segments const errorPropertyName = pathSegments[pathSegments.length - 1]; const message = error.keyword === 'type' || error.keyword === 'enum' ? `${errorPropertyName.charAt(0).toUpperCase() + errorPropertyName.slice(1)} ${error.message}` : `${error.message.charAt(0).toUpperCase() + error.message.slice(1)}`; return { startRow: lineNumber, startCol: 0, endRow: lineNumber, endCol: 1, className: 'error', text: message, type: 'text' as const, }; }); setErrors(errorMarkers); } else { setErrors([]); } } catch (error) { console.error('Error parsing or validating JSON:', error); } }; const debouncedValidateSchema = debounce(validateSchema, 300); const handleChange = (newValue: string) => { setValidationOn(false); setCurrentIndex(0); onSchemaChange(newValue); debouncedValidateSchema(newValue, schema); }; // Schema Validation Errors const ErrorNotification = ({ text, line }) => ( window.open('https://json.openmrs.org/form.schema.json', '_blank', 'noopener,noreferrer') } /> ); const onPreviousErrorClick = () => { setCurrentIndex((prevIndex) => Math.max(prevIndex - 1, 0)); }; const onNextErrorClick = () => { setCurrentIndex((prevIndex) => Math.min(prevIndex + 1, errors.length - 1)); }; const ErrorMessages = () => (
{currentIndex + 1}/{errors.length}
); return (
{errors.length && validationOn ? : null}
); }; export default SchemaEditor;