All files / shared / components YamlEditor.tsx

88.4% Statements 61/69
80.64% Branches 50/62
95.45% Functions 21/22
89.65% Lines 52/58

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    151x   151x   58x         34x 34x             42x 34x 30x 30x   32x 6x   24x 9x   1276x   19x                   14x                 1307x   54x   11x 11x 11x         54x 8x 1160x                 46x                   151x   256x 256x 256x 8x 8x 8x   256x 60x               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>
  )
}