Alex's Slip-box

These are my org-mode notes in sort of Zettelkasten style

CodeMirror, ViewComponent, and Stimulus

:ID: F1038331-823D-49D4-8549-B88AA1A3651A

# Overview

This is a dump of how I made a JSON editor using CodeMirror 6 within a Ruby on Rails application, using ViewComponent and StimulusJS.

I used this in TMP to for creating and updating LLM tools. This is what is looked like:

It does line numbers, formatting, basic linting and 2-way binding with a text area form field. Ultimately the JSON is stored in a jsonb field in a PG database.

# Dependencies

  • Ruby on Rails
  • ViewComponent
  • StimulusJS
  • CodeMirror 6 packages:
    • codemirror
    • @codemirror/lang-json
    • @codemirror/lint

# Implementation

# ViewComponent Class

Create a ViewComponent to encapsulate the JSON editor functionality. This wraps a text area form field, which is hidden. The code editor is the visible part and the data is 2-way bound with the code editor and form field.

class JsonEditorComponent < ApplicationViewComponent
  attr_reader :form, :field_name, :placeholder, :helper_text, :css_class

  def initialize(form:, field_name:, placeholder: '', helper_text: '', css_class: '')
    @form = form
    @field_name = field_name
    @placeholder = placeholder
    @helper_text = helper_text
    @css_class = css_class
  end

  def value
    return @value if defined? @value

    val = form.object.public_send(field_name)
    @value ||= val.blank? ? val : val.to_json
  end
end

# Component Template (HAML)

Create the component template using. The editor itself is “mounted” on the .editor class (editorElem stimulus controller target).

.editor-container{ class: css_class, data: { controller: 'json-editor' } }
  = form.text_area field_name, value:, rows: 10, class: ['form-control', 'd-none'], placeholder: placeholder, data: { 'json-editor-target' => 'input' }
  .editor{ data: { 'json-editor-target' => 'editorElem' } }
  %small.form-text.text-muted.d-block= helper_text
  %button.btn.btn-sm.btn-outline-secondary.mt-2{type: "button", disabled: value.present?,
                                                data: { 'json-editor-target' => 'templateButton', action: 'click->json-editor#insertSchemaTemplate' }}
    %i.bi.bi-code-square
    Insert JSON Schema Template

# Stimulus Controller

Create a Stimulus controller to handle the CodeMirror integration. Configuring CodeMirror is weird. It’s all about extensions and took a bit to wrap my head around it. For example, jsonParseLinter() returns a function that can be passed to linter which returns an extension that will do the JSON linting.

See also https://codemirror.net/examples/lint/

import { Controller } from "@hotwired/stimulus";
import { EditorView, basicSetup } from "codemirror"
import { json, jsonParseLinter } from "@codemirror/lang-json"
import { lintGutter, linter } from "@codemirror/lint"

export default class JsonEditor extends Controller {
  static targets = ['input', 'editorElem', 'templateButton']

  connect() {
    this.initializeEditor()
  }

  disconnect() {
    if (this.editorView) {
      this.editorView.destroy();
      this.editorView = null;
    }
  }

  get extensions() {
    return [
      basicSetup,
      json(),
      lintGutter(),
      linter(jsonParseLinter()),
      EditorView.lineWrapping,
      EditorView.updateListener.of(update => { // of returns an extension
        if (update.docChanged) {
          this.inputTarget.value = update.state.doc.toString()
          this.onInputSchemaChange()
        }
      })
    ]
  }

  initializeEditor() {
    this.editorView = new EditorView({
      doc: this.inputTarget.value,
      extensions: this.extensions,
      parent: this.editorElemTarget
    })
    this.formatContent()
  }

  formatContent() {
    try {
      const content = this.editorView.state.doc.toString();
      if (content.length) {
        // Replace the entire document with the formatted version
        const transaction = this.editorView.state.update({
          changes: {
            from: 0,
            to: this.editorView.state.doc.length,
            insert: this.formatJSON(JSON.parse(content)) // content is JSON string
          }
        });

        this.editorView.dispatch(transaction);
      }
    } catch (e) {
      console.error("JSON formatting failed:", e);
    }
  }

  // Parse and stringify with indentation
  formatJSON(json) {
    return JSON.stringify(json, null, 2);
  }

  onInputSchemaChange() {
    const val = this.inputTarget.value
    if (val.length) {
      this.templateButtonTarget.disabled = true
    } else {
      this.templateButtonTarget.disabled = false
    }
  }

  insertSchemaTemplate() {
    const schemaTemplate = {
      "type": "object",
      "required": ["name", "age"],
      "properties": {
        "name": {
          "type": "string",
          "description": "The person's full name"
        },
        "age": {
          "type": "integer",
          "description": "Age in years",
          "minimum": 0
        },
        // ... additional schema properties ...
      }
    };

    // Replace the entire document with the template
    const transaction = this.editorView.state.update({
      changes: {
        from: 0,
        to: this.editorView.state.doc.length,
        insert: this.formatJSON(schemaTemplate)
      }
    });

    this.editorView.dispatch(transaction);
  }
}

# How It Works

CodeMirror is kind of weird to configure. Once the extensions system is understood, it makes some sense, but certainly not intuitive. It’s all about the extensions.

See also https://codemirror.net/docs/guide/

This component was used to create JSON schema for creating LLM tools. As such it comes with a get started template because who can remember all that nonsense.

# Component Structure

  1. The JsonEditorComponent encapsulates the form field and editor configuration
  2. It handles converting the model attribute to JSON format when needed
  3. The component renders a hidden textarea (actual form field) and a container for CodeMirror

# Stimulus Integration

  1. The json-editor controller initializes CodeMirror when connected
  2. It sets up CodeMirror with JSON syntax highlighting and linting
  3. Changes in the editor are synced back to the hidden textarea using a custom updateListener extension. It’s weird.
  4. Provides a button to insert a template JSON schema when the field is empty

# Features

  • JSON Validation: Uses CodeMirror’s JSON linting to validate input
  • Auto-formatting: Formats JSON with proper indentation
  • Template Insertion: Provides a template button for quick schema creation
  • Two-way Binding: Changes in the editor update the form field value

# Usage Example

To use the JSON editor in a Rails view or component. It is intended to be used with a FormBuilder object.

= form_with(model: llm_tool) do |form|
  .card-body
    .mb-3
      = form.label :input_schema, class: 'form-label'
      = render JsonEditorComponent.new(form:, field_name: :input_schema, css_class: 'input-schema-editor',
      placeholder: '{"type": "object", "properties": {...}}',
      helper_text: 'JSON Schema defining the parameters for this tool. Must include "type" and "properties" fields.' )
  .card-footer
    .d-flex.justify-content-end.mt-4
      = link_to 'Cancel', llm_tools_path, class: 'btn btn-outline-secondary me-2'
      = form.submit class: 'btn btn-primary'

# Installation Steps

# 1. Install Required Packages

yarn add codemirror @codemirror/lang-json @codemirror/lint

# Misc Notes and Considerations

  • The hidden textarea ensures compatibility with Rails form handling
  • The component handles JSON serialization/deserialization automatically
  • The template button is disabled once content exists in the editor to prevent accidental overwrite.

Search Results