All files / shared / components YamlEditor.tsx

100% Statements 23/23
96% Branches 24/25
100% Functions 9/9
100% Lines 20/20

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109                                      42x   30x 30x   6x 6x   24x 9x       19x                   14x                 54x   54x   11x 11x 11x         54x 8x                   46x                             5x         17x               5x              
import { useState, useCallback } from 'react'
import { load } from 'js-yaml'
import type { LintResult, ConfigSchemaHelpNode } from '@/layers/interfaces'
 
interface YamlEditorProps {
  value: string
  onChange: (value: string) => void
  readOnly?: boolean
  lint?: (parsed: unknown, raw: string) => LintResult
  help?: ConfigSchemaHelpNode[]
  'data-testid'?: string
}
 
interface LintState {
  syntaxError: string | null
  lintResult: LintResult | null
}
 
function evaluate(raw: string, lint?: YamlEditorProps['lint']): LintState {
  if (raw.trim() === '') return { syntaxError: null, lintResult: null }
  let parsed: unknown
  try {
    parsed = load(raw)
  } catch (err: unknown) {
    const message = err instanceof Error ? err.message : String(err)
    return { syntaxError: `Invalid YAML: ${message}`, lintResult: null }
  }
  if (!lint) return { syntaxError: null, lintResult: null }
  return { syntaxError: null, lintResult: lint(parsed, raw) }
}
 
function HelpNode({ node, depth }: { node: ConfigSchemaHelpNode; depth: number }) {
  return (
    <div className={depth > 0 ? 'ml-3 flex flex-col gap-0.5' : 'flex flex-col gap-0.5'} data-testid={`yaml-help-node-${node.key}`}>
      <span>
        <span className="font-mono font-medium">{node.key}</span>
        {node.required && <span className="ml-1 text-muted-foreground">(required)</span>}
        {node.description && <span className="ml-1 text-muted-foreground">{node.description}</span>}
      </span>
      {node.children && node.children.length > 0 && (
        <div className="flex flex-col gap-0.5">
          {node.children.map((child) => (
            <HelpNode key={child.key} node={child} depth={depth + 1} />
          ))}
        </div>
      )}
    </div>
  )
}
 
export function YamlEditor({ value, onChange, readOnly = false, lint, help, 'data-testid': dataTestId }: YamlEditorProps) {
  const [state, setState] = useState<LintState>(() => evaluate(value, lint))
 
  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLTextAreaElement>) => {
      const raw = e.target.value
      onChange(raw)
      setState(evaluate(raw, lint))
    },
    [onChange, lint],
  )
 
  if (readOnly) {
    return (
      <div
        data-testid={dataTestId}
        className="font-mono text-sm whitespace-pre-wrap rounded-md border border-input bg-muted/30 px-3 py-2 min-h-[5rem] text-muted-foreground"
      >
        {value || <span className="italic">—</span>}
      </div>
    )
  }
 
  return (
    <div className="flex flex-col gap-1">
      <textarea
        value={value}
        onChange={handleChange}
        data-testid={dataTestId}
        className="flex min-h-[12rem] w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
        spellCheck={false}
      />
      {state.syntaxError && (
        <p className="text-xs text-destructive" data-testid="yaml-error">
          {state.syntaxError}
        </p>
      )}
      {state.lintResult?.errors.map((issue, index) => (
        <p key={`lint-error-${index}`} className="text-xs text-destructive" data-testid="yaml-lint-error">
          {issue.message}
        </p>
      ))}
      {state.lintResult?.warnings.map((issue, index) => (
        <p key={`lint-warning-${index}`} className="text-xs text-amber-600 dark:text-amber-400" data-testid="yaml-lint-warning">
          {issue.message}
        </p>
      ))}
      {help && help.length > 0 && (
        <div data-testid="yaml-help" className="mt-1 flex flex-col gap-0.5 rounded-md border border-input bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
          <p className="font-medium text-foreground">Config keys</p>
          {help.map((node) => (
            <HelpNode key={node.key} node={node} depth={0} />
          ))}
        </div>
      )}
    </div>
  )
}