CodeMirror, ViewComponent, and Stimulus
Table of Contents
: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
- The
JsonEditorComponent
encapsulates the form field and editor configuration - It handles converting the model attribute to JSON format when needed
- The component renders a hidden textarea (actual form field) and a container for CodeMirror
# Stimulus Integration
- The
json-editor
controller initializes CodeMirror when connected - It sets up CodeMirror with JSON syntax highlighting and linting
- Changes in the editor are synced back to the hidden textarea using a custom updateListener extension. It’s weird.
- 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.