diff --git a/.eslintrc.json b/.eslintrc.json index 8f840db..473a1ee 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -21,12 +21,7 @@ "ecmaVersion": "latest", "sourceType": "module" }, - "plugins": [ - "react", - "react-hooks", - "@typescript-eslint", - "prettier" - ], + "plugins": ["react", "react-hooks", "@typescript-eslint", "prettier"], "settings": { "react": { "version": "detect" @@ -44,13 +39,13 @@ ], "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/ban-ts-comment": "warn", + "@typescript-eslint/ban-ts-comment": "error", "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn", + "react-hooks/exhaustive-deps": "error", "prettier/prettier": "error", - "no-console": ["warn", { "allow": ["warn", "error"] }], + "no-console": ["error", { "allow": ["warn", "error"] }], "prefer-const": "error", "no-var": "error" }, diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index dc17ce9..4ae5218 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -2,9 +2,9 @@ name: Checks on: push: - branches: [main, master] + branches: [main] pull_request: - branches: [main, master] + branches: [main] jobs: checks: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e14bcb0..3e26501 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -2,7 +2,7 @@ name: Pull Request on: pull_request: - branches: [main, master] + branches: [main] jobs: pr-checks: diff --git a/render.mjs b/render.mjs index 2af3785..afb7e8a 100644 --- a/render.mjs +++ b/render.mjs @@ -1,18 +1,16 @@ #!/usr/bin/env node -import React from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { createRequire } from "module"; -const require = createRequire(import.meta.url); +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; // Try to import the built component, fall back to source if needed let DeckardSchema; try { - const deckardModule = await import("./dist/index.esm.js"); + const deckardModule = await import('./dist/index.esm.js'); DeckardSchema = deckardModule.DeckardSchema; } catch (error) { console.error( - 'Warning: Could not load built component, ensure "npm run build" has been run', + 'Warning: Could not load built component, ensure "npm run build" has been run' ); process.exit(1); } @@ -20,7 +18,7 @@ try { // Parse command line arguments const args = process.argv.slice(2); const schemaJson = args[0]; -const optionsJson = args[1] || "{}"; +const optionsJson = args[1] || '{}'; try { // Parse input @@ -32,11 +30,11 @@ try { React.createElement(DeckardSchema, { schema: schema, options: options, - }), + }) ); // Output the HTML - console.log(componentHtml); + process.stdout.write(componentHtml); } catch (error) { console.error(`Error rendering schema: ${error.message}`); process.exit(1); diff --git a/src/DeckardSchema.styles.css b/src/DeckardSchema.styles.css index 0770a7d..a8e68f8 100644 --- a/src/DeckardSchema.styles.css +++ b/src/DeckardSchema.styles.css @@ -83,6 +83,13 @@ --schema-accent-hover: rgba(148, 204, 235, 0.08); --schema-modal-bg: #ffffff; } + + .schema-container a.link-button:hover, + .schema-container a.row-button.link-button:hover, + .schema-container a.link-button:visited:hover, + .schema-container a.row-button.link-button:visited:hover { + color: #ffffff; + } } .schema-container * { @@ -114,6 +121,49 @@ margin-bottom: var(--schema-space-md); } +.schema-container .header-controls { + display: flex; + align-items: center; + gap: var(--schema-space-sm); +} + +.schema-container .keyboard-button { + background: none; + border: none; + cursor: pointer; + padding: var(--schema-space-sm); + border-radius: var(--schema-radius-md); + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + color: var(--schema-text-muted); + transition: all var(--schema-transition); + position: relative; +} + +.schema-container .keyboard-button:focus-visible { + outline: none; + background: var(--schema-surface-hover); + color: var(--schema-text); + box-shadow: 0 0 0 2px var(--schema-accent-soft); +} + +.schema-container .keyboard-button:hover { + background: var(--schema-surface-hover); + color: var(--schema-text); +} + +.schema-container .keyboard-button:active { + transform: scale(0.95); +} + +.schema-container .keyboard-button svg { + width: 1rem; + height: 1rem; +} + .schema-container .schema-description { font-size: var(--schema-text-lg); color: var(--schema-text-secondary); @@ -237,18 +287,11 @@ .schema-container .search-input:focus { outline: none; - box-shadow: inset 3px 0 0 var(--schema-border-focus); background: var(--schema-accent-hover); } /* ===== FOCUS STYLES ===== */ -.schema-container button:focus-visible { - outline: none; - box-shadow: inset 3px 0 0 var(--schema-border-focus); - background: var(--schema-accent-hover); -} - /* ===== ARRAY AND COMPOUND SCHEMAS ===== */ .schema-container .array-section, @@ -351,9 +394,11 @@ } .schema-container a.link-button:hover, -.schema-container a.row-button.link-button:hover { +.schema-container a.row-button.link-button:hover, +.schema-container a.link-button:visited:hover, +.schema-container a.row-button.link-button:visited:hover { text-decoration: none; - color: var(--schema-text); + color: #000000; } /* Active route link button - red map pin */ @@ -491,6 +536,10 @@ .schema-container .required-badge { font-weight: 700; } + + .schema-container .keyboard-button { + border: 1px solid var(--schema-border-strong); + } } @media (prefers-reduced-motion: reduce) { @@ -505,4 +554,8 @@ .tooltips-shimmer .tooltip-trigger { animation: none; } + + .schema-container .keyboard-button:active { + transform: none; + } } diff --git a/src/DeckardSchema.tsx b/src/DeckardSchema.tsx index 6110611..18998d3 100644 --- a/src/DeckardSchema.tsx +++ b/src/DeckardSchema.tsx @@ -18,11 +18,275 @@ import './property/CodeSnippet.styles.css'; import './property/ExamplesPanel.styles.css'; import './Rows.styles.css'; import './components/Settings.styles.css'; +import './components/Modal.styles.css'; +import './components/KeyboardModal.styles.css'; import './inputs/RadioGroup.styles.css'; -import { extractProperties, getSchemaType, resolveSchema } from './utils'; +import { + extractProperties, + getSchemaType, + resolveSchema, + hashToPropertyKey, + propertyKeyToHash, +} from './utils'; import Rows from './Rows'; import { Input } from './inputs'; -import { Settings } from './components'; +import { Settings, KeyboardModal, Tooltip } from './components'; +import { FaKeyboard } from 'react-icons/fa'; + +// Helper function to check only direct property matches (not nested) +function isDirectPropertyMatch( + schema: JsonSchema, + rootSchema: JsonSchema, + query: string, + searchIncludesExamples: boolean = false, + propertyName?: string, + visited: Set = new Set(), + depth: number = 0 +): boolean { + // Prevent infinite recursion + if (depth > 10 || visited.has(schema)) { + return false; + } + visited.add(schema); + const queryLower = query.toLowerCase(); + + // Resolve schema first to handle $ref definitions + const resolved = resolveSchema(schema, rootSchema); + + // Check property name + if (propertyName && propertyName.toLowerCase().includes(queryLower)) { + return true; + } + + // Check description (use resolved schema to handle $ref) + if (resolved.description?.toLowerCase().includes(queryLower)) { + return true; + } + + // Check type + if (getSchemaType(resolved, rootSchema).toLowerCase().includes(queryLower)) { + return true; + } + + // Check examples (only if enabled) (use resolved schema to handle $ref) + if ( + searchIncludesExamples && + resolved.examples?.some(example => { + const exampleText = + typeof example === 'string' ? example : JSON.stringify(example); + return exampleText.toLowerCase().includes(queryLower); + }) + ) { + return true; + } + + // Check oneOf options for direct matches (these are part of the property's definition) + if (resolved.oneOf) { + for (let i = 0; i < resolved.oneOf.length; i++) { + const subSchema = resolved.oneOf[i]; + + if ( + isDirectPropertyMatch( + subSchema, + rootSchema, + query, + searchIncludesExamples, + undefined, + visited, + depth + 1 + ) + ) { + return true; + } + } + } + + // Check allOf options for direct matches + if (resolved.allOf) { + for (const subSchema of resolved.allOf) { + if ( + isDirectPropertyMatch( + subSchema, + rootSchema, + query, + searchIncludesExamples, + undefined, + visited, + depth + 1 + ) + ) { + return true; + } + } + } + + // Check anyOf options for direct matches + if (resolved.anyOf) { + for (const subSchema of resolved.anyOf) { + if ( + isDirectPropertyMatch( + subSchema, + rootSchema, + query, + searchIncludesExamples, + undefined, + visited, + depth + 1 + ) + ) { + return true; + } + } + } + + return false; +} + +// Helper function to recursively search through schema structures +function searchInSchema( + schema: JsonSchema, + rootSchema: JsonSchema, + query: string, + searchIncludesExamples: boolean = false, + propertyName?: string, + visited: Set = new Set(), + depth: number = 0 +): boolean { + // Prevent infinite recursion + if (depth > 10 || visited.has(schema)) { + return false; + } + visited.add(schema); + const queryLower = query.toLowerCase(); + + // Check property name + if (propertyName && propertyName.toLowerCase().includes(queryLower)) { + return true; + } + + // Check description + if (schema.description?.toLowerCase().includes(queryLower)) { + return true; + } + + // Check type + if (getSchemaType(schema, rootSchema).toLowerCase().includes(queryLower)) { + return true; + } + + // Check examples (only if enabled) + if ( + searchIncludesExamples && + schema.examples?.some(example => { + const exampleText = + typeof example === 'string' ? example : JSON.stringify(example); + return exampleText.toLowerCase().includes(queryLower); + }) + ) { + return true; + } + + const resolved = resolveSchema(schema, rootSchema); + + // Check properties + if (resolved.properties) { + for (const [propName, propSchema] of Object.entries(resolved.properties)) { + if ( + searchInSchema( + propSchema, + rootSchema, + query, + searchIncludesExamples, + propName, + visited, + depth + 1 + ) + ) { + return true; + } + } + } + + // Check pattern properties + if (resolved.patternProperties) { + for (const [_pattern, propSchema] of Object.entries( + resolved.patternProperties + )) { + if ( + searchInSchema( + propSchema, + rootSchema, + query, + searchIncludesExamples, + undefined, + visited, + depth + 1 + ) + ) { + return true; + } + } + } + + // Check oneOf + if (resolved.oneOf) { + for (const subSchema of resolved.oneOf) { + if ( + searchInSchema( + subSchema, + rootSchema, + query, + searchIncludesExamples, + undefined, + visited, + depth + 1 + ) + ) { + return true; + } + } + } + + // Check allOf + if (resolved.allOf) { + for (const subSchema of resolved.allOf) { + if ( + searchInSchema( + subSchema, + rootSchema, + query, + searchIncludesExamples, + undefined, + visited, + depth + 1 + ) + ) { + return true; + } + } + } + + // Check anyOf + if (resolved.anyOf) { + for (const subSchema of resolved.anyOf) { + if ( + searchInSchema( + subSchema, + rootSchema, + query, + searchIncludesExamples, + undefined, + visited, + depth + 1 + ) + ) { + return true; + } + } + } + + return false; +} interface DeckardSchemaProps { schema: JsonSchema; @@ -37,6 +301,7 @@ const DEFAULT_OPTIONS: DeckardOptions = { includeExamples: false, examplesOnFocusOnly: true, searchable: true, + searchIncludesExamples: false, collapsible: true, autoExpand: false, theme: 'auto', @@ -158,6 +423,8 @@ export const DeckardSchema: React.FC = ({ results: 0, }); const [focusedProperty, setFocusedProperty] = useState(null); + const [keyboardModalOpen, setKeyboardModalOpen] = useState(false); + const [examplesHidden, setExamplesHidden] = useState(false); const properties = useMemo(() => { const props = extractProperties(schema, [], 0, schema, []); @@ -168,31 +435,21 @@ export const DeckardSchema: React.FC = ({ const filteredProperties = useMemo(() => { if (!searchState.query) return properties; - const query = searchState.query.toLowerCase(); - return properties.filter(prop => { - // Search field names - if (prop.name.toLowerCase().includes(query)) return true; - - // Search descriptions - if (prop.schema.description?.toLowerCase().includes(query)) return true; - - // Search schema type - if (getSchemaType(prop.schema, schema).toLowerCase().includes(query)) - return true; - - // Search examples - if (prop.schema.examples) { - for (const example of prop.schema.examples) { - const exampleText = - typeof example === 'string' ? example : JSON.stringify(example); - if (exampleText.toLowerCase().includes(query)) return true; - } - } - - return false; + return searchInSchema( + prop.schema, + schema, + searchState.query, + currentOptions.searchIncludesExamples || false, + prop.name + ); }); - }, [properties, searchState.query, schema]); + }, [ + properties, + searchState.query, + schema, + currentOptions.searchIncludesExamples, + ]); // Initialize property states - all properties are expandable now // Initialize property states for all properties including nested ones @@ -209,6 +466,7 @@ export const DeckardSchema: React.FC = ({ properties.forEach(property => { const key = property.path.join('.'); + newStates[key] = { expanded: Boolean(autoExpand), hasDetails: true, // All fields are expandable now @@ -218,9 +476,12 @@ export const DeckardSchema: React.FC = ({ }; // Recursively initialize nested properties - if (property.schema.properties || property.schema.patternProperties) { + // Resolve the schema to handle $ref and allOf + const resolvedSchema = resolveSchema(property.schema, rootSchema); + + if (resolvedSchema.properties || resolvedSchema.patternProperties) { initializePropertyStates( - property.schema, + resolvedSchema, property.path, depth + 1, rootSchema @@ -233,13 +494,13 @@ export const DeckardSchema: React.FC = ({ initializePropertyStates(schema, [], 0, schema); setPropertyStates(newStates); - }, [schema, autoExpand, searchState.query]); + }, [schema, autoExpand]); // Handle URL hash navigation - only on mount useEffect(() => { const hash = typeof window !== 'undefined' ? window.location.hash : ''; if (hash) { - const fieldKey = hash.substring(1).replace(/-/g, '.'); + const fieldKey = hashToPropertyKey(hash); // Update property states to expand path to target setPropertyStates(prev => { @@ -247,13 +508,23 @@ export const DeckardSchema: React.FC = ({ // Expand all parent paths to make the target field visible const pathParts = fieldKey.split('.'); + for (let i = 1; i <= pathParts.length; i++) { const parentPath = pathParts.slice(0, i).join('.'); + if (newStates[parentPath]) { newStates[parentPath] = { ...newStates[parentPath], expanded: true, }; + } else { + newStates[parentPath] = { + expanded: true, + hasDetails: true, + matchesSearch: true, + isDirectMatch: false, + hasNestedMatches: false, + }; } } @@ -267,9 +538,12 @@ export const DeckardSchema: React.FC = ({ setTimeout(() => { if (typeof document !== 'undefined') { const targetElement = document.getElementById( - fieldKey.replace(/\./g, '-') + propertyKeyToHash(fieldKey) ); - if (targetElement) { + if ( + targetElement && + typeof targetElement.scrollIntoView === 'function' + ) { targetElement.scrollIntoView({ behavior: 'smooth', block: 'start', @@ -321,39 +595,66 @@ export const DeckardSchema: React.FC = ({ } }); - // Then find and mark direct matches + // Find all properties with any matches (direct or nested) + const allMatches = new Set(); const directMatches = new Set(); + Object.keys(newStates).forEach(key => { const pathSegments = key.split('.'); - // Find the property in the schema to check for matches - let currentSchema = schema; + // Find the property in the schema + let currentSchema = resolveSchema(schema, schema); + let propertyName = ''; for (let i = 0; i < pathSegments.length; i++) { const segment = pathSegments[i]; - if (currentSchema.properties?.[segment]) { - currentSchema = currentSchema.properties[segment]; - if (i === pathSegments.length - 1) { - // Check if this property matches search - const matches = - segment.toLowerCase().includes(queryLower) || - currentSchema.description - ?.toLowerCase() - .includes(queryLower) || - getSchemaType(currentSchema, schema) - .toLowerCase() - .includes(queryLower) || - (currentSchema.examples && - currentSchema.examples.some(example => { - const exampleText = - typeof example === 'string' - ? example - : JSON.stringify(example); - return exampleText.toLowerCase().includes(queryLower); - })) || - false; - - if (matches) { + + // Handle pattern properties + if (segment.startsWith('(pattern-') && segment.endsWith(')')) { + if (currentSchema.patternProperties) { + const patternEntries = Object.entries( + currentSchema.patternProperties + ); + const patternIndex = parseInt( + segment.match(/\(pattern-(\d+)\)/)?.[1] || '0' + ); + if (patternEntries[patternIndex]) { + const [_pattern, patternSchema] = + patternEntries[patternIndex]; + currentSchema = resolveSchema(patternSchema, schema); + propertyName = '{pattern}'; + } + } + } else if (currentSchema.properties?.[segment]) { + currentSchema = resolveSchema( + currentSchema.properties[segment], + schema + ); + propertyName = segment; + } + + if (i === pathSegments.length - 1) { + // Check if this property has any matches (direct or nested) + if ( + searchInSchema( + currentSchema, + schema, + queryLower, + currentOptions.searchIncludesExamples || false, + propertyName + ) + ) { + allMatches.add(key); + + // Check if it's also a direct match + const isDirect = isDirectPropertyMatch( + currentSchema, + schema, + queryLower, + currentOptions.searchIncludesExamples || false, + propertyName + ); + if (isDirect) { directMatches.add(key); } } @@ -361,29 +662,49 @@ export const DeckardSchema: React.FC = ({ } }); - // Mark direct matches and propagate up parent chain - - directMatches.forEach(matchKey => { - // Mark the direct match + // Mark all matches and determine their type + allMatches.forEach(matchKey => { if (newStates[matchKey]) { + const isDirect = directMatches.has(matchKey); + + // Check if this property has nested matches by looking for child properties in allMatches + const hasChildMatches = Array.from(allMatches).some( + otherKey => + otherKey !== matchKey && otherKey.startsWith(matchKey + '.') + ); + + // Classification logic with debug output for dependencies + newStates[matchKey] = { ...newStates[matchKey], - expanded: true, + expanded: false, matchesSearch: true, - isDirectMatch: true, + isDirectMatch: isDirect, + hasNestedMatches: hasChildMatches, }; } + }); - // Propagate up the parent chain + // Propagate nested matches up parent chain + allMatches.forEach(matchKey => { const pathSegments = matchKey.split('.'); for (let i = pathSegments.length - 1; i > 0; i--) { const parentKey = pathSegments.slice(0, i).join('.'); - if (newStates[parentKey]) { + if (newStates[parentKey] && !allMatches.has(parentKey)) { + // Parent doesn't have its own matches, so mark it as having nested matches only newStates[parentKey] = { ...newStates[parentKey], - expanded: true, + expanded: false, matchesSearch: true, hasNestedMatches: true, + isDirectMatch: false, + }; + allMatches.add(parentKey); // Add to allMatches to continue propagating up + } else if (newStates[parentKey] && directMatches.has(parentKey)) { + // Parent has direct matches, so it should be both-hit + newStates[parentKey] = { + ...newStates[parentKey], + hasNestedMatches: true, }; } } @@ -407,7 +728,13 @@ export const DeckardSchema: React.FC = ({ setPropertyStates(newStates); } }, - [propertyStates, collapsible, schema, autoExpand] + [ + propertyStates, + collapsible, + schema, + autoExpand, + currentOptions.searchIncludesExamples, + ] ); const expandAll = useCallback(() => { @@ -461,7 +788,7 @@ export const DeckardSchema: React.FC = ({ const isInView = rect.top >= 0 && rect.bottom <= viewportHeight; // Only scroll if the element is not fully visible - if (!isInView) { + if (!isInView && typeof element.scrollIntoView === 'function') { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } @@ -495,21 +822,23 @@ export const DeckardSchema: React.FC = ({ schema, [] ); + allNestedProps.push(...nested); // Then, collect allOf properties if they exist if (prop.schema.allOf) { - prop.schema.allOf.forEach(allOfSchema => { + prop.schema.allOf.forEach((allOfSchema, _index) => { const resolvedSchema = resolveSchema(allOfSchema, schema); + if (resolvedSchema.properties || resolvedSchema.patternProperties) { - const allOfPath = [...prop.path, 'allof']; const allOfProps = extractProperties( resolvedSchema, - allOfPath, + prop.path, prop.depth + 1, schema, [] ); + allNestedProps.push(...allOfProps); } }); @@ -663,8 +992,8 @@ export const DeckardSchema: React.FC = ({ e.stopPropagation(); } - // Search shortcut: Shift + / - if (e.key === '?' && e.shiftKey) { + // Search shortcut: s + if (e.key === 's' && !e.ctrlKey && !e.metaKey && !e.shiftKey) { e.preventDefault(); if (typeof document !== 'undefined') { const searchInput = document.querySelector( @@ -694,11 +1023,29 @@ export const DeckardSchema: React.FC = ({ } // Collapse all: Ctrl/Cmd + Shift + E - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'E') { + if ( + (e.ctrlKey || e.metaKey) && + e.shiftKey && + (e.key === 'E' || e.key === 'e') + ) { e.preventDefault(); collapseAll(); } + // Toggle examples visibility: e + if (e.key === 'e' && !e.ctrlKey && !e.metaKey && !e.shiftKey) { + e.preventDefault(); + setExamplesHidden(prev => !prev); + return; + } + + // Open keyboard shortcuts: Shift + ? + if (e.key === '?' && e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + setKeyboardModalOpen(true); + return; + } + // Clear search: Escape if (e.key === 'Escape') { clearSearch(); @@ -802,7 +1149,7 @@ export const DeckardSchema: React.FC = ({ return; } - const anchor = `#${propertyKey}`; + const anchor = `#${propertyKeyToHash(propertyKey)}`; const url = `${window.location.origin}${window.location.pathname}${anchor}`; // Update the URL in the address bar (this will trigger native browser scrolling) @@ -855,6 +1202,14 @@ export const DeckardSchema: React.FC = ({ [] ); + const handleKeyboardModalToggle = useCallback(() => { + setKeyboardModalOpen(prev => !prev); + }, []); + + const handleKeyboardModalClose = useCallback(() => { + setKeyboardModalOpen(false); + }, []); + return ( <> @@ -870,28 +1225,56 @@ export const DeckardSchema: React.FC = ({ {includePropertiesTitle && (

Properties

- +
+ + + + +
)} {!includePropertiesTitle && (
- +
+ + + + +
)} @@ -901,7 +1284,7 @@ export const DeckardSchema: React.FC = ({ type="search" variant="search" size="md" - placeholder="Search properties... (press Shift+/)" + placeholder="Search properties... (press 's')" value={searchState.query} onChange={e => handleSearch(e.target.value)} tabIndex={1} @@ -942,6 +1325,7 @@ export const DeckardSchema: React.FC = ({ defaultExampleLanguage: currentOptions.defaultExampleLanguage, }} searchQuery={searchState.query} + examplesHidden={examplesHidden} /> )} @@ -963,6 +1347,11 @@ export const DeckardSchema: React.FC = ({ ))} )} +
diff --git a/src/PropertyRow.styles.ts b/src/PropertyRow.styles.ts index 94d7b28..c14763e 100644 --- a/src/PropertyRow.styles.ts +++ b/src/PropertyRow.styles.ts @@ -131,7 +131,6 @@ export const propertyRowStyles = ` color: var(--schema-text-secondary); font-size: var(--schema-text-base); line-height: 1.5; - margin-bottom: var(--schema-space-md); } .schema-container .schema-details .property-description-block:only-child { diff --git a/src/Rows.tsx b/src/Rows.tsx index 9720070..050ed36 100644 --- a/src/Rows.tsx +++ b/src/Rows.tsx @@ -21,6 +21,7 @@ interface RowsProps { className?: string; options?: { defaultExampleLanguage?: 'json' | 'yaml' | 'toml' }; searchQuery?: string; + examplesHidden?: boolean; } const Rows: React.FC = ({ @@ -40,6 +41,7 @@ const Rows: React.FC = ({ className = '', options, searchQuery, + examplesHidden = false, }) => { const rowsClasses = ['properties-rows', className].filter(Boolean).join(' '); @@ -62,13 +64,14 @@ const Rows: React.FC = ({ collapsible={collapsible} includeExamples={includeExamples} examplesOnFocusOnly={examplesOnFocusOnly} - propertyStates={propertyStates} rootSchema={rootSchema} + propertyStates={propertyStates} toggleProperty={toggleProperty} focusedProperty={focusedProperty} onFocusChange={onFocusChange} options={options} searchQuery={searchQuery} + examplesHidden={examplesHidden} /> ); })} diff --git a/src/__tests__/DeckardSchema.test.tsx b/src/__tests__/DeckardSchema.test.tsx index cfdacb4..326cb76 100644 --- a/src/__tests__/DeckardSchema.test.tsx +++ b/src/__tests__/DeckardSchema.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { DeckardSchema } from '../DeckardSchema'; import { JsonSchema } from '../types'; @@ -33,4 +33,2253 @@ describe('DeckardSchema', () => { render(); expect(screen.getByText('required')).toBeInTheDocument(); }); + + describe('search functionality with real-world schema', () => { + const realWorldSchema: JsonSchema = { + type: 'object', + properties: { + sdk: { + title: 'SDK configuration', + allOf: [ + { + $ref: '#/definitions/sdkConfig', + }, + { + type: 'object', + patternProperties: { + '^[a-zA-Z0-9_-]+$': { + oneOf: [ + { + $ref: '#/definitions/sdkConfig', + }, + { + type: ['string', 'number', 'boolean', 'array', 'object'], + }, + ], + description: + 'Target-specific SDK configuration that overrides the default SDK settings for a particular target architecture.', + }, + }, + }, + ], + description: + 'SDK settings for building your Avocado OS project. Defines the build environment, dependencies, and compilation configurations.', + }, + ext: { + type: 'object', + description: 'Extension configuration', + properties: { + name: { + type: 'string', + description: 'Extension name', + }, + }, + }, + }, + definitions: { + sdkConfig: { + type: 'object', + properties: { + dependencies: { + type: 'object', + description: + 'SDK-level dependencies required for building the project. These dependencies are specific to the SDK environment.', + }, + image: { + type: 'string', + description: + 'Docker image to use for the SDK build environment. This provides the base system and tools needed for compilation.', + }, + repo_release: { + type: 'string', + description: + 'Specific release/tag of the SDK repository to use. Can be overridden per target.', + }, + repo_url: { + type: 'string', + description: + 'URL of the repository containing SDK resources. Can be overridden per target.', + }, + }, + }, + }, + }; + + test('shows both-hit indicator for properties with direct and nested matches', async () => { + render( + + ); + + // Search for "sdk" + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'sdk' } }); + + await waitFor(() => { + // Should find the sdk property + const sdkProperty = screen.getByText('sdk'); + expect(sdkProperty).toBeInTheDocument(); + }); + + await waitFor(() => { + // The "sdk" property should show as both-hit because: + // 1. It matches "sdk" directly (property name) + // 2. It has nested properties with descriptions containing "SDK" + const bothHitIndicator = document.querySelector('.both-hit'); + + // Should be both-hit, not just direct-hit + expect(bothHitIndicator).toBeInTheDocument(); + + // Should have the viewfinder icon for both-hit + if (bothHitIndicator) { + const icon = bothHitIndicator.querySelector('svg'); + expect(icon?.getAttribute('viewBox')).toBe('0 0 640 512'); + } + + // Should have correct tooltip + expect(bothHitIndicator?.getAttribute('aria-label')).toContain( + 'matches search and contains nested matches' + ); + }); + }); + + test('preserves search indicators when toggling property expansion', async () => { + render( + + ); + + // Search for "sdk" + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'sdk' } }); + + await waitFor(() => { + const sdkProperty = screen.getByText('sdk'); + expect(sdkProperty).toBeInTheDocument(); + }); + + // Find the sdk property and click its toggle button + const sdkElement = document.querySelector('[data-property-key="sdk"]'); + const sdkToggleButton = sdkElement?.querySelector( + '.expand-button' + ) as HTMLElement; + fireEvent.click(sdkToggleButton); + + // The search indicator should still be present after toggling + await waitFor(() => { + const searchIndicator = document.querySelector('.search-hit-indicator'); + expect(searchIndicator).toBeInTheDocument(); + }); + }); + + test('handles pattern properties with oneOf types in search', async () => { + const patternOneOfSchema: JsonSchema = { + type: 'object', + properties: { + services: { + type: 'object', + description: 'Service configurations', + patternProperties: { + '^[a-zA-Z0-9_-]+$': { + oneOf: [ + { + type: 'object', + properties: { + port: { + type: 'number', + description: 'Service port number', + }, + host: { + type: 'string', + description: 'Service hostname', + }, + }, + }, + { + type: 'string', + description: 'Service URL string', + }, + ], + description: 'Dynamic service configuration', + }, + }, + }, + }, + }; + + render( + + ); + + // Search for "service" which should match the parent and pattern property descriptions + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'service' } }); + + await waitFor(() => { + // Should find the services property + const servicesProperty = screen.getByText('services'); + expect(servicesProperty).toBeInTheDocument(); + }); + + await waitFor(() => { + // The "services" property should show as both-hit because: + // 1. It has "Service" in its description (direct match) + // 2. It has pattern properties with "Service" in their descriptions (nested matches) + const bothHitIndicator = document.querySelector('.both-hit'); + const directHitIndicator = document.querySelector('.direct-hit'); + + // Should be both-hit or direct-hit + expect(bothHitIndicator || directHitIndicator).toBeInTheDocument(); + }); + + // Now search for "port" which should only match nested properties + fireEvent.change(searchInput, { target: { value: 'port' } }); + + await waitFor(() => { + // Should still show services property due to nested match + const servicesProperty = screen.getByText('services'); + expect(servicesProperty).toBeInTheDocument(); + + // Should be collapsed even though it contains matches + const servicesElement = document.querySelector( + '[data-property-key="services"]' + ); + expect(servicesElement?.classList.contains('expanded')).toBe(false); + }); + + await waitFor(() => { + // The "services" property should show as indirect-hit because: + // It doesn't match "port" directly but contains nested "port" matches + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeInTheDocument(); + }); + }); + + test('search respects collapsible setting when collapsing properties', async () => { + const nestedSchema: JsonSchema = { + type: 'object', + properties: { + config: { + type: 'object', + description: 'Configuration settings', + properties: { + database: { + type: 'object', + description: 'Database configuration', + properties: { + host: { + type: 'string', + description: 'Database host', + }, + port: { + type: 'number', + description: 'Database port', + }, + }, + }, + }, + }, + }, + }; + + // Test with collapsible: true - should collapse everything when searching + const { rerender } = render( + + ); + + // Start a search with collapsible: true - should collapse and then expand matches + let searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'host' } }); + + await waitFor(() => { + // Should show indirect-hit indicator for config (contains nested "host" match but doesn't match "host" directly) + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeInTheDocument(); + }); + + // Test with collapsible: false - should not collapse when searching + rerender( + + ); + + // Clear search first + searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: '' } }); + + await waitFor(() => { + const configProperty = screen.getByText('config'); + expect(configProperty).toBeInTheDocument(); + }); + + // Start a search with collapsible: false - should maintain expansion state + fireEvent.change(searchInput, { target: { value: 'host' } }); + + await waitFor(() => { + // Should still find the config property and show search indicators + const configProperty = screen.getByText('config'); + expect(configProperty).toBeInTheDocument(); + + // Should show indirect-hit indicator + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeInTheDocument(); + }); + }); + + test('search collapses properties when collapsible is true', async () => { + const expandableSchema: JsonSchema = { + type: 'object', + properties: { + config: { + type: 'object', + description: 'Configuration settings', + properties: { + database: { + type: 'object', + description: 'Database configuration', + properties: { + host: { + type: 'string', + description: 'Database host', + }, + }, + }, + cache: { + type: 'object', + description: 'Cache configuration', + properties: { + redis: { + type: 'string', + description: 'Redis connection string', + }, + }, + }, + }, + }, + }, + }; + + render( + + ); + + // Initially, properties should be expanded due to autoExpand + await waitFor(() => { + const configProperty = screen.getByText('config'); + expect(configProperty).toBeInTheDocument(); + + // Should be expanded initially + const configElement = document.querySelector( + '[data-property-key="config"]' + ); + expect(configElement?.classList.contains('expanded')).toBe(true); + }); + + // Start a search - this should collapse everything first, then expand only matches + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'redis' } }); + + await waitFor(() => { + // Config should be visible (contains nested match) but collapsed non-matching children + const configProperty = screen.getByText('config'); + expect(configProperty).toBeInTheDocument(); + + // Should show indirect-hit indicator for config (contains nested redis match but doesn't match "redis" directly) + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeInTheDocument(); + }); + + // Clear search to verify properties expand back to autoExpand state + fireEvent.change(searchInput, { target: { value: '' } }); + + await waitFor(() => { + const configProperty = screen.getByText('config'); + expect(configProperty).toBeInTheDocument(); + + // Should be expanded again after clearing search + const configElement = document.querySelector( + '[data-property-key="config"]' + ); + expect(configElement?.classList.contains('expanded')).toBe(true); + }); + }); + + test('search collapses everything and does not expand matches', async () => { + const nestedSchema: JsonSchema = { + type: 'object', + properties: { + user: { + type: 'object', + description: 'User configuration', + properties: { + profile: { + type: 'object', + description: 'User profile information', + properties: { + name: { + type: 'string', + description: 'User name', + }, + email: { + type: 'string', + description: 'User email address', + }, + }, + }, + settings: { + type: 'object', + description: 'User settings', + properties: { + theme: { + type: 'string', + description: 'UI theme preference', + }, + }, + }, + }, + }, + }, + }; + + render( + + ); + + // Initially, properties should be expanded due to autoExpand + await waitFor(() => { + const userProperty = screen.getByText('user'); + expect(userProperty).toBeInTheDocument(); + + // Should be expanded initially + const userElement = document.querySelector( + '[data-property-key="user"]' + ); + expect(userElement?.classList.contains('expanded')).toBe(true); + }); + + // Start a search - this should collapse everything and NOT expand matches + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'email' } }); + + await waitFor(() => { + // User property should be visible (contains nested match) + const userProperty = screen.getByText('user'); + expect(userProperty).toBeInTheDocument(); + + // Should show indirect-hit indicator (user contains nested email match but doesn't match "email" directly) + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeInTheDocument(); + + // User should be COLLAPSED, not expanded, even though it contains a match + const userElement = document.querySelector( + '[data-property-key="user"]' + ); + expect(userElement?.classList.contains('expanded')).toBe(false); + }); + }); + + test('search collapses previously expanded properties', async () => { + const expandableSchema: JsonSchema = { + type: 'object', + properties: { + database: { + type: 'object', + description: 'Database configuration', + properties: { + host: { + type: 'string', + description: 'Database host', + }, + port: { + type: 'number', + description: 'Database port', + }, + }, + }, + cache: { + type: 'object', + description: 'Cache configuration', + properties: { + redis: { + type: 'string', + description: 'Redis connection string', + }, + }, + }, + }, + }; + + render( + + ); + + // Initially, properties should be expanded due to autoExpand + await waitFor(() => { + const databaseElement = document.querySelector( + '[data-property-key="database"]' + ); + const cacheElement = document.querySelector( + '[data-property-key="cache"]' + ); + + expect(databaseElement?.classList.contains('expanded')).toBe(true); + expect(cacheElement?.classList.contains('expanded')).toBe(true); + }); + + // Start a search - this should collapse ALL properties, even previously expanded ones + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'redis' } }); + + await waitFor(() => { + // Only cache should be visible since database doesn't match "redis" search + const cacheElement = document.querySelector( + '[data-property-key="cache"]' + ); + const databaseElement = document.querySelector( + '[data-property-key="database"]' + ); + + // Cache should be visible and collapsed (even though it contains a match) + expect(cacheElement).toBeInTheDocument(); + expect(cacheElement?.classList.contains('expanded')).toBe(false); + + // Database should not be visible since it doesn't match the search + expect(databaseElement).toBeNull(); + + // Cache should show indirect-hit indicator (contains nested "redis" match but doesn't match "redis" directly) + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeInTheDocument(); + }); + }); + + test('searchIncludesExamples disabled by default - does not search examples', async () => { + const schemaWithExamples: JsonSchema = { + type: 'object', + properties: { + apiKey: { + type: 'string', + description: 'API key for authentication', + examples: ['sk_test_12345', 'sk_live_67890'], + }, + }, + }; + + render( + + ); + + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'sk_test' } }); + + await waitFor(() => { + // Should show no results since examples are not searched by default + const noResults = document.querySelector('.no-search-results'); + expect(noResults).toBeInTheDocument(); + }); + }); + + test('searchIncludesExamples enabled - searches examples content', async () => { + const schemaWithExamples: JsonSchema = { + type: 'object', + properties: { + apiKey: { + type: 'string', + description: 'API key for authentication', + examples: ['sk_test_12345', 'sk_live_67890'], + }, + config: { + type: 'object', + description: 'Configuration settings', + properties: { + timeout: { + type: 'number', + description: 'Request timeout', + examples: [5000, 10000], + }, + }, + }, + }, + }; + + render( + + ); + + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'sk_test' } }); + + await waitFor(() => { + // Should find apiKey property since examples are now searched + const apiKeyProperty = screen.getByText('apiKey'); + expect(apiKeyProperty).toBeInTheDocument(); + + // Should show direct hit indicator + const directHitIndicator = document.querySelector('.direct-hit'); + expect(directHitIndicator).toBeInTheDocument(); + }); + + // Test nested example search + fireEvent.change(searchInput, { target: { value: '5000' } }); + + await waitFor(() => { + // Should find config property through nested timeout example + const configProperty = screen.getByText('config'); + expect(configProperty).toBeInTheDocument(); + + // Should show indirect-hit indicator for config (contains nested example match but doesn't match "5000" directly) + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeInTheDocument(); + }); + }); + + test('searchIncludesExamples disabled - parent gets indirect-hit when only sub-properties match', async () => { + const complexSchema: JsonSchema = { + type: 'object', + properties: { + storage: { + type: 'object', + description: 'Storage system settings', + examples: ['redis://localhost:6379/cache'], // This should NOT match when examples disabled + properties: { + connection: { + type: 'object', + description: 'Connection parameters', + properties: { + endpoint: { + type: 'string', + description: 'Service endpoint URL with zebra protocol', // This SHOULD match "zebra" + }, + }, + }, + }, + }, + }, + }; + + render( + + ); + + const searchInput = screen.getByPlaceholderText(/search properties/i); + // Search for "zebra" which exists only in deeply nested endpoint description + fireEvent.change(searchInput, { target: { value: 'zebra' } }); + + await waitFor(() => { + // Storage property should be visible because it contains a nested match + const storageProperty = screen.getByText('storage'); + expect(storageProperty).toBeInTheDocument(); + + // Should show indirect-hit indicator ONLY (not both-hit) because: + // - Examples search is disabled, so "redis://localhost..." doesn't match + // - Parent "storage" name and description don't contain "zebra" + // - Only the deeply nested "endpoint" property description matches "zebra" + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeInTheDocument(); + + // Should NOT show both-hit indicator + const bothHitIndicator = document.querySelector('.both-hit'); + expect(bothHitIndicator).toBeNull(); + + // Should NOT show direct-hit indicator + const directHitIndicator = document.querySelector('.direct-hit'); + expect(directHitIndicator).toBeNull(); + }); + }); + + test('comprehensive search indicator coverage - all three types', async () => { + const comprehensiveSchema: JsonSchema = { + type: 'object', + properties: { + direct: { + type: 'string', + description: 'A property that contains the search term directly', + }, + indirect: { + type: 'object', + description: 'Container property', + properties: { + nested: { + type: 'string', + description: 'Contains the search term', + }, + }, + }, + both: { + type: 'object', + description: 'Contains the search term and has nested matches', + properties: { + child: { + type: 'string', + description: 'Also contains the search term', + }, + }, + }, + nomatch: { + type: 'string', + description: 'Does not contain anything relevant', + }, + }, + }; + + render( + + ); + + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'search term' } }); + + await waitFor(() => { + // Direct match - property description contains "search term" + const directProperty = screen.getByText('direct'); + expect(directProperty).toBeInTheDocument(); + + // Indirect match - property has nested "search term" matches but doesn't match itself + const indirectProperty = screen.getByText('indirect'); + expect(indirectProperty).toBeInTheDocument(); + + // Both match - property description AND nested properties contain "search term" + const bothProperty = screen.getByText('both'); + expect(bothProperty).toBeInTheDocument(); + + // No match property should not be visible + const noMatchProperty = screen.queryByText('nomatch'); + expect(noMatchProperty).toBeNull(); + }); + + await waitFor(() => { + // Check that we have exactly one of each indicator type + const directHitIndicator = document.querySelector('.direct-hit'); + const indirectHitIndicator = document.querySelector('.indirect-hit'); + const bothHitIndicator = document.querySelector('.both-hit'); + + expect(directHitIndicator).toBeInTheDocument(); + expect(indirectHitIndicator).toBeInTheDocument(); + expect(bothHitIndicator).toBeInTheDocument(); + + // Verify we have exactly 3 search indicators total + const allIndicators = document.querySelectorAll( + '.search-hit-indicator' + ); + expect(allIndicators).toHaveLength(3); + }); + }); + + test('oneOf description matches should be direct hits', async () => { + const oneOfSchema: JsonSchema = { + type: 'object', + properties: { + dependencies: { + oneOf: [ + { + type: 'string', + description: 'Simple string dependency', + }, + { + type: 'object', + description: + 'Object defining extension dependencies or package dependencies with version constraints. For runtime dependencies, can define extension dependencies. For extension dependencies, can include SDK compile configurations.', + }, + ], + }, + }, + }; + + render( + + ); + + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'SDK' } }); + + await waitFor(() => { + // Should find the dependencies property + const dependenciesProperty = screen.getByText('dependencies'); + expect(dependenciesProperty).toBeInTheDocument(); + }); + + await waitFor(() => { + // Should show direct-hit indicator because the match is in the oneOf description + // which is part of the property's own definition + const directHitIndicator = document.querySelector('.direct-hit'); + expect(directHitIndicator).toBeInTheDocument(); + + // Should NOT show indirect-hit since the match is direct + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeNull(); + }); + }); + + test('oneOf direct match with nested matches should be both-hit', async () => { + const oneOfWithNestedSchema: JsonSchema = { + type: 'object', + properties: { + dependencies: { + oneOf: [ + { + type: 'string', + description: 'Simple string dependency', + }, + { + type: 'object', + description: + 'Object defining extension dependencies or package dependencies with version constraints. For runtime dependencies, can define extension dependencies. For extension dependencies, can include SDK compile configurations.', + properties: { + compile: { + type: 'object', + description: 'SDK compilation settings', + properties: { + flags: { + type: 'array', + description: 'SDK compiler flags', + }, + }, + }, + }, + }, + ], + }, + }, + }; + + render( + + ); + + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'SDK' } }); + + await waitFor(() => { + // Should find the dependencies property + const dependenciesProperty = screen.getByText('dependencies'); + expect(dependenciesProperty).toBeInTheDocument(); + }); + + await waitFor(() => { + // Should show direct-hit indicator because oneOf properties are part of the + // property's own definition, not separate nested entities + const directHitIndicator = document.querySelector('.direct-hit'); + expect(directHitIndicator).toBeInTheDocument(); + + // Should NOT show both-hit or indirect-hit since oneOf content is direct + const bothHitIndicator = document.querySelector('.both-hit'); + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(bothHitIndicator).toBeNull(); + expect(indirectHitIndicator).toBeNull(); + }); + }); + + test('search with no results shows empty state', async () => { + const simpleSchema: JsonSchema = { + type: 'object', + properties: { + name: { + type: 'string', + description: 'User name', + }, + email: { + type: 'string', + description: 'User email address', + }, + age: { + type: 'number', + description: 'User age in years', + }, + }, + }; + + render( + + ); + + // Initially, all properties should be visible + await waitFor(() => { + expect(screen.getByText('name')).toBeInTheDocument(); + expect(screen.getByText('email')).toBeInTheDocument(); + expect(screen.getByText('age')).toBeInTheDocument(); + }); + + // Search for something that doesn't match anything + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'nonexistent' } }); + + await waitFor(() => { + // No properties should be visible + expect(screen.queryByText('name')).toBeNull(); + expect(screen.queryByText('email')).toBeNull(); + expect(screen.queryByText('age')).toBeNull(); + + // Should not have any search indicators + const searchIndicators = document.querySelectorAll( + '.search-hit-indicator' + ); + expect(searchIndicators).toHaveLength(0); + + // Should show the no search results message + const noResultsMessage = screen.getByText( + 'No properties match your search' + ); + expect(noResultsMessage).toBeInTheDocument(); + + // Should have the no search results container + const noResultsContainer = document.querySelector('.no-search-results'); + expect(noResultsContainer).toBeInTheDocument(); + }); + + // Clear the search to verify properties come back + fireEvent.change(searchInput, { target: { value: '' } }); + + await waitFor(() => { + // All properties should be visible again + expect(screen.getByText('name')).toBeInTheDocument(); + expect(screen.getByText('email')).toBeInTheDocument(); + expect(screen.getByText('age')).toBeInTheDocument(); + }); + }); + + test('search transitions from results to no results and back', async () => { + const testSchema: JsonSchema = { + type: 'object', + properties: { + username: { + type: 'string', + description: 'User login name', + }, + password: { + type: 'string', + description: 'User password', + }, + settings: { + type: 'object', + description: 'Application settings', + properties: { + theme: { + type: 'string', + description: 'UI theme preference', + }, + }, + }, + }, + }; + + render( + + ); + + const searchInput = screen.getByPlaceholderText(/search properties/i); + + // Start with a search that has results + fireEvent.change(searchInput, { target: { value: 'user' } }); + + await waitFor(() => { + // Should find username (contains "user") and password (description contains "User") + expect(screen.getByText('username')).toBeInTheDocument(); + expect(screen.getByText('password')).toBeInTheDocument(); + expect(screen.queryByText('settings')).toBeNull(); + + // Should have search indicators + const searchIndicators = document.querySelectorAll( + '.search-hit-indicator' + ); + expect(searchIndicators.length).toBeGreaterThan(0); + }); + + // Change to a search with no results + fireEvent.change(searchInput, { target: { value: 'xyz123nonexistent' } }); + + await waitFor(() => { + // No properties should be visible + expect(screen.queryByText('username')).toBeNull(); + expect(screen.queryByText('password')).toBeNull(); + expect(screen.queryByText('settings')).toBeNull(); + + // Should show no search results message + expect( + screen.getByText('No properties match your search') + ).toBeInTheDocument(); + + // Should not have any search indicators + const searchIndicators = document.querySelectorAll( + '.search-hit-indicator' + ); + expect(searchIndicators).toHaveLength(0); + }); + + // Search for something that has results again + fireEvent.change(searchInput, { target: { value: 'settings' } }); + + await waitFor(() => { + // Should find settings property + expect(screen.getByText('settings')).toBeInTheDocument(); + expect(screen.queryByText('username')).toBeNull(); + expect(screen.queryByText('password')).toBeNull(); + + // No search results message should be gone + expect( + screen.queryByText('No properties match your search') + ).toBeNull(); + + // Should have search indicators again + const searchIndicators = document.querySelectorAll( + '.search-hit-indicator' + ); + expect(searchIndicators.length).toBeGreaterThan(0); + }); + }); + + test('oneOf description match with nested properties should be direct hit (not indirect)', async () => { + const complexOneOfSchema: JsonSchema = { + type: 'object', + properties: { + dependencies: { + oneOf: [ + { + type: 'string', + description: 'Simple string dependency', + }, + { + type: 'object', + description: + 'Object defining extension dependencies or package dependencies with version constraints. For runtime dependencies, can define extension dependencies. For extension dependencies, can include SDK compile configurations.', + properties: { + compile: { + type: 'object', + description: 'Compilation settings', + properties: { + flags: { + type: 'array', + description: 'Compiler flags', + }, + }, + }, + runtime: { + type: 'object', + description: 'Runtime configuration settings', + properties: { + version: { + type: 'string', + description: 'Version constraint', + }, + }, + }, + }, + }, + ], + }, + }, + }; + + render( + + ); + + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'SDK' } }); + + await waitFor(() => { + // Should find the dependencies property + const dependenciesProperty = screen.getByText('dependencies'); + expect(dependenciesProperty).toBeInTheDocument(); + }); + + await waitFor(() => { + // Should show direct-hit indicator because the match is in the oneOf description + // Even though the oneOf item also has nested properties, the match in the oneOf + // description itself should make this a direct hit on the parent property + const directHitIndicator = document.querySelector('.direct-hit'); + expect(directHitIndicator).toBeInTheDocument(); + + // Should NOT show indirect-hit or both-hit since the match is direct in oneOf description + const indirectHitIndicator = document.querySelector('.indirect-hit'); + const bothHitIndicator = document.querySelector('.both-hit'); + expect(indirectHitIndicator).toBeNull(); + expect(bothHitIndicator).toBeNull(); + }); + }); + + test('oneOf with patternProperties should show direct hit for description match', async () => { + const patternPropertiesOneOfSchema: JsonSchema = { + type: 'object', + properties: { + dependencies: { + title: 'Dependencies', + oneOf: [ + { + type: 'string', + description: + "Version requirement string. Use '*' for any version, '>=X.Y' for minimum version, 'X.Y.Z' for exact version, or semantic version ranges.", + }, + { + type: 'object', + description: + 'Object defining extension dependencies or package dependencies with version constraints. For runtime dependencies, can define extension dependencies. For extension dependencies, can include SDK compile configurations.', + patternProperties: { + '^[a-zA-Z0-9_.-]+$': { + oneOf: [ + { + type: 'string', + description: 'Version constraint string.', + }, + { + type: 'object', + description: 'Extension dependency object.', + properties: { + path: { type: 'string' }, + version: { type: 'string' }, + }, + additionalProperties: true, + }, + ], + }, + }, + additionalProperties: false, + }, + ], + }, + }, + }; + + render( + + ); + + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'SDK' } }); + + await waitFor(() => { + // Should find the dependencies property + const dependenciesProperty = screen.getByText('dependencies'); + expect(dependenciesProperty).toBeInTheDocument(); + }); + + await waitFor(() => { + // Should show direct-hit indicator because the match is in the oneOf description + // even though the oneOf object option has patternProperties with nested structure + const directHitIndicator = document.querySelector('.direct-hit'); + expect(directHitIndicator).toBeInTheDocument(); + + // Should NOT show indirect-hit since the match is in the oneOf description, not in nested properties + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeNull(); + }); + }); + + test('oneOf with nested properties but description match should be direct hit', async () => { + const oneOfWithNestedSchema: JsonSchema = { + type: 'object', + properties: { + dependencies: { + description: 'Package dependencies required by the extension.', + oneOf: [ + { + type: 'string', + description: 'Simple string dependency', + }, + { + type: 'object', + description: + 'Object defining extension dependencies or package dependencies with version constraints. For runtime dependencies, can define extension dependencies. For extension dependencies, can include SDK compile configurations.', + properties: { + 'example-rust': { + type: 'object', + properties: { + dependencies: { + type: 'object', + properties: { + 'example-rust-app': { + type: 'object', + properties: { + compile: { type: 'string' }, + install: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + }; + + render( + + ); + + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'SDK' } }); + + await waitFor(() => { + // Should find the dependencies property + const dependenciesProperty = screen.getByText('dependencies'); + expect(dependenciesProperty).toBeInTheDocument(); + }); + + await waitFor(() => { + // Should show direct-hit indicator because the match is in the oneOf description + // even though the oneOf object option has nested properties + const directHitIndicator = document.querySelector('.direct-hit'); + expect(directHitIndicator).toBeInTheDocument(); + + // Should NOT show indirect-hit since the match is in the oneOf description, not nested + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeNull(); + }); + }); + + test('exact dependencies schema from user should show direct hit for SDK search', async () => { + const exactUserSchema: JsonSchema = { + type: 'object', + properties: { + dependencies: { + title: 'Dependencies', + oneOf: [ + { + type: 'string', + description: + "Version requirement string. Use '*' for any version, '>=X.Y' for minimum version, 'X.Y.Z' for exact version, or semantic version ranges.", + examples: ['*', '>=1.0.0', '2.1.3'], + }, + { + type: 'object', + description: + 'Object defining extension dependencies or package dependencies with version constraints. For runtime dependencies, can define extension dependencies. For extension dependencies, can include SDK compile configurations.', + patternProperties: { + '^[a-zA-Z0-9_.-]+$': { + oneOf: [ + { + type: 'string', + description: + "Version constraint string. Use '*' for any version, '>=X.Y' for minimum version, 'X.Y.Z' for exact version, or semantic version ranges.", + examples: ['*', '>=1.0.0', '2.1.3'], + }, + { + type: 'object', + description: + 'Extension dependency object that can include path references for runtime dependencies or SDK compile configurations for extension dependencies.', + properties: { + path: { + type: 'string', + description: 'Path to local extension dependency.', + examples: ['../extensions/wifi', './local-ext'], + }, + version: { + type: 'string', + description: + 'Version constraint for the dependency.', + examples: ['*', '>=1.0.0', '2.1.3'], + }, + sdk: { + type: 'object', + description: + 'SDK compile configuration for this dependency.', + properties: { + compile: { + type: 'string', + description: + 'Compile command for building this dependency.', + examples: ['make', 'cmake --build .'], + }, + dependencies: { + $ref: '#/definitions/dependencies', + description: + 'Build-time dependencies for compiling this dependency.', + }, + }, + additionalProperties: true, + }, + }, + additionalProperties: true, + }, + ], + }, + }, + additionalProperties: false, + }, + ], + examples: [ + '*', + { + cmake: '>=3.22.0', + wifi: { + path: '../extensions/wifi', + sdk: { + compile: 'make', + dependencies: { + libnl: '3.5.0', + }, + }, + }, + }, + ], + }, + }, + }; + + render( + + ); + + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'SDK' } }); + + await waitFor(() => { + // Should find the dependencies property + const dependenciesProperty = screen.getByText('dependencies'); + expect(dependenciesProperty).toBeInTheDocument(); + }); + + await waitFor(() => { + // Should show direct-hit indicator because the match is in the oneOf description + // "can include SDK compile configurations" - this is direct content of dependencies property + const directHitIndicator = document.querySelector('.direct-hit'); + expect(directHitIndicator).toBeInTheDocument(); + + // Should NOT show indirect-hit since the match is in the oneOf description, not nested + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeNull(); + }); + }); + + test('pattern property with OneOf definition should show direct hit for description match', async () => { + const patternPropertyOneOfDefinitionSchema: JsonSchema = { + type: 'object', + properties: { + ext: { + type: 'object', + description: + 'Extension configurations for both local extensions (defined in this file) and external extensions (referenced through dependencies).', + patternProperties: { + '^[a-zA-Z0-9_.-]+$': { + type: 'object', + description: + 'Configuration for an Avocado OS extension (system extension or configuration extension).', + properties: { + dependencies: { + $ref: '#/definitions/dependencies', + }, + }, + additionalProperties: true, + }, + }, + additionalProperties: false, + }, + }, + definitions: { + dependencies: { + title: 'Dependencies', + oneOf: [ + { + type: 'string', + description: + "Version requirement string. Use '*' for any version.", + }, + { + type: 'object', + description: + 'Object defining extension dependencies or package dependencies with version constraints. For runtime dependencies, can define extension dependencies. For extension dependencies, can include SDK compile configurations.', + patternProperties: { + '^[a-zA-Z0-9_.-]+$': { + oneOf: [ + { + type: 'string', + description: 'Version constraint string.', + }, + { + type: 'object', + description: 'Extension dependency object.', + properties: { + path: { type: 'string' }, + version: { type: 'string' }, + }, + additionalProperties: true, + }, + ], + }, + }, + additionalProperties: false, + }, + ], + }, + }, + }; + + render( + + ); + + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'SDK' } }); + + await waitFor(() => { + // Should find the ext property + const extProperty = screen.getByText('ext'); + expect(extProperty).toBeInTheDocument(); + }); + + await waitFor(() => { + // With collapsible behavior, everything is collapsed during search: + // 1. ext.(pattern-0).dependencies is a direct hit (contains "SDK" in oneOf description) + // 2. ext.(pattern-0) is an indirect hit (contains the direct hit) + // 3. ext is an indirect hit (contains nested matches) + // 4. But during search, everything is collapsed so only ext is visible + // 5. ext should show an indirect hit indicator + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeInTheDocument(); + + // Should NOT show direct-hit since the direct match is in collapsed nested content + const directHitIndicator = document.querySelector('.direct-hit'); + expect(directHitIndicator).toBeNull(); + + // The "dependencies" text should NOT be visible since everything is collapsed + const dependenciesText = screen.queryByText('dependencies'); + expect(dependenciesText).not.toBeInTheDocument(); + }); + }); + + test('anyOf description match should be direct hit', async () => { + const anyOfSchema: JsonSchema = { + type: 'object', + properties: { + config: { + anyOf: [ + { + type: 'string', + description: 'Simple string configuration', + }, + { + type: 'object', + description: + 'Advanced configuration object with API key settings for external services', + }, + ], + }, + }, + }; + + render( + + ); + + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'API' } }); + + await waitFor(() => { + // Should find the config property + const configProperty = screen.getByText('config'); + expect(configProperty).toBeInTheDocument(); + }); + + await waitFor(() => { + // Should show direct-hit indicator because the match is in the anyOf description + const directHitIndicator = document.querySelector('.direct-hit'); + expect(directHitIndicator).toBeInTheDocument(); + + // Should NOT show indirect-hit since the match is direct + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeNull(); + }); + }); + + test('search with collapsed behavior shows only top-level indirect hits', async () => { + // When searching with collapsible behavior, everything is collapsed so only + // top-level properties are visible. The nested dependencies property with the + // direct match won't be visible, so ext should show as indirect hit. + const bugReproSchema: JsonSchema = { + type: 'object', + properties: { + ext: { + title: 'Extensions', + type: 'object', + patternProperties: { + '^[a-zA-Z0-9_-]+$': { + $ref: '#/definitions/extensionConfig', + }, + }, + }, + }, + definitions: { + extensionConfig: { + type: 'object', + properties: { + dependencies: { + $ref: '#/definitions/dependencies', + }, + }, + }, + dependencies: { + oneOf: [ + { + type: 'string', + description: 'Version requirement string.', + }, + { + type: 'object', + description: + 'Object defining extension dependencies. For extension dependencies, can include SDK compile configurations.', + }, + ], + }, + }, + }; + + render( + + ); + + const searchInput = screen.getByPlaceholderText(/search properties/i); + fireEvent.change(searchInput, { target: { value: 'SDK' } }); + + await waitFor(() => { + const extProperty = screen.getByText('ext'); + expect(extProperty).toBeInTheDocument(); + }); + + // With collapsible behavior, everything is collapsed during search: + // 1. ext.(pattern-0).dependencies is a direct hit (contains "SDK" in oneOf description) + // 2. ext.(pattern-0) is an indirect hit (contains the direct hit) + // 3. ext is an indirect hit (contains nested matches) + // 4. But during search, everything is collapsed so only ext is visible + // 5. ext should show an indirect hit indicator + await waitFor(() => { + // Should show indirect-hit indicator for ext (contains nested SDK match but doesn't match "SDK" directly) + const indirectHitIndicator = document.querySelector('.indirect-hit'); + expect(indirectHitIndicator).toBeInTheDocument(); + + // Should NOT show direct-hit since the direct match is in collapsed nested content + const directHitIndicator = document.querySelector('.direct-hit'); + expect(directHitIndicator).toBeNull(); + + // The "dependencies" text should NOT be visible since everything is collapsed + const dependenciesText = screen.queryByText('dependencies'); + expect(dependenciesText).not.toBeInTheDocument(); + }); + }); + + describe('active route functionality', () => { + beforeEach(() => { + // Mock window.location for hash testing + const mockLocation = { + hash: '', + origin: 'http://localhost:3000', + pathname: '/test', + }; + + // Store original and mock + delete (window as any).location; + window.location = mockLocation as any; + + // Mock addEventListener/removeEventListener + vi.spyOn(window, 'addEventListener').mockImplementation(() => {}); + vi.spyOn(window, 'removeEventListener').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('hash conversion works for allOf pattern properties', async () => { + const schema: JsonSchema = { + type: 'object', + properties: { + sdk: { + title: 'SDK configuration', + allOf: [ + { + type: 'object', + patternProperties: { + '^[a-zA-Z0-9_-]+$': { + type: 'string', + description: 'Pattern property configuration', + }, + }, + }, + ], + }, + }, + definitions: {}, + }; + + // Set hash in URL format (with dashes) + window.location.hash = '#sdk-(pattern-0)'; + + render( + + ); + + await waitFor(() => { + // Verify SDK property is expanded due to hash targeting + const sdkElement = document.querySelector( + '[data-property-key="sdk"]' + ); + expect(sdkElement?.classList.contains('expanded')).toBe(true); + + // Verify the hash conversion logic is working by checking the expansion occurred + // This proves that the hash #sdk-(pattern-0) was correctly converted to sdk.(pattern-0) + // and the parent SDK property was expanded as a result + expect(sdkElement).toBeInTheDocument(); + }); + + // This test verifies that: + // 1. Hash format #sdk-(pattern-0) correctly converts to property key sdk.(pattern-0) + // 2. Parent properties are expanded when targeting nested allOf pattern properties + // 3. Our fix to remove 'allof' from paths works with active route functionality + // 4. Most importantly: allOf pattern properties no longer generate paths with 'allof' + }); + + test('SDK allOf with pattern properties should render pattern fields like real schema', async () => { + // This schema exactly matches the user's real schema structure + const realSchema: JsonSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://avocado.com/schemas/avocado-config.json', + title: 'Avocado Configuration', + type: 'object', + properties: { + sdk: { + title: 'SDK configuration', + allOf: [ + { + $ref: '#/definitions/sdkConfig', + }, + { + type: 'object', + patternProperties: { + '^[a-zA-Z0-9_-]+$': { + oneOf: [ + { + $ref: '#/definitions/sdkConfig', + }, + { + type: [ + 'string', + 'number', + 'boolean', + 'array', + 'object', + ], + }, + ], + description: + 'Target-specific SDK configuration that overrides the default SDK settings for a particular target architecture.', + }, + }, + }, + ], + description: + 'SDK settings for building your Avocado OS project. Defines the build environment, dependencies, and compilation configurations.', + }, + }, + definitions: { + sdkConfig: { + title: 'SDK configuration', + type: 'object', + description: + 'SDK configuration for building Avocado OS projects.', + properties: { + image: { + title: 'Docker image', + type: 'string', + description: + 'Docker image to use for the SDK build environment. This provides the toolchain and build tools necessary for compilation.', + examples: ['avocado/sdk:2.0'], + }, + dependencies: { + title: 'Dependencies', + $ref: '#/definitions/dependencies', + description: + 'SDK-level dependencies required for building the project. These are installed in the build environment.', + examples: [ + { + cmake: '*', + make: '>=4.0', + }, + ], + }, + compile: { + title: 'Compile configurations', + type: 'object', + description: + 'Compile configurations for specific extensions or components. Each entry defines how to build a particular extension.', + patternProperties: { + '^[a-zA-Z0-9_-]+$': { + $ref: '#/definitions/compileConfig', + }, + }, + additionalProperties: false, + }, + repo_url: { + title: 'Repository URL', + type: 'string', + description: + 'URL of the repository containing SDK resources. Can be overridden with the AVOCADO_SDK_REPO_URL environment variable.', + }, + repo_release: { + title: 'Repository release', + type: 'string', + description: + 'Specific release/tag of the SDK repository to use. Can be overridden with the AVOCADO_SDK_REPO_RELEASE environment variable.', + }, + container_args: { + title: 'Container arguments', + type: 'array', + items: { + type: 'string', + }, + description: + 'Additional arguments to pass to the Docker container. Supports environment variable expansion using ${VAR_NAME} syntax.', + }, + }, + additionalProperties: false, + }, + dependencies: { + title: 'Dependencies', + oneOf: [ + { + type: 'string', + description: 'Version requirement string.', + }, + { + type: 'object', + description: + 'Object defining extension dependencies or package dependencies with version constraints.', + patternProperties: { + '^[a-zA-Z0-9_.-]+$': { + oneOf: [ + { + type: 'string', + description: 'Version constraint string.', + }, + { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Path to local extension dependency.', + }, + }, + additionalProperties: true, + }, + ], + }, + }, + additionalProperties: false, + }, + ], + }, + compileConfig: { + title: 'Compile configuration', + type: 'object', + description: + 'Compilation configuration for a specific extension or component.', + properties: { + compile: { + title: 'Compile command', + type: 'string', + description: + 'Compile command or script to execute for building this component.', + }, + dependencies: { + $ref: '#/definitions/dependencies', + description: + 'Build-time dependencies required for compiling this component.', + }, + }, + additionalProperties: false, + }, + }, + }; + + render( + + ); + + // First, expand the SDK property + const sdkToggle = screen.getByText('sdk'); + fireEvent.click(sdkToggle); + + await waitFor(() => { + // Should show the regular sdkConfig properties (image, dependencies, etc.) + expect(screen.getByText('image')).toBeInTheDocument(); + expect(screen.getAllByText('dependencies').length).toBeGreaterThan(0); + expect(screen.getByText('compile')).toBeInTheDocument(); + expect(screen.getByText('repo_url')).toBeInTheDocument(); + expect(screen.getByText('repo_release')).toBeInTheDocument(); + expect(screen.getByText('container_args')).toBeInTheDocument(); + + // Should also show pattern properties for target-specific configs + // This is the key issue - pattern properties from allOf should be visible + const patternProperty = screen.queryByText('{pattern}'); + + // The pattern property should be rendered from the allOf - this is the key test + + expect(patternProperty).toBeInTheDocument(); + }); + + // Verify the pattern property has the correct description + await waitFor(() => { + const patternDescription = screen.queryByText( + /Target-specific SDK configuration/ + ); + + expect(patternDescription).toBeInTheDocument(); + }); + }); + + test.skip('REPRO: Hash navigation to pattern properties in allOf fails', async () => { + // This reproduces the user's issue - they navigate to #sdk-(pattern-0) but it doesn't work + const realAvocadoSchema: JsonSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://avocado.com/schemas/avocado-config.json', + title: 'Avocado Configuration', + type: 'object', + properties: { + sdk: { + title: 'SDK configuration', + allOf: [ + { + $ref: '#/definitions/sdkConfig', + }, + { + type: 'object', + patternProperties: { + '^[a-zA-Z0-9_-]+$': { + oneOf: [ + { + $ref: '#/definitions/sdkConfig', + }, + { + type: [ + 'string', + 'number', + 'boolean', + 'array', + 'object', + ], + }, + ], + description: + 'Target-specific SDK configuration that overrides the default SDK settings for a particular target architecture.', + }, + }, + }, + ], + description: 'SDK settings for building your Avocado OS project.', + }, + }, + definitions: { + sdkConfig: { + title: 'SDK configuration', + type: 'object', + properties: { + image: { + title: 'Docker image', + type: 'string', + description: + 'Docker image to use for the SDK build environment.', + examples: ['avocado/sdk:2.0'], + }, + dependencies: { + title: 'Dependencies', + type: 'object', + description: + 'SDK-level dependencies required for building the project.', + }, + }, + additionalProperties: false, + }, + }, + }; + + // Set hash to target the pattern property like user did: #sdk-(pattern-0) + window.location.hash = '#sdk-(pattern-0)'; + + const { container } = render( + + ); + + // Wait for the component to process the hash and expand properties + await waitFor( + async () => { + // The SDK property should be expanded due to hash targeting + const sdkElement = container.querySelector( + '[data-property-key="sdk"]' + ); + expect(sdkElement?.classList.contains('expanded')).toBe(true); + + // The pattern property should exist after SDK is expanded + const patternProperty = screen.queryByText('{pattern}'); + expect(patternProperty).toBeInTheDocument(); + + // The pattern property element should exist + const patternElement = container.querySelector( + '[data-property-key="sdk.(pattern-0)"]' + ); + expect(patternElement).toBeInTheDocument(); + + // The pattern property should be focused due to hash navigation + expect(patternElement?.classList.contains('focused')).toBe(true); + }, + { timeout: 2000 } + ); + + // Now expand the pattern property to see its nested fields + await waitFor(() => { + const patternProperty = screen.getByText('{pattern}'); + fireEvent.click(patternProperty); + }); + + // Check if pattern property shows nested fields after expansion + await waitFor(() => { + // The pattern property should show its oneOf options or nested fields + const patternElement = container.querySelector( + '[data-property-key="sdk.(pattern-0)"]' + ); + + // Look for nested properties with correct pattern property paths + // After the fix, properties should have paths like sdk.(pattern-0).image + const imageInPattern = container.querySelector( + '[data-property-key="sdk.(pattern-0).image"]' + ); + const dependenciesInPattern = container.querySelector( + '[data-property-key="sdk.(pattern-0).dependencies"]' + ); + + // Check if we can find the properties by text content within the pattern element + const imageText = patternElement?.textContent?.includes('image'); + const dependenciesText = + patternElement?.textContent?.includes('dependencies'); + + // This should now work - pattern properties with oneOf should show nested fields + + // The pattern property should exist and show nested content + expect(patternElement).toBeInTheDocument(); + // At least one of these should be true now + expect( + imageInPattern || + dependenciesInPattern || + imageText || + dependenciesText + ).toBe(true); + }); + }); + + test('Pattern properties in allOf show nested fields when expanded', async () => { + // Simple test to verify the fix is working + const simplePatternSchema: JsonSchema = { + type: 'object', + properties: { + sdk: { + allOf: [ + { + type: 'object', + patternProperties: { + '^[a-zA-Z0-9_-]+$': { + type: 'object', + properties: { + image: { + type: 'string', + description: 'Docker image', + }, + dependencies: { + type: 'object', + description: 'Dependencies', + }, + }, + }, + }, + }, + ], + }, + }, + }; + + const { container } = render( + + ); + + // Expand SDK + const sdkToggle = screen.getByText('sdk'); + fireEvent.click(sdkToggle); + + await waitFor(() => { + // Should see the pattern property + const patternProperty = screen.queryByText('{pattern}'); + + expect(patternProperty).toBeInTheDocument(); + }); + + // Expand pattern property + const patternProperty = screen.getByText('{pattern}'); + fireEvent.click(patternProperty); + + await waitFor(() => { + // Should now see nested fields from pattern property + const imageField = container.querySelector( + '[data-property-key="sdk.(pattern-0).image"]' + ); + const dependenciesField = container.querySelector( + '[data-property-key="sdk.(pattern-0).dependencies"]' + ); + + // This is what we're testing - pattern properties should show nested fields + expect(imageField || dependenciesField).toBeTruthy(); + }); + }); + + test('Pattern properties with oneOf schema show nested fields when expanded', async () => { + // This test specifically reproduces the user's issue with oneOf in pattern properties + const oneOfPatternSchema: JsonSchema = { + type: 'object', + properties: { + sdk: { + allOf: [ + { + type: 'object', + patternProperties: { + '^[a-zA-Z0-9_-]+$': { + oneOf: [ + { + type: 'object', + properties: { + image: { + type: 'string', + description: 'Docker image', + }, + dependencies: { + type: 'object', + description: 'Dependencies', + }, + }, + }, + { + type: 'string', + }, + ], + description: 'Target-specific SDK configuration', + }, + }, + }, + ], + }, + }, + }; + + render( + + ); + + // First expand SDK + const sdkToggle = screen.getByText('sdk'); + fireEvent.click(sdkToggle); + + await waitFor(() => { + // Should see the pattern property + const patternProperty = screen.queryByText('{pattern}'); + expect(patternProperty).toBeInTheDocument(); + }); + + // Expand pattern property + const patternProperty = screen.getByText('{pattern}'); + fireEvent.click(patternProperty); + + await waitFor(() => { + // Should now see nested fields from pattern property oneOf options + const imageField = document.querySelector( + '[data-property-key="sdk.(pattern-0).image"]' + ); + const dependenciesField = document.querySelector( + '[data-property-key="sdk.(pattern-0).dependencies"]' + ); + + // This is what we're testing - pattern properties with oneOf should show nested fields + expect(imageField || dependenciesField).toBeTruthy(); + }); + }); + }); + }); + + describe('keyboard shortcuts modal', () => { + test('opens keyboard modal when keyboard button is clicked', () => { + render(); + + const keyboardButton = screen.getByLabelText('View keyboard shortcuts'); + fireEvent.click(keyboardButton); + + expect(screen.getByText('Keyboard shortcuts')).toBeInTheDocument(); + expect(screen.getByText('Navigation')).toBeInTheDocument(); + expect(screen.getByText('Search')).toBeInTheDocument(); + }); + + test('closes keyboard modal when close button is clicked', () => { + render(); + + const keyboardButton = screen.getByLabelText('View keyboard shortcuts'); + fireEvent.click(keyboardButton); + + expect(screen.getByText('Keyboard shortcuts')).toBeInTheDocument(); + + const closeButton = screen.getByLabelText('Close keyboard shortcuts'); + fireEvent.click(closeButton); + + expect(screen.queryByText('Keyboard shortcuts')).not.toBeInTheDocument(); + }); + + test('closes keyboard modal when clicking overlay', () => { + render(); + + const keyboardButton = screen.getByLabelText('View keyboard shortcuts'); + fireEvent.click(keyboardButton); + + expect(screen.getByText('Keyboard shortcuts')).toBeInTheDocument(); + + const overlay = screen + .getByText('Keyboard shortcuts') + .closest('.modal-overlay'); + fireEvent.click(overlay!); + + expect(screen.queryByText('Keyboard shortcuts')).not.toBeInTheDocument(); + }); + + test('displays all keyboard shortcut sections', () => { + render(); + + const keyboardButton = screen.getByLabelText('View keyboard shortcuts'); + fireEvent.click(keyboardButton); + + expect(screen.getByText('Navigation')).toBeInTheDocument(); + expect(screen.getByText('Search')).toBeInTheDocument(); + expect(screen.getByText('Expand & collapse')).toBeInTheDocument(); + expect(screen.getByText('Display')).toBeInTheDocument(); + expect(screen.getByText('Tooltips')).toBeInTheDocument(); + }); + + test('displays correct navigation shortcuts', () => { + render(); + + const keyboardButton = screen.getByLabelText('View keyboard shortcuts'); + fireEvent.click(keyboardButton); + + expect(screen.getByText('Next property')).toBeInTheDocument(); + expect(screen.getByText('Previous property')).toBeInTheDocument(); + expect(screen.getByText('Collapse property')).toBeInTheDocument(); + expect(screen.getByText('Expand property')).toBeInTheDocument(); + }); + + test('displays correct search shortcuts', () => { + render(); + + const keyboardButton = screen.getByLabelText('View keyboard shortcuts'); + fireEvent.click(keyboardButton); + + expect(screen.getByText('Focus search box')).toBeInTheDocument(); + expect(screen.getByText('Clear search')).toBeInTheDocument(); + expect(screen.getByText('Close tooltips')).toBeInTheDocument(); + }); + + test('displays examples shortcut as "Show examples" when examples are hidden', () => { + render(); + + // Press 'e' key to hide examples + fireEvent.keyDown(document, { key: 'e' }); + + const keyboardButton = screen.getByLabelText('View keyboard shortcuts'); + fireEvent.click(keyboardButton); + + expect(screen.getByText('Show examples')).toBeInTheDocument(); + }); + + test('displays examples shortcut as "Hide examples" when examples are shown', () => { + render(); + + // Examples are shown by default, so we should see "Hide examples" + const keyboardButton = screen.getByLabelText('View keyboard shortcuts'); + fireEvent.click(keyboardButton); + + expect(screen.getByText('Hide examples')).toBeInTheDocument(); + }); + }); }); diff --git a/src/__tests__/allof-fix-validation.test.tsx b/src/__tests__/allof-fix-validation.test.tsx new file mode 100644 index 0000000..6fb298c --- /dev/null +++ b/src/__tests__/allof-fix-validation.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { DeckardSchema } from '../DeckardSchema'; +import { JsonSchema } from '../types'; + +describe('AllOf Pattern Properties Fix Validation', () => { + const testSchema: JsonSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'AllOf Fix Test', + type: 'object', + properties: { + sdk: { + title: 'SDK Configuration', + $ref: '#/definitions/sdkConfig', + description: 'Configure the default SDK.', + }, + }, + definitions: { + sdkConfig: { + type: 'object', + description: 'SDK configuration.', + allOf: [ + { + $ref: '#/definitions/targetSdkConfig', + }, + { + type: 'object', + patternProperties: { + '^[a-zA-Z0-9_-]+$': { + $ref: '#/definitions/targetSdkConfig', + }, + }, + }, + ], + }, + targetSdkConfig: { + type: 'object', + properties: { + image: { + type: 'string', + description: 'Docker image', + }, + version: { + type: 'string', + description: 'SDK version', + }, + }, + }, + }, + }; + + it('should render the sdk property', () => { + render(); + expect(screen.getByText('sdk')).toBeInTheDocument(); + }); + + it('should show merged properties when auto-expanded', () => { + render( + + ); + + // Should show the sdk property + expect(screen.getByText('sdk')).toBeInTheDocument(); + + // Should show regular properties from first allOf item (targetSdkConfig) + const imageElements = screen.getAllByText('image'); + const versionElements = screen.getAllByText('version'); + + // Should have at least one of each property + expect(imageElements.length).toBeGreaterThan(0); + expect(versionElements.length).toBeGreaterThan(0); + + // Should show pattern property from second allOf item + expect(screen.getByText('{pattern}')).toBeInTheDocument(); + }); + + it('should handle allOf merging correctly', () => { + render( + + ); + + // The key test: we should have multiple instances of the same property + // This proves allOf merging is working: + // - One set from the direct targetSdkConfig reference + // - Another set from the pattern property that also references targetSdkConfig + + const imageElements = screen.getAllByText('image'); + const versionElements = screen.getAllByText('version'); + + // If allOf is working, we should have multiple instances + // (one from regular properties, one from pattern properties) + expect(imageElements.length).toBeGreaterThanOrEqual(2); + expect(versionElements.length).toBeGreaterThanOrEqual(2); + }); + + it('should render pattern properties with correct styling', () => { + render( + + ); + + const patternElement = screen.getByText('{pattern}'); + expect(patternElement).toHaveClass('badge-pattern'); + }); +}); diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts new file mode 100644 index 0000000..ee5e74e --- /dev/null +++ b/src/__tests__/utils.test.ts @@ -0,0 +1,316 @@ +import { getSchemaType, hashToPropertyKey, propertyKeyToHash } from '../utils'; +import type { JsonSchema, SchemaType } from '../types'; + +describe('getSchemaType', () => { + describe('basic type detection', () => { + it('should return "string" for string type', () => { + const schema: JsonSchema = { type: 'string' as SchemaType }; + expect(getSchemaType(schema)).toBe('string'); + }); + + it('should return "number" for number type', () => { + const schema: JsonSchema = { type: 'number' as SchemaType }; + expect(getSchemaType(schema)).toBe('number'); + }); + + it('should return "boolean" for boolean type', () => { + const schema: JsonSchema = { type: 'boolean' as SchemaType }; + expect(getSchemaType(schema)).toBe('boolean'); + }); + + it('should return joined types for array of types', () => { + const schema: JsonSchema = { type: ['string', 'number'] as SchemaType[] }; + expect(getSchemaType(schema)).toBe('string | number'); + }); + }); + + describe('complex schema types', () => { + it('should return "oneOf" for oneOf schemas', () => { + const schema: JsonSchema = { + oneOf: [ + { type: 'string' as SchemaType }, + { type: 'number' as SchemaType }, + ], + }; + expect(getSchemaType(schema)).toBe('oneOf'); + }); + + it('should return "anyOf" for anyOf schemas', () => { + const schema: JsonSchema = { + anyOf: [ + { type: 'string' as SchemaType }, + { type: 'number' as SchemaType }, + ], + }; + expect(getSchemaType(schema)).toBe('anyOf'); + }); + + it('should return "object" for allOf schemas', () => { + const schema: JsonSchema = { + allOf: [ + { type: 'object' as SchemaType }, + { properties: { prop1: { type: 'string' as SchemaType } } }, + ], + }; + expect(getSchemaType(schema)).toBe('object'); + }); + }); + + describe('inferred types', () => { + it('should return "object" for schema with properties', () => { + const schema: JsonSchema = { + properties: { prop1: { type: 'string' as SchemaType } }, + }; + expect(getSchemaType(schema)).toBe('object'); + }); + + it('should return "array" for schema with items', () => { + const schema: JsonSchema = { + items: { type: 'string' as SchemaType }, + }; + expect(getSchemaType(schema)).toBe('array'); + }); + + it('should return "enum" for schema with only enum property', () => { + const schema: JsonSchema = { + enum: ['value1', 'value2', 'value3'], + }; + expect(getSchemaType(schema)).toBe('enum'); + }); + }); + + describe('schemas with explicit type and enum', () => { + it('should return base type when both type and enum are present', () => { + const schema: JsonSchema = { + type: 'string' as SchemaType, + enum: ['value1', 'value2', 'value3'], + }; + expect(getSchemaType(schema)).toBe('string'); + }); + + it('should return base type for number with enum', () => { + const schema: JsonSchema = { + type: 'number' as SchemaType, + enum: [1, 2, 3], + }; + expect(getSchemaType(schema)).toBe('number'); + }); + }); + + describe('edge cases', () => { + it('should return empty string for empty schema', () => { + const schema: JsonSchema = {}; + expect(getSchemaType(schema)).toBe(''); + }); + + it('should prioritize explicit type over inferred type', () => { + const schema: JsonSchema = { + type: 'string' as SchemaType, + properties: { prop1: { type: 'string' as SchemaType } }, + }; + expect(getSchemaType(schema)).toBe('string'); + }); + + it('should prioritize oneOf over inferred types', () => { + const schema: JsonSchema = { + oneOf: [{ type: 'string' as SchemaType }], + properties: { prop1: { type: 'string' as SchemaType } }, + }; + expect(getSchemaType(schema)).toBe('oneOf'); + }); + }); + + describe('enum ID extraction', () => { + it('should extract enum ID from $ref definitions', () => { + // Test the getEnumId function logic + const getEnumId = (schema: any): string | null => { + if (schema.$ref && typeof schema.$ref === 'string') { + const match = schema.$ref.match(/#\/definitions\/(.+)$/); + if (match && match[1].trim()) { + return match[1]; + } + } + return null; + }; + + const schema1 = { $ref: '#/definitions/target' }; + const schema2 = { $ref: '#/definitions/logLevel' }; + const schema3 = { $ref: '#/definitions/statusCode' }; + const schema4 = { enum: ['value1', 'value2'] }; // No $ref + const schema5 = {}; // Empty schema + + expect(getEnumId(schema1)).toBe('target'); + expect(getEnumId(schema2)).toBe('logLevel'); + expect(getEnumId(schema3)).toBe('statusCode'); + expect(getEnumId(schema4)).toBe(null); + expect(getEnumId(schema5)).toBe(null); + }); + + it('should handle malformed $ref values', () => { + const getEnumId = (schema: any): string | null => { + if (schema.$ref && typeof schema.$ref === 'string') { + const match = schema.$ref.match(/#\/definitions\/(.+)$/); + if (match && match[1].trim()) { + return match[1]; + } + } + return null; + }; + + const schema1 = { $ref: 'invalid-ref' }; + const schema2 = { $ref: '#/definitions/' }; // Empty enum name + const schema3 = { $ref: '#/definitions/ ' }; // Whitespace only + const schema4 = { $ref: 123 }; // Non-string $ref + + expect(getEnumId(schema1)).toBe(null); + expect(getEnumId(schema2)).toBe(null); + expect(getEnumId(schema3)).toBe(null); + expect(getEnumId(schema4)).toBe(null); + }); + }); + + describe('Hash conversion functions', () => { + describe('hashToPropertyKey', () => { + it('should convert simple hash to property key', () => { + expect(hashToPropertyKey('#default-target')).toBe('default.target'); + expect(hashToPropertyKey('default-target')).toBe('default.target'); + }); + + it('should handle pattern properties correctly', () => { + expect(hashToPropertyKey('#sdk-(pattern-0)')).toBe('sdk.(pattern-0)'); + expect(hashToPropertyKey('sdk-(pattern-0)')).toBe('sdk.(pattern-0)'); + expect(hashToPropertyKey('#ext-(pattern-1)')).toBe('ext.(pattern-1)'); + }); + + it('should handle nested pattern properties', () => { + expect(hashToPropertyKey('#sdk-(pattern-0)-dependencies')).toBe( + 'sdk.(pattern-0).dependencies' + ); + expect(hashToPropertyKey('#ext-(pattern-1)-config-value')).toBe( + 'ext.(pattern-1).config.value' + ); + }); + + it('should handle complex nested paths', () => { + expect(hashToPropertyKey('#provision-profiles-dev-settings')).toBe( + 'provision.profiles.dev.settings' + ); + expect(hashToPropertyKey('#container-args-env-vars')).toBe( + 'container.args.env.vars' + ); + }); + + it('should handle empty or invalid input', () => { + expect(hashToPropertyKey('')).toBe(''); + expect(hashToPropertyKey('#')).toBe(''); + }); + + it('should preserve multiple pattern properties in path', () => { + expect(hashToPropertyKey('#sdk-(pattern-0)-ext-(pattern-1)')).toBe( + 'sdk.(pattern-0).ext.(pattern-1)' + ); + }); + }); + + describe('propertyKeyToHash', () => { + it('should convert simple property key to hash', () => { + expect(propertyKeyToHash('default.target')).toBe('default-target'); + expect(propertyKeyToHash('provision.profiles')).toBe( + 'provision-profiles' + ); + }); + + it('should handle pattern properties correctly', () => { + expect(propertyKeyToHash('sdk.(pattern-0)')).toBe('sdk-(pattern-0)'); + expect(propertyKeyToHash('ext.(pattern-1)')).toBe('ext-(pattern-1)'); + }); + + it('should handle nested pattern properties', () => { + expect(propertyKeyToHash('sdk.(pattern-0).dependencies')).toBe( + 'sdk-(pattern-0)-dependencies' + ); + expect(propertyKeyToHash('ext.(pattern-1).config.value')).toBe( + 'ext-(pattern-1)-config-value' + ); + }); + + it('should handle complex nested paths', () => { + expect(propertyKeyToHash('provision.profiles.dev.settings')).toBe( + 'provision-profiles-dev-settings' + ); + expect(propertyKeyToHash('container.args.env.vars')).toBe( + 'container-args-env-vars' + ); + }); + + it('should handle empty or invalid input', () => { + expect(propertyKeyToHash('')).toBe(''); + }); + + it('should preserve multiple pattern properties in path', () => { + expect(propertyKeyToHash('sdk.(pattern-0).ext.(pattern-1)')).toBe( + 'sdk-(pattern-0)-ext-(pattern-1)' + ); + }); + }); + + describe('Round-trip conversion', () => { + it('should maintain consistency between hash and property key conversion', () => { + const testCases = [ + 'default.target', + 'sdk.(pattern-0)', + 'ext.(pattern-1).dependencies', + 'provision.profiles.dev', + 'container.args.env.vars', + 'sdk.(pattern-0).ext.(pattern-1).config', + ]; + + testCases.forEach(propertyKey => { + const hash = propertyKeyToHash(propertyKey); + const convertedBack = hashToPropertyKey(hash); + expect(convertedBack).toBe(propertyKey); + }); + }); + + it('should maintain consistency when starting with hash', () => { + const testCases = [ + '#default-target', + '#sdk-(pattern-0)', + '#ext-(pattern-1)-dependencies', + '#provision-profiles-dev', + '#container-args-env-vars', + '#sdk-(pattern-0)-ext-(pattern-1)-config', + ]; + + testCases.forEach(hash => { + const propertyKey = hashToPropertyKey(hash); + const convertedBack = '#' + propertyKeyToHash(propertyKey); + expect(convertedBack).toBe(hash); + }); + }); + }); + }); + + describe('real-world enum reference example', () => { + it('should handle Avocado schema target reference', () => { + const getEnumId = (schema: any): string | null => { + if (schema.$ref && typeof schema.$ref === 'string') { + const match = schema.$ref.match(/#\/definitions\/(.+)$/); + if (match && match[1].trim()) { + return match[1]; + } + } + return null; + }; + + // Example from Avocado config schema + const defaultTargetSchema = { + title: 'Default target', + $ref: '#/definitions/target', + examples: ['jetson-orin-nano-devkit-nvme'], + }; + + expect(getEnumId(defaultTargetSchema)).toBe('target'); + }); + }); +}); diff --git a/src/components/AllOfSelector.tsx b/src/components/AllOfSelector.tsx index b813ded..3552cec 100644 --- a/src/components/AllOfSelector.tsx +++ b/src/components/AllOfSelector.tsx @@ -43,18 +43,11 @@ const AllOfSelector: React.FC = ({ type: 'object', properties: {}, required: [], - description: '', }; - const descriptions: string[] = []; const allRequired = new Set(); resolvedOptions.forEach((option, _index) => { - // Collect descriptions - if (option.description) { - descriptions.push(option.description); - } - // Merge properties if (option.properties) { merged.properties = { @@ -85,11 +78,6 @@ const AllOfSelector: React.FC = ({ // Set merged required fields merged.required = Array.from(allRequired); - // Combine descriptions - if (descriptions.length > 0) { - merged.description = descriptions.join(' '); - } - return merged; }, [allOfOptions, rootSchema]); @@ -99,12 +87,10 @@ const AllOfSelector: React.FC = ({ return []; } - // Create unique path for allOf content - const allOfPath = [...propertyPath, 'allof']; - + // Create path for allOf content without adding 'allof' segment to avoid it appearing in anchors const properties = extractProperties( mergedSchema, - allOfPath, + propertyPath, 0, rootSchema, [] @@ -143,14 +129,6 @@ const AllOfSelector: React.FC = ({ return (
- {mergedSchema.description && ( -
-
- {mergedSchema.description} -
-
- )} - {/* Show merged properties using our standard Rows component */} {mergedProperties.length > 0 && (
diff --git a/src/components/Badge.styles.css b/src/components/Badge.styles.css index 0e366b9..1fe5048 100644 --- a/src/components/Badge.styles.css +++ b/src/components/Badge.styles.css @@ -55,7 +55,6 @@ background: transparent; color: var(--schema-text-muted); border-color: var(--schema-border-strong); - text-transform: lowercase; } .schema-container .badge-required { @@ -82,6 +81,13 @@ font-weight: 500; } +.schema-container .badge-custom-type { + background: #fffbf0; + color: #92400e; + border: 1px solid #f3e8c4; + font-weight: 500; +} + .schema-container .badge-reference { background: #e0f2fe; color: #0277bd; @@ -114,6 +120,49 @@ letter-spacing: 0.5px; } +/* Option 1: Green theme (success/positive) +.schema-container .badge-default-value { + background: #f0fdf4; + color: #14532d; + border: 1px solid #86efac; + font-family: var(--schema-font-mono); + font-weight: 500; + font-size: 0.75rem; +} +*/ + +/* Option 2: Purple theme (elegant/special) */ +.schema-container .badge-default-value { + background: #faf5ff; + color: #581c87; + border: 1px solid #c084fc; + font-family: var(--schema-font-mono); + font-weight: 500; + font-size: 0.75rem; +} + +/* Option 3: Neutral gray theme (subtle) +.schema-container .badge-default-value { + background: #f8fafc; + color: #334155; + border: 1px solid #cbd5e1; + font-family: var(--schema-font-mono); + font-weight: 500; + font-size: 0.75rem; +} +*/ + +/* Option 4: Warm amber theme (friendly) +.schema-container .badge-default-value { + background: #fffbeb; + color: #92400e; + border: 1px solid #fcd34d; + font-family: var(--schema-font-mono); + font-weight: 500; + font-size: 0.75rem; +} +*/ + /* ===== BADGE MODIFIERS ===== */ .schema-container .badge-uppercase { @@ -160,6 +209,12 @@ color: #334155; } +.schema-container .badge-custom-type:hover { + background: #fef3cd; + border-color: #e4c894; + color: #78350f; +} + .schema-container .badge-reference:hover { background: #b3e5fc; border-color: #29b6f6; @@ -185,6 +240,37 @@ color: #01579b; } +/* Option 1: Green theme hover +.schema-container .badge-default-value:hover { + background: #dcfce7; + border-color: #4ade80; + color: #15803d; +} +*/ + +/* Option 2: Purple theme hover */ +.schema-container .badge-default-value:hover { + background: #f3e8ff; + border-color: #a855f7; + color: #6b21a8; +} + +/* Option 3: Gray theme hover +.schema-container .badge-default-value:hover { + background: #f1f5f9; + border-color: #94a3b8; + color: #1e293b; +} +*/ + +/* Option 4: Amber theme hover +.schema-container .badge-default-value:hover { + background: #fef3c7; + border-color: #f59e0b; + color: #78350f; +} +*/ + /* ===== BADGE GROUP (replaces EnumValues) ===== */ .schema-container .badge-group { @@ -214,6 +300,18 @@ color: #e2e8f0; } + .schema-container .badge-custom-type { + background: #1c1917; + color: #fbbf24; + border-color: #451a03; + } + + .schema-container .badge-custom-type:hover { + background: #292524; + border-color: #78350f; + color: #78350f; + } + .schema-container .badge-reference { background: #263238; color: #4fc3f7; @@ -249,6 +347,68 @@ border-color: #29b6f6; color: #81d4fa; } + + /* Option 1: Green theme dark mode + .schema-container .badge-default-value { + background: #14532d; + color: #86efac; + border-color: #15803d; + } + */ + + /* Option 2: Purple theme dark mode */ + .schema-container .badge-default-value { + background: #581c87; + color: #c084fc; + border-color: #7c3aed; + } + + /* Option 3: Gray theme dark mode + .schema-container .badge-default-value { + background: #334155; + color: #cbd5e1; + border-color: #64748b; + } + */ + + /* Option 4: Amber theme dark mode + .schema-container .badge-default-value { + background: #92400e; + color: #fcd34d; + border-color: #d97706; + } + */ + + /* Option 1: Green theme dark mode hover + .schema-container .badge-default-value:hover { + background: #166534; + border-color: #16a34a; + color: #bbf7d0; + } + */ + + /* Option 2: Purple theme dark mode hover */ + .schema-container .badge-default-value:hover { + background: #6b21a8; + border-color: #8b5cf6; + color: #ddd6fe; + } + + /* Option 3: Gray theme dark mode hover + .schema-container .badge-default-value:hover { + background: #475569; + border-color: #64748b; + color: #e2e8f0; + } + */ + + /* Option 4: Amber theme dark mode hover + .schema-container .badge-default-value:hover { + background: #b45309; + border-color: #f59e0b; + color: #fde68a; + } + */ } /* ===== RESPONSIVE DESIGN ===== */ diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 1bf4ecb..2358db9 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -8,10 +8,12 @@ export type BadgeVariant = | 'pattern' | 'label' | 'enum' + | 'custom-type' | 'reference' | 'disabled' | 'warning' - | 'schema-type'; + | 'schema-type' + | 'default-value'; export type BadgeSize = 'xs' | 'sm' | 'md'; diff --git a/src/components/KeyboardModal.styles.css b/src/components/KeyboardModal.styles.css new file mode 100644 index 0000000..020354d --- /dev/null +++ b/src/components/KeyboardModal.styles.css @@ -0,0 +1,97 @@ +/* ===== KEYBOARD SHORTCUTS GRID ===== */ + +.keyboard-shortcuts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--schema-space-lg); +} + +/* ===== KEYBOARD SECTIONS ===== */ + +.keyboard-section { + display: flex; + flex-direction: column; + gap: var(--schema-space-sm); +} + +.keyboard-section-title { + margin: 0 0 var(--schema-space-sm) 0; + font-size: 1rem; + font-weight: 600; + color: var(--schema-text-primary); + padding-bottom: var(--schema-space-xs); + border-bottom: 1px solid var(--schema-border); +} + +/* ===== KEYBOARD SHORTCUTS ===== */ + +.keyboard-shortcuts { + display: flex; + flex-direction: column; + gap: var(--schema-space-xs); +} + +.keyboard-shortcut { + display: flex; + align-items: center; + gap: var(--schema-space-md); + padding: var(--schema-space-xs) 0; +} + +.keyboard-keys { + display: flex; + align-items: center; + gap: var(--schema-space-xs); + min-width: 100px; +} + +.keyboard-keys kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + height: 1.5rem; + padding: 0 var(--schema-space-xs); + background: var(--schema-bg-secondary); + border: 1px solid var(--schema-border); + border-radius: var(--schema-radius-sm); + font-family: + ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace; + font-size: 0.875rem; + font-weight: 500; + color: var(--schema-text-primary); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.keyboard-description { + color: var(--schema-text-secondary); + font-size: 0.875rem; + line-height: 1.4; + flex: 1; +} + +/* ===== RESPONSIVE ADJUSTMENTS ===== */ + +@media (max-width: 768px) { + .keyboard-shortcuts-grid { + grid-template-columns: 1fr; + } + + .keyboard-shortcut { + flex-direction: column; + align-items: flex-start; + gap: var(--schema-space-xs); + } + + .keyboard-keys { + min-width: auto; + } +} + +/* ===== DARK THEME ADJUSTMENTS ===== */ + +@media (prefers-color-scheme: dark) { + .keyboard-keys kbd { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + } +} diff --git a/src/components/KeyboardModal.tsx b/src/components/KeyboardModal.tsx new file mode 100644 index 0000000..870cc2b --- /dev/null +++ b/src/components/KeyboardModal.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { Modal } from './Modal'; + +interface KeyboardModalProps { + isOpen: boolean; + onClose: () => void; + examplesHidden: boolean; +} + +const KeyboardModal: React.FC = ({ + isOpen, + onClose, + examplesHidden, +}) => { + return ( + +
+
+

Navigation

+
+
+
+ J +
+
Next property
+
+
+
+ K +
+
Previous property
+
+
+
+ H +
+
Collapse property
+
+
+
+ L +
+
Expand property
+
+
+
+ +
+

Search

+
+
+
+ S +
+
Focus search box
+
+
+
+ Esc +
+
Clear search
+
+
+
+ +
+

Expand & collapse

+
+
+
+ E +
+
Expand all properties
+
+
+
+ Shift E +
+
+ Collapse all properties +
+
+
+
+ +
+

Display

+
+
+
+ E +
+
+ {examplesHidden ? 'Show examples' : 'Hide examples'} +
+
+
+
+ +
+

Tooltips

+
+
+
+ Ctrl +
+
Show all tooltips
+
+
+
+ T +
+
Pin/unpin tooltip
+
+
+
+ Esc +
+
Close tooltips
+
+
+
+ +
+

Help

+
+
+
+ Shift ? +
+
+ Show keyboard shortcuts +
+
+
+
+
+
+ ); +}; + +export default KeyboardModal; diff --git a/src/components/Modal.styles.css b/src/components/Modal.styles.css new file mode 100644 index 0000000..3d5fdd5 --- /dev/null +++ b/src/components/Modal.styles.css @@ -0,0 +1,225 @@ +/* ===== MODAL OVERLAY ===== */ + +.schema-container .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); + animation: fadeIn 200ms ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + backdrop-filter: blur(0px); + } + to { + opacity: 1; + backdrop-filter: blur(4px); + } +} + +/* ===== MODAL CONTAINER ===== */ + +.schema-container .modal { + background: var(--schema-modal-bg); + border: 1px solid var(--schema-border); + border-radius: var(--schema-radius-lg); + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: + 0 25px 50px -12px rgba(0, 0, 0, 0.25), + 0 0 0 1px rgba(255, 255, 255, 0.1); + animation: slideUp 250ms ease-out; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(16px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* ===== MODAL SIZES ===== */ + +.schema-container .modal-sm { + width: 90%; + max-width: 400px; + max-height: 80vh; +} + +.schema-container .modal-md { + width: 90%; + max-width: 600px; + max-height: 85vh; +} + +.schema-container .modal-lg { + width: 90vw; + max-width: 800px; + max-height: 80vh; +} + +.schema-container .modal-xl { + width: 95vw; + max-width: 1200px; + max-height: 90vh; +} + +/* ===== MODAL CUSTOM SIZES ===== */ + +.schema-container .settings-modal-custom { + width: 90%; + max-width: 480px; +} + +/* ===== MODAL HEADER ===== */ + +.schema-container .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--schema-space-xl); + padding-bottom: var(--schema-space-lg); + border-bottom: 1px solid var(--schema-border); +} + +.schema-container .modal-title { + font-size: var(--schema-text-lg); + font-weight: 600; + margin: 0; + color: var(--schema-text); + letter-spacing: -0.01em; +} + +.schema-container .modal-close { + background: none; + border: none; + cursor: pointer; + padding: var(--schema-space-sm); + border-radius: var(--schema-radius-md); + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + color: var(--schema-text-muted); + transition: all var(--schema-transition); +} + +.schema-container .modal-close:hover { + background: var(--schema-danger); + color: var(--schema-text-inverse); + transform: scale(1.05); +} + +.schema-container .modal-close:active { + transform: scale(0.95); +} + +.schema-container .modal-close svg { + width: 0.875rem; + height: 0.875rem; +} + +/* ===== MODAL CONTENT ===== */ + +.schema-container .modal-content { + padding: var(--schema-space-lg); + overflow-y: auto; + flex: 1; + min-height: 0; +} + +/* ===== MODAL FOOTER ===== */ + +.schema-container .modal-footer { + padding: var(--schema-space-lg) var(--schema-space-xl); + border-top: 1px solid var(--schema-border); + background: var(--schema-modal-bg); +} + +/* ===== RESPONSIVE ADJUSTMENTS ===== */ + +@media (max-width: 640px) { + .schema-container .modal-sm, + .schema-container .modal-md, + .schema-container .modal-lg, + .schema-container .modal-xl { + width: 95vw; + max-height: 90vh; + } + + .schema-container .settings-modal-custom { + width: 95%; + } + + .schema-container .modal-header, + .schema-container .modal-content, + .schema-container .modal-footer { + padding-left: var(--schema-space-lg); + padding-right: var(--schema-space-lg); + } +} + +/* ===== DARK THEME ADJUSTMENTS ===== */ + +@media (prefers-color-scheme: dark) { + .schema-container .modal-overlay { + background: rgba(0, 0, 0, 0.6); + } + + .schema-container .modal { + box-shadow: + 0 25px 50px -12px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(255, 255, 255, 0.05); + } +} + +/* ===== ACCESSIBILITY ENHANCEMENTS ===== */ + +@media (prefers-contrast: high) { + .schema-container .modal-close { + border: 1px solid var(--schema-border-strong); + } + + .schema-container .modal { + border-width: 2px; + } +} + +@media (prefers-reduced-motion: reduce) { + .schema-container .modal-overlay { + animation: none; + } + + .schema-container .modal { + animation: none; + } + + .schema-container .modal-close:hover { + transform: none; + } + + .schema-container .modal-close:active { + transform: none; + } +} + +/* ===== CUSTOM PROPERTIES FOR THEMING ===== */ + +.schema-container { + --schema-radius-lg: 0.75rem; +} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..040ebdf --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,90 @@ +import React, { useCallback, useEffect } from 'react'; +import { FaTimes } from 'react-icons/fa'; +import { Button } from '../inputs'; +import './Modal.styles.css'; + +export interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + footer?: React.ReactNode; + className?: string; + size?: 'sm' | 'md' | 'lg' | 'xl'; + closeOnOverlayClick?: boolean; + closeOnEscape?: boolean; + showCloseButton?: boolean; +} + +export const Modal: React.FC = ({ + isOpen, + onClose, + title, + children, + footer, + className = '', + size = 'md', + closeOnOverlayClick = true, + closeOnEscape = true, + showCloseButton = true, +}) => { + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (closeOnOverlayClick && e.target === e.currentTarget) { + onClose(); + } + }, + [onClose, closeOnOverlayClick] + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (closeOnEscape && e.key === 'Escape') { + onClose(); + } + }, + [onClose, closeOnEscape] + ); + + useEffect(() => { + if (isOpen && closeOnEscape) { + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + } + }, [isOpen, handleKeyDown, closeOnEscape]); + + if (!isOpen) return null; + + const modalClasses = ['modal', `modal-${size}`, className] + .filter(Boolean) + .join(' '); + + return ( +
+
+
+

{title}

+ {showCloseButton && ( + + )} +
+ +
{children}
+ + {footer &&
{footer}
} +
+
+ ); +}; + +export default Modal; diff --git a/src/components/OneOfSelector.styles.css b/src/components/OneOfSelector.styles.css index 4a2e3d6..a3f16be 100644 --- a/src/components/OneOfSelector.styles.css +++ b/src/components/OneOfSelector.styles.css @@ -1,18 +1,14 @@ .oneof-selector { - border: 1px solid var(--schema-border-subtle); - border-radius: var(--schema-radius); - background: var(--schema-surface-subtle); margin: var(--schema-space-sm) 0; } .oneof-tabs { display: flex; gap: var(--schema-space-xs); - padding: var(--schema-space-xs); - background: var(--schema-surface-muted); - border-bottom: 1px solid var(--schema-border-subtle); - border-radius: var(--schema-radius) var(--schema-radius) 0 0; + padding: 0; + background: transparent; flex-wrap: wrap; + margin-bottom: var(--schema-space-sm); } .oneof-tab { @@ -28,6 +24,10 @@ min-height: 2.5rem; } +.oneof-tab:active .badge { + transform: scale(0.95); +} + .schema-container .oneof-tab .badge { cursor: pointer; } @@ -36,8 +36,60 @@ transform: scale(1.05); } -.oneof-tab:active .badge { - transform: scale(0.95); +/* Search hit status for oneof tabs and badges */ +.oneof-tab.search-hit { + position: relative; +} + +/* Badge search hit styling */ +.schema-container .oneof-tab .badge.search-hit { + position: relative; +} + +.schema-container .oneof-tab .badge.search-hit::after { + content: ''; + position: absolute; + top: -1px; + right: -1px; + width: 6px; + height: 6px; + border-radius: 50%; + pointer-events: none; +} + +.schema-container .oneof-tab .badge.search-hit.direct-hit::after { + background: var(--schema-border-focus); + box-shadow: 0 0 0 1px white; +} + +.schema-container .oneof-tab .badge.search-hit.indirect-hit::after { + background: var(--schema-border-focus); + box-shadow: 0 0 0 1px white; +} + +.schema-container .oneof-tab .badge.search-hit.both-hit::after { + background: var(--schema-border-focus); + box-shadow: 0 0 0 1px white; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.1); + } +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .schema-container .oneof-tab .badge.search-hit.both-hit::after { + animation: none; + } } .oneof-tab:focus { @@ -68,8 +120,8 @@ } .oneof-content { - padding: var(--schema-space-md); - background: var(--schema-surface); + padding: 0; + background: transparent; } .oneof-description { @@ -80,10 +132,10 @@ font-size: var(--schema-text-base); color: var(--schema-text-secondary); line-height: 1.5; - padding: var(--schema-space-sm); - background: var(--schema-surface-subtle); - border: 1px solid var(--schema-border-subtle); - border-radius: var(--schema-radius-sm); + padding: 0; + background: transparent; + border: none; + border-radius: 0; } .oneof-properties h4 { @@ -126,15 +178,6 @@ /* Dark mode adjustments */ @media (prefers-color-scheme: dark) { - .oneof-selector { - background: var(--schema-surface-subtle); - border-color: var(--schema-border-subtle); - } - - .oneof-tabs { - background: var(--schema-surface-muted); - } - .oneof-property-item .property-name { background: var(--schema-surface-muted); color: var(--schema-text); @@ -143,10 +186,6 @@ /* High contrast mode */ @media (prefers-contrast: high) { - .oneof-selector { - border-width: 2px; - } - .oneof-tab { border-width: 2px; } diff --git a/src/components/OneOfSelector.tsx b/src/components/OneOfSelector.tsx index e9beccb..86ca0e5 100644 --- a/src/components/OneOfSelector.tsx +++ b/src/components/OneOfSelector.tsx @@ -1,13 +1,15 @@ import React, { useState, useCallback, useMemo } from 'react'; import { JsonSchema, PropertyState } from '../types'; -import { resolveSchema, extractProperties } from '../utils'; +import { resolveSchema, extractProperties, searchInSchema } from '../utils'; import { Badge } from './Badge'; import Rows from '../Rows'; + import './OneOfSelector.styles.css'; interface OneOfSelectorProps { oneOfOptions: JsonSchema[]; rootSchema: JsonSchema; + propertyPath?: string[]; _onCopy?: (text: string, element: HTMLElement) => void; onCopyLink?: (propertyKey: string, element: HTMLElement) => void; propertyStates?: Record; @@ -21,6 +23,7 @@ interface OneOfSelectorProps { const OneOfSelector: React.FC = ({ oneOfOptions, rootSchema, + propertyPath = [], _onCopy, onCopyLink, propertyStates: _propertyStates = {}, @@ -39,12 +42,22 @@ const OneOfSelector: React.FC = ({ const getOptionDisplay = useCallback( (option: JsonSchema, index: number) => { + // Check for search hit status + let searchHitStatus = 'none'; + if (searchQuery?.trim()) { + const hasMatch = searchInSchema(option, rootSchema, searchQuery, true); + if (hasMatch) { + searchHitStatus = 'direct'; // OneOf options are considered direct matches when they contain search hits + } + } + // Check for title first, use it if available if (option.title) { return { label: option.title, type: option.type || 'object', isReference: Boolean(oneOfOptions[index].$ref), + searchHitStatus, }; } @@ -57,6 +70,7 @@ const OneOfSelector: React.FC = ({ label: displayName, type: 'object', isReference: true, + searchHitStatus, }; } @@ -67,6 +81,7 @@ const OneOfSelector: React.FC = ({ label: types.join(' | '), type: types.join(' | '), isReference: false, + searchHitStatus, }; } @@ -74,9 +89,10 @@ const OneOfSelector: React.FC = ({ label: 'Unknown', type: 'unknown', isReference: false, + searchHitStatus, }; }, - [oneOfOptions] + [oneOfOptions, searchQuery, rootSchema] ); const selectedOption = resolvedOptions[selectedIndex]; @@ -89,11 +105,15 @@ const OneOfSelector: React.FC = ({ return []; } - // Create unique property states for oneOf content to avoid conflicts - const oneOfPath = ['oneof', selectedIndex.toString()]; + // Use the provided property path to maintain context for pattern properties + // If no property path is provided, fall back to the old oneOf path behavior + const basePath = + propertyPath.length > 0 + ? propertyPath + : ['oneof', selectedIndex.toString()]; const properties = extractProperties( currentOption, - oneOfPath, + basePath, 0, rootSchema, [] @@ -101,7 +121,7 @@ const OneOfSelector: React.FC = ({ // Sort properties alphabetically by name return properties.sort((a, b) => a.name.localeCompare(b.name)); - }, [resolvedOptions, selectedIndex, rootSchema]); + }, [resolvedOptions, selectedIndex, rootSchema, propertyPath]); // Create isolated property states for oneOf properties - completely independent const [internalPropertyStates, setInternalPropertyStates] = useState< @@ -151,18 +171,26 @@ const OneOfSelector: React.FC = ({ {optionDisplays.map((display, index) => ( + + Settings are saved per-site and will persist between visits. +

+ } + > +
+

Examples

+
+ - {isOpen && ( -
-
-
-

Display Settings

- -
- -
-
-

Examples

-
-
+
+

Navigation

+
+ -
-

Display

-
-