diff --git a/components/JsonEditor.tsx b/components/JsonEditor.tsx index 34fefe0cc..c7870e7ca 100644 --- a/components/JsonEditor.tsx +++ b/components/JsonEditor.tsx @@ -1,7 +1,8 @@ +/* eslint-disable linebreak-style */ import React, { useContext } from 'react'; import { BaseEditor, createEditor, Descendant, Text } from 'slate'; import { Editable, ReactEditor, Slate, withReact } from 'slate-react'; -import classnames from 'classnames'; +import { cn } from '@/lib/utils'; import getPartsOfJson, { SyntaxPart } from '~/lib/getPartsOfJson'; import jsonSchemaReferences from './jsonSchemaLinks'; import { useRouter } from 'next/router'; @@ -11,6 +12,12 @@ import getScopesOfParsedJsonSchema, { JsonSchemaPathWithScope, JsonSchemaScope, } from '~/lib/getScopesOfParsedJsonSchema'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Alert } from '@/components/ui/alert'; +import Highlight from 'react-syntax-highlighter'; +import { atomOneDark } from 'react-syntax-highlighter/dist/cjs/styles/hljs'; type CustomElement = CustomNode | CustomText; type CustomNode = { type: 'paragraph'; children: CustomText[] }; @@ -53,7 +60,7 @@ type MultipathDecoration = { syntaxPart: SyntaxPart; }; -const META_REGEX = /^\s*\/\/ props (?{.*}).*\n/g; +const META_REGEX = /^\s*\/\/ props (?{.*}).*\n/; // Prevent annoying error messages because slate is not SSR ready /* istanbul ignore next: @@ -97,10 +104,111 @@ const getTextPathIndexesFromNode = ( return textPathIndexesFromNodes; }; -const calculateNewDecorationsMap = (value: CustomElement[]) => { +// Function to create basic syntax highlighting for partial schemas +const getBasicSyntaxParts = (serializedText: string): SyntaxPart[] => { + const parts: SyntaxPart[] = []; + + // Define patterns for basic syntax highlighting + const patterns = [ + // Strings (including property names) + { regex: /"[^"\\]*(?:\\.[^"\\]*)*"/g, type: 'stringValue' }, + // Numbers + { regex: /-?\d+\.?\d*(?:[eE][+-]?\d+)?/g, type: 'numberValue' }, + // Booleans + { regex: /\b(?:true|false)\b/g, type: 'booleanValue' }, + // Null + { regex: /\bnull\b/g, type: 'nullValue' }, + // Object brackets + { regex: /[{}]/g, type: 'objectBracket' }, + // Array brackets + { regex: /[[\]]/g, type: 'arrayBracket' }, + // Commas + { regex: /,/g, type: 'comma' }, + // Property names (quoted strings followed by colon) + { regex: /"[^"\\]*(?:\\.[^"\\]*)*"\s*:/g, type: 'objectProperty' }, + ]; + + patterns.forEach(({ regex, type }) => { + let match; + while ((match = regex.exec(serializedText)) !== null) { + // Special handling for property names + if (type === 'objectProperty') { + const fullMatch = match[0]; + const colonIndex = fullMatch.lastIndexOf(':'); + const propertyPart = fullMatch.substring(0, colonIndex); + + // Add quotes + parts.push({ + type: 'objectPropertyStartQuotes', + index: match.index, + length: 1, + match: '"', + jsonPath: '$', + }); + + // Add property name + parts.push({ + type: 'objectProperty', + index: match.index + 1, + length: propertyPart.length - 2, + match: propertyPart.slice(1, -1), + jsonPath: '$', + }); + + // Add closing quotes + parts.push({ + type: 'objectPropertyEndQuotes', + index: match.index + propertyPart.length - 1, + length: 1, + match: '"', + jsonPath: '$', + }); + } else { + // Map some types to match existing styling + let mappedType = type; + if (type === 'objectBracket') { + mappedType = + match[0] === '{' ? 'objectStartBracket' : 'objectEndBracket'; + } else if (type === 'arrayBracket') { + mappedType = + match[0] === '[' ? 'arrayStartBracket' : 'arrayEndBracket'; + } else if (type === 'comma') { + mappedType = 'arrayComma'; + } + + parts.push({ + type: mappedType, + index: match.index, + length: match[0].length, + match: match[0], + jsonPath: '$', + }); + } + } + }); + + // Sort parts by index to ensure proper ordering + return parts.sort((a, b) => a.index - b.index); +}; + +const calculateNewDecorationsMap = ( + value: CustomElement[], + /* istanbul ignore next: Default parameter is never triggered in current implementation */ + isPartialSchema: boolean = false, +) => { const serializedText = serializeNodesWithoutLineBreaks(value); const textPathIndexes = getTextPathIndexesFromNodes(value); - const partsOfJson: SyntaxPart[] = getPartsOfJson(serializedText); + + let partsOfJson: SyntaxPart[]; + + if (isPartialSchema) { + // Use basic syntax highlighting for partial schemas + partsOfJson = getBasicSyntaxParts(serializedText); + } else { + // Use full JSON parsing for complete schemas + partsOfJson = getPartsOfJson(serializedText); + } + const multipathDecorations = getMultipathDecorationsByMatchesAndTextPathIndexes( partsOfJson, @@ -176,13 +284,29 @@ const deserializeCode = (code: string): CustomElement[] => { return paragraphs; }; -export default function JsonEditor({ initialCode }: { initialCode: string }) { +export default function JsonEditor({ + initialCode, + isJsonc = false, + language, + code, +}: { + initialCode?: string; + isJsonc?: boolean; + language?: string; + code?: string; +}) { const fullMarkdown = useContext(FullMarkdownContext); + + // Determine if we're in JSON/JSONC mode or regular code mode + const isJsonMode = initialCode !== undefined; + const codeContent = isJsonMode ? initialCode : code || ''; + /* istanbul ignore next: In the test environment, the fullMarkdown is not provided. */ const hasCodeblockAsDescendant: boolean | undefined = (() => { - const positionOfCodeInFullMarkdown = fullMarkdown?.indexOf(initialCode); + if (!isJsonMode) return false; + const positionOfCodeInFullMarkdown = fullMarkdown?.indexOf(codeContent); if (!positionOfCodeInFullMarkdown) return; - const endPositionOfCode = positionOfCodeInFullMarkdown + initialCode.length; + const endPositionOfCode = positionOfCodeInFullMarkdown + codeContent.length; const startPositionOfNextBlock = endPositionOfCode + '\n```\n'.length; const markdownAfterCodeBlock = fullMarkdown?.substr( startPositionOfNextBlock, @@ -191,9 +315,23 @@ export default function JsonEditor({ initialCode }: { initialCode: string }) { })(); const router = useRouter(); + + // Clean code and detect partial schema for JSONC const cleanedUpCode = React.useMemo(() => { - return initialCode.replace(META_REGEX, ''); - }, [initialCode]); + if (!isJsonMode) return codeContent; + + let code = codeContent.replace(META_REGEX, ''); + + if (isJsonc) { + // Remove partial schema comments for JSONC + code = code + .replace(/\/\/ partial schema\n?/g, '') + .replace(/\/\* partial schema \*\/\n?/g, '') + .trim(); + } + + return code; + }, [codeContent, isJsonc, isJsonMode]); const [value, setValue] = React.useState( deserializeCode(cleanedUpCode), @@ -204,10 +342,10 @@ export default function JsonEditor({ initialCode }: { initialCode: string }) { ); const [editor] = React.useState(() => withReact(createEditor())); - //const [] React.useState() - const meta: null | Meta = (() => { - const metaRegexFinding = META_REGEX.exec(initialCode); + const meta: null | Meta = React.useMemo(() => { + if (!isJsonMode) return null; + const metaRegexFinding = META_REGEX.exec(codeContent); if (!metaRegexFinding) return null; try { const metaString: undefined | string = metaRegexFinding?.groups?.meta; @@ -219,7 +357,7 @@ export default function JsonEditor({ initialCode }: { initialCode: string }) { } catch (e) { return null; } - })(); + }, [codeContent, isJsonMode]); const parsedCode: null | any = React.useMemo(() => { try { @@ -229,7 +367,19 @@ export default function JsonEditor({ initialCode }: { initialCode: string }) { } }, [serializedCode]); - const isJsonSchema = parsedCode?.['$schema'] || meta?.isSchema; + // Detect partial schema for JSONC + const isPartialSchema = React.useMemo(() => { + if (!isJsonc || !isJsonMode) return false; + const codeString = String(codeContent || ''); + return ( + codeString.includes('// partial schema') || + codeString.includes('/* partial schema */') + ); + }, [codeContent, isJsonc, isJsonMode]); + + const isJsonSchema = React.useMemo(() => { + return parsedCode?.['$schema'] || meta?.isSchema; + }, [parsedCode, meta]); const jsonPathsWithJsonScope: JsonSchemaPathWithScope[] = React.useMemo(() => { @@ -237,13 +387,17 @@ export default function JsonEditor({ initialCode }: { initialCode: string }) { return getScopesOfParsedJsonSchema(parsedCode); }, [parsedCode, isJsonSchema]); - const validation: null | 'valid' | 'invalid' = - typeof meta?.valid === 'boolean' + const validation: null | 'valid' | 'invalid' = React.useMemo(() => { + return typeof meta?.valid === 'boolean' ? meta.valid ? 'valid' : 'invalid' : null; - const caption: null | string = meta?.caption || null; + }, [meta]); + + const caption: null | string = React.useMemo(() => { + return meta?.caption || null; + }, [meta]); // fullCodeText variable is for use in copy pasting the code for the user const fullCodeText = React.useMemo(() => { @@ -261,10 +415,88 @@ export default function JsonEditor({ initialCode }: { initialCode: string }) { const [copied, setCopied] = React.useState(false); const allPathDecorationsMap: Record = React.useMemo( - () => calculateNewDecorationsMap(value), - [value], + () => calculateNewDecorationsMap(value, isPartialSchema), + [value, isPartialSchema], ); + // Badge text logic for regular code blocks + const getBadgeText = () => { + if (!language) return 'code'; + const lang = language.replace('lang-', ''); + return lang; + }; + + // If not in JSON mode, render as regular code block + if (!isJsonMode) { + return ( + +
+ + + {getBadgeText()} + +
+ +
+ + {codeContent} + +
+
+
+ ); + } + return ( setValue(e) } > -
{/* Copy code button */} -
{ navigator.clipboard.writeText(fullCodeText); setCopied(true); @@ -295,28 +529,45 @@ export default function JsonEditor({ initialCode }: { initialCode: string }) { }} data-test='copy-clipboard-button' > - Copy icon - Copied icon -
-
+ ) : ( + Copy icon + )} + + - {isJsonSchema ? ( + {isJsonc ? ( + isPartialSchema ? ( + <> +  logo-white{' '} + part of schema + + ) : ( + <>code + ) + ) : isJsonSchema ? ( <> data )} -
+
- { - e.preventDefault(); - const text = window.getSelection()?.toString(); - navigator.clipboard.writeText(text || ''); - }} - onCut={ - /* istanbul ignore next : - The editor is read-only, so the onCut function will never be called. */ - (e) => { + + { e.preventDefault(); const text = window.getSelection()?.toString(); navigator.clipboard.writeText(text || ''); - setValue([{ type: 'paragraph', children: [{ text: '' }] }]); + }} + onCut={ + /* istanbul ignore next : + The editor is read-only, so the onCut function will never be called. */ + (e) => { + e.preventDefault(); + const text = window.getSelection()?.toString(); + navigator.clipboard.writeText(text || ''); + setValue([{ type: 'paragraph', children: [{ text: '' }] }]); + } } - } - readOnly={true} - decorate={([node, path]) => { - if (!Text.isText(node)) return []; - const stringPath = path.join(','); - /* istanbul ignore next: allPathDecorationsMap[stringPath] cannot be null */ - return allPathDecorationsMap[stringPath] || []; - }} - renderLeaf={(props: any) => { - const { leaf, children, attributes } = props; - const textStyles: undefined | string = (() => { - if ( - [ - 'objectPropertyStartQuotes', - 'objectPropertyEndQuotes', - ].includes(leaf.syntaxPart?.type) - ) - return 'text-blue-200'; - if (['objectProperty'].includes(leaf.syntaxPart?.type)) { - const isJsonScope = jsonPathsWithJsonScope - .filter( - (jsonPathWithScope) => - jsonPathWithScope.scope === - JsonSchemaScope.TypeDefinition, - ) - .map( - (jsonPathsWithJsonScope) => jsonPathsWithJsonScope.jsonPath, - ) - .includes(leaf.syntaxPart?.parentJsonPath); + readOnly={true} + decorate={([node, path]) => { + if (!Text.isText(node)) return []; + const stringPath = path.join(','); + /* istanbul ignore next: allPathDecorationsMap[stringPath] cannot be null */ + return allPathDecorationsMap[stringPath] || []; + }} + renderLeaf={(props: any) => { + const { leaf, children, attributes } = props; + const textStyles: undefined | string = (() => { if ( - isJsonScope && - jsonSchemaReferences.objectProperty[leaf.text] - ) { - return 'cursor-pointer text-blue-400 hover:text-blue-300 decoration-blue-500/30 hover:decoration-blue-500/50 underline underline-offset-4'; + [ + 'objectPropertyStartQuotes', + 'objectPropertyEndQuotes', + ].includes(leaf.syntaxPart?.type) + ) + return 'text-blue-200'; + if (['objectProperty'].includes(leaf.syntaxPart?.type)) { + const isJsonScope = jsonPathsWithJsonScope + .filter( + (jsonPathWithScope) => + jsonPathWithScope.scope === + JsonSchemaScope.TypeDefinition, + ) + .map( + (jsonPathsWithJsonScope) => + jsonPathsWithJsonScope.jsonPath, + ) + .includes(leaf.syntaxPart?.parentJsonPath); + if ( + isJsonScope && + jsonSchemaReferences.objectProperty[leaf.text] + ) { + return 'cursor-pointer text-blue-400 hover:text-blue-300 decoration-blue-500/30 hover:decoration-blue-500/50 underline underline-offset-4'; + } + return 'text-cyan-500'; } - return 'text-cyan-500'; - } - if (leaf.syntaxPart?.type === 'stringValue') { - if (jsonSchemaReferences.stringValue[leaf.text]) { - return 'cursor-pointer text-amber-300 hover:text-amber-300 decoration-amber-500/30 hover:decoration-amber-500/50 underline underline-offset-4'; + if (leaf.syntaxPart?.type === 'stringValue') { + if (jsonSchemaReferences.stringValue[leaf.text]) { + return 'cursor-pointer text-amber-300 hover:text-amber-300 decoration-amber-500/30 hover:decoration-amber-500/50 underline underline-offset-4'; + } + return 'text-lime-200'; } - return 'text-lime-200'; - } - if ( - [ - 'objectStartBracket', - 'objectEndBracket', - 'arrayComma', - 'arrayStartBracket', - 'arrayEndBracket', - ].includes(leaf.syntaxPart?.type) - ) - return 'text-slate-400'; - if ( - [ - 'numberValue', - 'stringValue', - 'booleanValue', - 'nullValue', - ].includes(leaf.syntaxPart?.type) - ) - return 'text-lime-200'; - })(); - - const link: null | string = (() => - jsonSchemaReferences?.[leaf.syntaxPart?.type]?.[leaf.text] || - null)(); - - return ( - { - /* istanbul ignore if : link cannot be null */ - if (!link) return; - router.push(link); - }} - className={classnames('pb-2', textStyles, 'whitespace-pre')} - title={leaf.syntaxPart?.type} - {...attributes} - > - {children} - - ); - }} - renderElement={(props: any) => { - // This will be the path to the image element. - const { element, children, attributes } = props; - const path = ReactEditor.findPath(editor, element); - const line = path[0] + 1; - /* istanbul ignore else : no else block to test */ - if (element.type === 'paragraph') { + if ( + [ + 'objectStartBracket', + 'objectEndBracket', + 'arrayComma', + 'arrayStartBracket', + 'arrayEndBracket', + ].includes(leaf.syntaxPart?.type) + ) + return 'text-slate-400'; + if ( + [ + 'numberValue', + 'stringValue', + 'booleanValue', + 'nullValue', + ].includes(leaf.syntaxPart?.type) + ) + return 'text-lime-200'; + + // Handle partial schema specific highlighting that might not match exactly + if (!leaf.syntaxPart?.type) { + // If no syntax part type, apply default white color for partial schemas + return isPartialSchema ? 'text-white' : undefined; + } + })(); + + const link: null | string = (() => + jsonSchemaReferences?.[leaf.syntaxPart?.type]?.[leaf.text] || + null)(); + return ( { + /* istanbul ignore if : link cannot be null */ + if (!link) return; + router.push(link); + }} + className={cn('pb-2', textStyles, 'whitespace-pre')} + title={leaf.syntaxPart?.type} {...attributes} > - - {children} + {children} ); - } - /* istanbul ignore next: - * There is no other element type in the render function. Hence this will never be called.*/ - throw new Error( - `unknown element.type [${element.type}] in render function`, - ); - }} - /> + }} + renderElement={(props: any) => { + // This will be the path to the image element. + const { element, children, attributes } = props; + const path = ReactEditor.findPath(editor, element); + const line = path[0] + 1; + /* istanbul ignore else : no else block to test */ + if (element.type === 'paragraph') { + return ( + + + {children} + + ); + } + /* istanbul ignore next: + * There is no other element type in the render function. Hence this will never be called.*/ + throw new Error( + `unknown element.type [${element.type}] in render function`, + ); + }} + /> + {validation === 'invalid' && ( -
not compliant to schema -
+ )} {validation === 'valid' && ( -
compliant to schema -
+ )} -
+
{ pre: ({ children }) => { const language = children?.props?.className; const isJsonCode = language === 'lang-json'; + const isJsoncCode = language === 'lang-jsonc'; const code = children?.props?.children; + if (isJsonCode) { return ; } - return ( -
- - {code} - -
- ); + if (isJsoncCode) { + return ; + } + + // Use JsonEditor for regular code blocks + return ; }, blockquote: { component: ({ children }) => ( @@ -670,7 +653,7 @@ export function TableOfContentMarkdown({ ); }, - } /* eslint-enable */ + } : { component: () => null }, ...hiddenElements( 'strong', diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 000000000..719548083 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,68 @@ +/* eslint-disable linebreak-style */ +/* eslint-disable react/prop-types */ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + { + variants: { + variant: { + default: 'bg-card text-card-foreground', + destructive: + 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/cypress/components/JsonEditor.cy.tsx b/cypress/components/JsonEditor.cy.tsx index b31e24396..8f61f958f 100644 --- a/cypress/components/JsonEditor.cy.tsx +++ b/cypress/components/JsonEditor.cy.tsx @@ -1,3 +1,4 @@ +/* eslint-disable cypress/no-unnecessary-waiting */ import JsonEditor from '~/components/JsonEditor'; import React from 'react'; import mockNextRouter, { MockRouter } from '../plugins/mockNextRouterUtils'; @@ -95,35 +96,34 @@ describe('JSON Editor Component', () => { // mount component cy.mount(); - // check if copy img is visible + // check if copy img is visible initially cy.get('[data-test="copy-clipboard-button"]') .children('img') - .should('have.length', 2) - .first() + .should('have.length', 1) .should('have.attr', 'src', '/icons/copy.svg') .should('be.visible'); - // click on copy img + // click on copy button cy.get('[data-test="copy-clipboard-button"]').click(); - // check if clipboard writeText is copied the correct code + // check if clipboard writeText is called with the correct code cy.get('@clipboardWriteText').should( 'have.been.calledWith', JSON.stringify(initialCode, null, 2) + '\n', ); - // check if copied img is visible + // check if copied img is visible after clicking cy.get('[data-test="copy-clipboard-button"]') .children('img') - .last() + .should('have.length', 1) .should('have.attr', 'src', '/icons/copied.svg') .should('be.visible'); // after 2 seconds, check if copy img is visible again + cy.wait(2100); // Wait slightly longer than the 2000ms timeout cy.get('[data-test="copy-clipboard-button"]') .children('img') - .should('have.length', 2) - .first() + .should('have.length', 1) .should('have.attr', 'src', '/icons/copy.svg') .should('be.visible'); }); @@ -213,4 +213,545 @@ describe('JSON Editor Component', () => { cy.get('[data-test="json-editor"]').trigger('copy'); cy.get('@clipboardWriteText').should('have.been.calledWith', ''); }); + + // Test JSONC support with isJsonc prop + it('should render JSONC code correctly', () => { + const jsoncCode = `{ + // This is a comment + "name": "test", + "value": 123 +}`; + + cy.mount(); + + // Check that the badge shows "code" for regular JSONC + cy.get('[data-test="check-json-schema"]').contains('code'); + }); + + // Test partial schema detection in JSONC + it('should detect and display partial schema correctly', () => { + const partialSchemaCode = `// partial schema +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +}`; + + cy.mount(); + + // Check that the badge shows "part of schema" and has the schema icon + cy.get('[data-test="check-json-schema"]').contains('part of schema'); + cy.get('[data-test="check-json-schema"] img').should( + 'have.attr', + 'src', + '/logo-white.svg', + ); + }); + + // Test partial schema with block comment + it('should detect partial schema with block comment', () => { + const partialSchemaCode = `/* partial schema */ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +}`; + + cy.mount(); + + // Check that the badge shows "part of schema" + cy.get('[data-test="check-json-schema"]').contains('part of schema'); + }); + + // Test schema badge for JSON with $schema property + it('should show schema badge for JSON with $schema property', () => { + const schemaCode = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +}`; + + cy.mount(); + + // Check that the badge shows "schema" and has the schema icon + cy.get('[data-test="check-json-schema"]').contains('schema'); + cy.get('[data-test="check-json-schema"] img').should( + 'have.attr', + 'src', + '/logo-white.svg', + ); + }); + + // Test schema badge for JSON with meta isSchema flag + it('should show schema badge for JSON with meta isSchema flag', () => { + const schemaCode = `// props { "isSchema": true } +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +}`; + + cy.mount(); + + // Check that the badge shows "schema" + cy.get('[data-test="check-json-schema"]').contains('schema'); + }); + + // Test data badge for regular JSON without schema + it('should show data badge for regular JSON', () => { + const dataCode = `{ + "name": "test", + "value": 123, + "active": true +}`; + + cy.mount(); + + // Check that the badge shows "data" + cy.get('[data-test="check-json-schema"]').contains('data'); + }); + + // Test indented code with meta indent flag + it('should apply indentation with meta indent flag', () => { + const indentedCode = `// props { "indent": true } +{ + "name": "test" +}`; + + cy.mount(); + + // Check that the card has the indentation class + // The ml-10 class is applied to the Card component, not the Editable + cy.get('[data-test="json-editor"]') + .closest('.relative') + .should('have.class', 'ml-10'); + }); + + // Test invalid JSON parsing + it('should handle invalid JSON gracefully', () => { + const invalidJson = `{ + "name": "test", + "value": 123, + "unclosed": { +}`; + + cy.mount(); + + // Should still render without crashing + cy.get('[data-test="json-editor"]').should('exist'); + // Should show data badge since it's not valid JSON + cy.get('[data-test="check-json-schema"]').contains('data'); + }); + + // Test empty code + it('should handle empty code', () => { + cy.mount(); + + // Should still render without crashing + cy.get('[data-test="json-editor"]').should('exist'); + }); + + // Test code with only whitespace + it('should handle whitespace-only code', () => { + cy.mount(); + + // Should still render without crashing + cy.get('[data-test="json-editor"]').should('exist'); + }); + + // Test cut functionality (read-only editor, so this is mainly for coverage) + it('should handle cut event', () => { + // mock clipboard writeText + cy.window().then((win) => { + cy.stub(win.navigator.clipboard, 'writeText').as('clipboardWriteText'); + // Mock getSelection to return some text + cy.stub(win, 'getSelection').returns({ + toString: () => 'selected text', + }); + }); + + cy.mount(); + + // Test that the component renders without errors + cy.get('[data-test="json-editor"]').should('exist'); + + // Note: Cut event is not typically triggered in read-only editors + // This test ensures the component handles the event handler properly + }); + + // Test selection and copy functionality + it('should handle text selection and copy', () => { + // mock clipboard writeText + cy.window().then((win) => { + cy.stub(win.navigator.clipboard, 'writeText').as('clipboardWriteText'); + // Mock getSelection to return some text + cy.stub(win, 'getSelection').returns({ + toString: () => 'selected text', + }); + }); + + cy.mount(); + + // Trigger copy event + cy.get('[data-test="json-editor"]').trigger('copy'); + cy.get('@clipboardWriteText').should( + 'have.been.calledWith', + 'selected text', + ); + }); + + // Test click on non-link text + it('should handle click on non-link text', () => { + cy.mount(); + + // Click on regular text (should not navigate) + cy.get('[data-test="json-editor"] span').first().click(); + + // Should not have called router.push + cy.get('@routerPush').should('not.have.been.called'); + }); + + // Test partial schema syntax highlighting + it('should apply syntax highlighting to partial schemas', () => { + const partialSchemaCode = `// partial schema +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +}`; + + cy.mount(); + + // Check that the code is rendered (syntax highlighting applied) + cy.get('[data-test="json-editor"]').should('exist'); + cy.get('[data-test="check-json-schema"]').contains('part of schema'); + }); + + // Test array bracket syntax highlighting in partial schemas (covers lines 171-172) + it('should handle array brackets in partial schema syntax highlighting', () => { + const partialSchemaWithArrays = `// partial schema +{ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } +}`; + + cy.mount( + , + ); + + // Check that the code is rendered with array syntax highlighting + cy.get('[data-test="json-editor"]').should('exist'); + cy.get('[data-test="check-json-schema"]').contains('part of schema'); + }); + + // Test specific array bracket characters to ensure full coverage of mapping logic + it('should handle both opening and closing array brackets in partial schemas', () => { + const arrayBracketsTest = `// partial schema +[ + "item1", + "item2" +]`; + + cy.mount(); + + // Check that the code is rendered with array syntax highlighting + cy.get('[data-test="json-editor"]').should('exist'); + cy.get('[data-test="check-json-schema"]').contains('part of schema'); + }); + + // Test calculateNewDecorationsMap with explicit isPartialSchema=false parameter + it('should use full JSON parsing when isPartialSchema is explicitly false', () => { + const regularJson = `{ + "name": "test", + "value": 123, + "array": [1, 2, 3] +}`; + + cy.mount(); + + // Check that the code is rendered with full JSON parsing + cy.get('[data-test="json-editor"]').should('exist'); + cy.get('[data-test="check-json-schema"]').contains('data'); + }); + + // Test full JSON parsing for non-partial schemas (covers line 194) + it('should use full JSON parsing for complete schemas', () => { + const completeSchema = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } +}`; + + cy.mount(); + + // Check that the code is rendered with full JSON parsing + cy.get('[data-test="json-editor"]').should('exist'); + cy.get('[data-test="check-json-schema"]').contains('schema'); + }); + + // Test meta props with invalid JSON + it('should handle invalid meta props JSON', () => { + const invalidMetaProps = + '// props { "valid": true, "caption": "test" }\n{ "test": "value" }'; + + cy.mount(); + + // Should still render without crashing + cy.get('[data-test="json-editor"]').should('exist'); + }); + + // Test meta props with missing groups + it('should handle meta props with missing groups', () => { + const metaPropsWithoutGroups = '// props {}\n{ "test": "value" }'; + + cy.mount(); + + // Should still render without crashing + cy.get('[data-test="json-editor"]').should('exist'); + }); + + // Test code caption without meta + it('should handle code without caption', () => { + cy.mount(); + + // Should render without caption + cy.get('[data-test="code-caption"]').should('exist'); + }); + + // Test validation without meta + it('should handle code without validation meta', () => { + cy.mount(); + + // Should not show validation alerts + cy.get('[data-test="compliant-to-schema"]').should('not.exist'); + cy.get('[data-test="not-compliant-to-schema"]').should('not.exist'); + }); + + // ===== REGULAR CODE BLOCK TESTS ===== + + // Test regular code block rendering with language and code props + it('should render regular code block with language and code props', () => { + const testCode = `function hello() { + console.log("Hello, World!"); +}`; + + cy.mount(); + + // Should render the code block (not the JSON editor) + cy.get('[data-test="json-editor"]').should('not.exist'); + + // Should show the copy button + cy.get('button').should('be.visible'); + + // Should show the language badge + cy.get('.bg-white\\/20').contains('javascript'); + }); + + // Test regular code block copy functionality + it('should copy regular code block text when copy button is clicked', () => { + const testCode = `function hello() { + console.log("Hello, World!"); +}`; + + // mock clipboard writeText + cy.window().then((win) => { + cy.stub(win.navigator.clipboard, 'writeText').as('clipboardWriteText'); + }); + + cy.mount(); + + // Click on copy button + cy.get('button').click(); + + // Check if clipboard writeText is called with the correct code + cy.get('@clipboardWriteText').should('have.been.calledWith', testCode); + + // Check if copied icon is visible after clicking + cy.get('button img').should('have.attr', 'src', '/icons/copied.svg'); + + // After 2 seconds, check if copy icon is visible again + cy.wait(2100); + cy.get('button img').should('have.attr', 'src', '/icons/copy.svg'); + }); + + // Test regular code block with different languages + it('should display correct language badge for different languages', () => { + const testCode = 'const x = 1;'; + + // Test JavaScript + cy.mount(); + cy.get('.bg-white\\/20').contains('javascript'); + + // Test Python + cy.mount(); + cy.get('.bg-white\\/20').contains('python'); + + // Test TypeScript + cy.mount(); + cy.get('.bg-white\\/20').contains('typescript'); + }); + + // Test regular code block without language + it('should display "code" badge when no language is provided', () => { + const testCode = 'some random code'; + + cy.mount(); + + // Should show "code" as the badge text + cy.get('.bg-white\\/20').contains('code'); + }); + + // Test regular code block with empty code + it('should handle empty code in regular code block', () => { + cy.mount(); + + // Should still render without crashing + cy.get('button').should('be.visible'); + cy.get('.bg-white\\/20').contains('javascript'); + }); + + // Test regular code block with whitespace-only code + it('should handle whitespace-only code in regular code block', () => { + cy.mount(); + + // Should still render without crashing + cy.get('button').should('be.visible'); + cy.get('.bg-white\\/20').contains('javascript'); + }); + + // Test regular code block syntax highlighting + it('should apply syntax highlighting to regular code blocks', () => { + const testCode = `function hello() { + console.log("Hello, World!"); + return true; +}`; + + cy.mount(); + + // Should render the code with syntax highlighting + // The Highlight component should be present + cy.get('.overflow-x-auto').should('exist'); + + // Should show the copy button and badge + cy.get('button').should('be.visible'); + cy.get('.bg-white\\/20').contains('javascript'); + }); + + // Test regular code block with complex code + it('should handle complex code in regular code blocks', () => { + const complexCode = `import React from 'react'; + +interface Props { + name: string; + age: number; +} + +const Component: React.FC = ({ name, age }) => { + const [count, setCount] = React.useState(0); + + React.useEffect(() => { + console.log(\`Hello \${name}, you are \${age} years old\`); + }, [name, age]); + + return ( +
+

Hello {name}!

+

Count: {count}

+ +
+ ); +}; + +export default Component;`; + + cy.mount(); + + // Should render the complex code without crashing + cy.get('button').should('be.visible'); + cy.get('.bg-white\\/20').contains('typescript'); + }); + + // Test that JSON mode and regular code mode are mutually exclusive + it('should prioritize JSON mode when both initialCode and code are provided', () => { + const jsonCode = '{"test": "value"}'; + const regularCode = 'console.log("test");'; + + cy.mount( + , + ); + + // Should render in JSON mode (with JSON editor) + cy.get('[data-test="json-editor"]').should('exist'); + cy.get('[data-test="check-json-schema"]').contains('data'); + }); + + // Test regular code block with special characters + it('should handle special characters in regular code blocks', () => { + const specialCode = + 'const special = "Hello & World! < > " \' \\n \\t \\r";'; + + cy.mount(); + + // Should render without crashing + cy.get('button').should('be.visible'); + cy.get('.bg-white\\/20').contains('javascript'); + }); + + // Test regular code block copy functionality with special characters + it('should copy code with special characters correctly', () => { + const specialCode = + 'const special = "Hello & World! < > " \' \\n \\t \\r";'; + + // mock clipboard writeText + cy.window().then((win) => { + cy.stub(win.navigator.clipboard, 'writeText').as('clipboardWriteText'); + }); + + cy.mount(); + + // Click on copy button + cy.get('button').click(); + + // Check if clipboard writeText is called with the correct code + cy.get('@clipboardWriteText').should('have.been.calledWith', specialCode); + }); }); diff --git a/cypress/components/ui/alert.cy.tsx b/cypress/components/ui/alert.cy.tsx new file mode 100644 index 000000000..44d3d3872 --- /dev/null +++ b/cypress/components/ui/alert.cy.tsx @@ -0,0 +1,214 @@ +import React from 'react'; +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; + +describe('Alert Component', () => { + it('renders basic Alert component correctly', () => { + cy.mount(This is a basic alert message); + + cy.get('[data-slot="alert"]').should('exist'); + cy.get('[role="alert"]').should('exist'); + cy.get('[data-slot="alert"]').contains('This is a basic alert message'); + }); + + it('renders Alert with default variant correctly', () => { + cy.mount(Default alert message); + + cy.get('[data-slot="alert"]').should('exist'); + cy.get('[data-slot="alert"]').should('have.class', 'bg-card'); + cy.get('[data-slot="alert"]').should('have.class', 'text-card-foreground'); + }); + + it('renders Alert with destructive variant correctly', () => { + cy.mount(Destructive alert message); + + cy.get('[data-slot="alert"]').should('exist'); + cy.get('[data-slot="alert"]').should('have.class', 'text-destructive'); + cy.get('[data-slot="alert"]').should('have.class', 'bg-card'); + }); + + it('renders Alert with custom className correctly', () => { + cy.mount( + Alert with custom class, + ); + + cy.get('[data-slot="alert"]').should('have.class', 'custom-alert-class'); + }); + + it('renders Alert with AlertTitle correctly', () => { + cy.mount( + + Alert Title + This is the alert description + , + ); + + cy.get('[data-slot="alert-title"]').should('exist'); + cy.get('[data-slot="alert-title"]').contains('Alert Title'); + cy.get('[data-slot="alert-title"]').should('have.class', 'font-medium'); + }); + + it('renders Alert with AlertDescription correctly', () => { + cy.mount( + + This is an alert description + , + ); + + cy.get('[data-slot="alert-description"]').should('exist'); + cy.get('[data-slot="alert-description"]').contains( + 'This is an alert description', + ); + cy.get('[data-slot="alert-description"]').should( + 'have.class', + 'text-muted-foreground', + ); + }); + + it('renders Alert with both AlertTitle and AlertDescription correctly', () => { + cy.mount( + + Important Notice + + This is a detailed description of the alert message. + + , + ); + + cy.get('[data-slot="alert-title"]').should('exist'); + cy.get('[data-slot="alert-title"]').contains('Important Notice'); + cy.get('[data-slot="alert-description"]').should('exist'); + cy.get('[data-slot="alert-description"]').contains( + 'This is a detailed description of the alert message.', + ); + }); + + it('renders Alert with icon correctly', () => { + cy.mount( + + + Alert with Icon + This alert has an icon + , + ); + + cy.get('[data-testid="alert-icon"]').should('exist'); + cy.get('[data-slot="alert"]').should( + 'have.class', + 'has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr]', + ); + }); + + it('renders destructive Alert with icon correctly', () => { + cy.mount( + + + Destructive Alert + + This is a destructive alert with icon + + , + ); + + cy.get('[data-testid="destructive-icon"]').should('exist'); + cy.get('[data-slot="alert"]').should('have.class', 'text-destructive'); + // Check that the alert description has the data-slot attribute and is styled appropriately + cy.get('[data-slot="alert-description"]').should('exist'); + cy.get('[data-slot="alert-description"]').should( + 'have.attr', + 'data-slot', + 'alert-description', + ); + }); + + it('renders AlertTitle with custom className correctly', () => { + cy.mount( + + Custom Title + , + ); + + cy.get('[data-slot="alert-title"]').should( + 'have.class', + 'custom-title-class', + ); + }); + + it('renders AlertDescription with custom className correctly', () => { + cy.mount( + + + Custom Description + + , + ); + + cy.get('[data-slot="alert-description"]').should( + 'have.class', + 'custom-description-class', + ); + }); + + it('renders multiple Alert components correctly', () => { + cy.mount( +
+ + First Alert + First alert description + + + Second Alert + Second alert description + +
, + ); + + cy.get('[data-testid="alert-1"]').should('exist'); + cy.get('[data-testid="alert-1"] [data-slot="alert-title"]').contains( + 'First Alert', + ); + cy.get('[data-testid="alert-2"]').should('exist'); + cy.get('[data-testid="alert-2"] [data-slot="alert-title"]').contains( + 'Second Alert', + ); + cy.get('[data-testid="alert-2"]').should('have.class', 'text-destructive'); + }); + + it('renders Alert with HTML content correctly', () => { + cy.mount( + + HTML Content Alert + + This alert contains bold text and{' '} + italic text. + + , + ); + + cy.get('[data-slot="alert-description"]').contains('bold text'); + cy.get('[data-slot="alert-description"]').contains('italic text'); + cy.get('[data-slot="alert-description"] strong').should('exist'); + cy.get('[data-slot="alert-description"] em').should('exist'); + }); + + it('renders Alert with accessibility attributes correctly', () => { + cy.mount( + + Accessible Alert + + This alert has custom accessibility attributes + + , + ); + + cy.get('[data-testid="accessible-alert"]').should( + 'have.attr', + 'aria-label', + 'Custom alert', + ); + cy.get('[data-testid="accessible-alert"]').should( + 'have.attr', + 'role', + 'alert', + ); + }); +}); diff --git a/pages/learn/getting-started-step-by-step/getting-started-step-by-step.md b/pages/learn/getting-started-step-by-step/getting-started-step-by-step.md index 1717c9d28..78ca3e695 100644 --- a/pages/learn/getting-started-step-by-step/getting-started-step-by-step.md +++ b/pages/learn/getting-started-step-by-step/getting-started-step-by-step.md @@ -103,6 +103,7 @@ To add the `properties` object to the schema: 1. Add the `properties` validation keyword to the end of the schema: ```jsonc + // partial schema ... "title": "Product", "description": "A product from Acme's catalog", @@ -117,6 +118,7 @@ To add the `properties` object to the schema: * `type`: defines what kind of data is expected. For this example, since the product identifier is a numeric value, use `integer`. ```jsonc + // partial schema ... "properties": { "productId": { @@ -177,6 +179,7 @@ To define a required property: 1. Inside the `properties` object, add the `price` key. Include the usual schema annotations `description` and `type`, where `type` is a number: ```jsonc + // partial schema "properties": { ... "price": { @@ -189,6 +192,7 @@ To define a required property: 2. Add the `exclusiveMinimum` validation keyword and set the value to zero: ```jsonc + // partial schema "price": { "description": "The price of the product", "type": "number", @@ -199,6 +203,7 @@ To define a required property: 3. Add the `required` validation keyword to the end of the schema, after the `properties` object. Add `productID`, `productName`, and the new `price` key to the array: ```jsonc + // partial schema ... "properties": { ... @@ -255,6 +260,7 @@ To define an optional property: 1. Inside the `properties` object, add the `tags` keyword. Include the usual schema annotations `description` and `type`, and define `type` as an array: ```jsonc + // partial schema ... "properties": { ... @@ -268,6 +274,7 @@ To define an optional property: 2. Add a new validation keyword for `items` to define what appears in the array. For example, `string`: ```jsonc + // partial schema ... "tags": { "description": "Tags for the product", @@ -281,6 +288,7 @@ To define an optional property: 3. To make sure there is at least one item in the array, use the `minItems` validation keyword: ```jsonc + // partial schema ... "tags": { "description": "Tags for the product", @@ -295,6 +303,7 @@ To define an optional property: 4. To make sure that every item in the array is unique, use the `uniqueItems` validation keyword and set it to `true`: ```jsonc + // partial schema ... "tags": { "description": "Tags for the product", @@ -357,6 +366,7 @@ To create a nested data structure: 1. Inside the `properties` object, create a new key called `dimensions`: ```jsonc + // partial schema ... "properties": { ... @@ -367,6 +377,7 @@ To create a nested data structure: 2. Define the `type` validation keyword as `object`: ```jsonc + // partial schema ... "dimensions": { "type": "object" @@ -376,6 +387,7 @@ To create a nested data structure: 3. Add the `properties` validation keyword to contain the nested data structure. Inside the new `properties` keyword, add keywords for `length`, `width`, and `height` that all use the `number` type: ```jsonc + // partial schema ... "dimensions": { "type": "object", @@ -396,6 +408,7 @@ To create a nested data structure: 4. To make each of these properties required, add a `required` validation keyword inside the `dimensions` object: ```jsonc + // partial schema ... "dimensions": { "type": "object", @@ -504,6 +517,7 @@ To reference this schema in the product catalog schema: 1. Inside the `properties` object, add a key named `warehouseLocation`: ```jsonc + // partial schema ... "properties": { ... @@ -514,6 +528,7 @@ To reference this schema in the product catalog schema: 2. To link to the external geographical location schema, add the `$ref` schema keyword and the schema URL: ```jsonc + // partial schema ... "warehouseLocation": { "description": "Coordinates of the warehouse where the product is located.", diff --git a/pages/understanding-json-schema/reference/non_json_data.md b/pages/understanding-json-schema/reference/non_json_data.md index 0f401002b..934ce089b 100644 --- a/pages/understanding-json-schema/reference/non_json_data.md +++ b/pages/understanding-json-schema/reference/non_json_data.md @@ -79,7 +79,7 @@ To better understand how `contentEncoding` and `contentMediaType` are applied in ![Role of contentEncoding and contenMediaType keywords in the transmission of non-JSON data](/img/media-keywords.png) --> -```mermaid +```code block-beta columns 9 A space B space C space D space E