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    134x   134x   44x         32x 32x             42x 32x 30x 30x   30x 6x   24x 9x   1276x   19x                   14x                 1307x   54x   11x 11x 11x         54x 8x 1160x                 46x                   134x   216x 216x 216x 8x 8x 8x   216x 52x               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>
  )
}