defmodule JSONSchemaEditor do @moduledoc """ A Phoenix LiveComponent for visually editing JSON Schemas. It provides a rich interface for creating and modifying JSON Schemas (Draft 07), supporting nested structures, arrays, validation constraints, logical composition (oneOf, anyOf, allOf), and conditional logic (if, then, else, not). The component also includes a "Test Lab" for live validation of sample data and a "JSON Preview" with syntax highlighting. ## Attributes * `id` (required) - A unique identifier for the component instance. * `schema` (required) - The initial JSON Schema map to edit. Defaults to a basic schema if empty. * `on_save` (optional) - A 1-arity callback function invoked when the user clicks "Save". It receives the current schema as a map. * `class` (optional) - Additional CSS classes to apply to the root container. ## Usage <.live_component module={JSONSchemaEditor} id="json-editor" schema={@my_schema} on_save={fn new_schema -> send(self(), {:save_schema, new_schema}) end} class="my-custom-theme" /> """ use Phoenix.LiveComponent alias JSONSchemaEditor.{ SchemaUtils, Validator, Components, SchemaGenerator, SimpleValidator, UIState, SchemaMutator } @types ~w(string number integer boolean object array null) @logic_types ~w(anyOf oneOf allOf) @formats ~w(email date-time date time uri uuid ipv4 ipv6 hostname) def update(assigns, socket) do known_keys = [ :id, :schema, :on_save, :ui_state, :active_tab, :myself, :class, :show_import_modal, :import_error, :import_mode, :test_data_str, :test_errors ] {known_assigns, rest} = Map.split(assigns, known_keys) socket = socket |> assign(known_assigns) |> assign(:rest, Map.merge(Map.get(socket.assigns, :rest, %{}), rest)) |> assign_new(:class, fn -> nil end) |> assign_new(:ui_state, fn -> %{} end) |> assign_new(:schema, fn -> %{} end) |> update(:schema, &Map.put_new(&1, "$schema", "https://json-schema.org/draft-07/schema")) |> assign(types: @types, formats: @formats, logic_types: @logic_types) |> assign_new(:active_tab, fn -> :editor end) |> assign_new(:show_import_modal, fn -> false end) |> assign_new(:import_error, fn -> nil end) |> assign_new(:import_mode, fn -> :schema end) |> assign_new(:history, fn -> [] end) |> assign_new(:future, fn -> [] end) |> assign_new(:test_data_str, fn -> "{\n \"example\": \"value\"\n}" end) |> assign_new(:test_errors, fn -> [] end) |> validate_and_assign_errors() {:ok, socket} end defp validate_and_assign_errors(socket) do schema_errors = Validator.validate_schema(socket.assigns.schema) socket = assign(socket, :validation_errors, schema_errors) validate_test_data(socket) end defp validate_test_data(socket) do case JSON.decode(socket.assigns.test_data_str) do {:ok, data} -> errors = SimpleValidator.validate(socket.assigns.schema, data) status = if errors == [], do: :ok, else: errors assign(socket, :test_errors, status) {:error, _} -> assign(socket, :test_errors, ["Invalid JSON Syntax"]) end end defp push_history(socket) do socket |> update(:history, fn h -> [socket.assigns.schema | Enum.take(h, 49)] end) |> assign(:future, []) end def handle_event("update_test_data", %{"value" => value}, socket) do {:noreply, socket |> assign(:test_data_str, value) |> validate_test_data()} end def handle_event("undo", _, socket) do case socket.assigns.history do [previous | rest] -> socket = socket |> update(:future, fn f -> [socket.assigns.schema | f] end) |> assign(:history, rest) |> assign(:schema, previous) |> validate_and_assign_errors() {:noreply, socket} [] -> {:noreply, socket} end end def handle_event("redo", _, socket) do case socket.assigns.future do [next | rest] -> socket = socket |> update(:history, fn h -> [socket.assigns.schema | h] end) |> assign(:future, rest) |> assign(:schema, next) |> validate_and_assign_errors() {:noreply, socket} [] -> {:noreply, socket} end end def handle_event("switch_tab", %{"tab" => tab}, socket), do: {:noreply, assign(socket, :active_tab, String.to_existing_atom(tab))} def handle_event("open_import_modal", _, socket), do: {:noreply, assign(socket, :show_import_modal, true) |> assign(:import_mode, :schema)} def handle_event("close_import_modal", _, socket), do: {:noreply, assign(socket, :show_import_modal, false) |> assign(:import_error, nil)} def handle_event("set_import_mode", %{"mode" => mode}, socket), do: {:noreply, assign(socket, :import_mode, String.to_existing_atom(mode))} def handle_event("import_schema", %{"schema_text" => text}, socket) do case JSON.decode(text) do {:ok, data} -> schema = if socket.assigns.import_mode == :json do SchemaGenerator.generate(data) else data end if is_map(schema) do socket = socket |> push_history() |> assign(:schema, schema) |> assign(:show_import_modal, false) |> assign(:import_error, nil) |> validate_and_assign_errors() {:noreply, socket} else {:noreply, assign(socket, :import_error, "Invalid schema: Must be a JSON object")} end {:error, _} -> {:noreply, assign(socket, :import_error, "Invalid JSON")} end end def handle_event("save", _, socket) do if Enum.empty?(socket.assigns.validation_errors) and socket.assigns[:on_save], do: socket.assigns.on_save.(socket.assigns.schema) {:noreply, socket} end # --- Complex Mutations (involve UI State or special logic) --- def handle_event("add_property", %{"path" => path}, socket) do {:ok, path_list} = JSON.decode(path) current_props = SchemaUtils.get_in_path(socket.assigns.schema, path_list) |> Map.get("properties", %{}) {new_schema, new_key} = SchemaMutator.add_property(socket.assigns.schema, path) {:noreply, socket |> push_history() |> assign(:schema, new_schema) |> update(:ui_state, &UIState.add_property(&1, path, current_props, new_key)) |> validate_and_assign_errors()} end def handle_event("delete_property", %{"path" => path, "key" => key}, socket) do {:noreply, socket |> push_history() |> assign(:schema, SchemaMutator.delete_property(socket.assigns.schema, path, key)) |> update(:ui_state, &UIState.remove_property(&1, path, key)) |> validate_and_assign_errors()} end def handle_event("rename_property", %{"path" => path, "old_key" => old, "value" => new}, socket) do case SchemaMutator.rename_property(socket.assigns.schema, path, old, new) do {:ok, new_schema} -> {:ok, path_list} = JSON.decode(path) current_props = SchemaUtils.get_in_path(socket.assigns.schema, path_list) |> Map.get("properties", %{}) {:noreply, socket |> push_history() |> assign(:schema, new_schema) |> update( :ui_state, &UIState.rename_property(&1, path, current_props, old, String.trim(new)) ) |> validate_and_assign_errors()} _ -> {:noreply, socket} end end def handle_event("toggle_ui", %{"path" => p, "type" => t}, socket), do: {:noreply, update(socket, :ui_state, &Map.update(&1, "#{t}:#{p}", true, fn v -> !v end))} # --- Simple Mutations (Schema update only) --- def handle_event(event, params, socket) do case get_mutation(event, params) do {func, args} -> {:noreply, socket |> push_history() |> assign(:schema, apply(SchemaMutator, func, [socket.assigns.schema | args])) |> validate_and_assign_errors()} nil -> {:noreply, socket} end end defp get_mutation("change_type", %{"path" => p, "type" => t}), do: {:change_type, [p, t]} defp get_mutation("toggle_required", %{"path" => p, "key" => k}), do: {:toggle_required, [p, k]} defp get_mutation("set_default_schema", _), do: {:update_field, ["[]", "$schema", "https://json-schema.org/draft-07/schema"]} defp get_mutation("change_schema", %{"value" => v}), do: {:update_field, ["[]", "$schema", v]} defp get_mutation("change_format", %{"path" => p, "value" => v}), do: {:update_field, [p, "format", v]} defp get_mutation("change_title", %{"path" => p, "value" => v}), do: {:update_field, [p, "title", String.trim(v)]} defp get_mutation("change_description", %{"path" => p, "value" => v}), do: {:update_field, [p, "description", String.trim(v)]} defp get_mutation("update_constraint", %{"path" => p, "field" => f, "value" => v}), do: {:update_constraint, [p, f, v]} defp get_mutation("update_const", %{"path" => p, "value" => v}), do: {:update_const, [p, v]} defp get_mutation("toggle_additional_properties", %{"path" => p}), do: {:toggle_additional_properties, [p]} defp get_mutation("add_enum_value", %{"path" => p}), do: {:add_enum_value, [p]} defp get_mutation("remove_enum_value", %{"path" => p, "index" => i}), do: {:remove_enum_value, [p, i]} defp get_mutation("update_enum_value", %{"path" => p, "index" => i, "value" => v}), do: {:update_enum_value, [p, i, v]} defp get_mutation("add_logic_branch", %{"path" => p, "type" => t}), do: {:add_logic_branch, [p, t]} defp get_mutation("remove_logic_branch", %{"path" => p, "type" => t, "index" => i}), do: {:remove_logic_branch, [p, t, i]} defp get_mutation("add_child", %{"path" => p, "key" => k}), do: {:add_child, [p, k]} defp get_mutation("remove_child", %{"path" => p, "key" => k}), do: {:remove_child, [p, k]} defp get_mutation(_, _), do: nil def render(assigns) do ~H"""
<%= if Map.get(@schema, "$schema") in [nil, ""] do %> <% end %>
<%= if @active_tab == :editor do %>
<% end %> <%= if @active_tab == :preview do %>
Current Schema
<% end %> <%= if @active_tab == :test do %>
Sample JSON Data
Validation Results <%= if @test_errors == :ok do %> Valid <% else %> Invalid <% end %>
<%= if @test_errors == :ok do %>
Data matches the schema!
<% else %>
<%= for error <- @test_errors do %>
<%= if is_binary(error) do %> {error} <% else %> {elem(error, 1)} {elem(error, 0)} <% end %>
<% end %>
<% end %>
<% end %>
<%= if @show_import_modal do %>
<%= if @import_error do %>
{@import_error}
<% end %>
<% end %>
""" end end