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, and logical composition (oneOf, anyOf, allOf). ## 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, PrettyPrinter, Components} @types ~w(string number integer boolean object array) @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 ] {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) |> validate_and_assign_errors() {:ok, socket} end defp validate_and_assign_errors(socket), do: assign(socket, :validation_errors, Validator.validate_schema(socket.assigns.schema)) 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)} def handle_event("close_import_modal", _, socket), do: {:noreply, assign(socket, :show_import_modal, false) |> assign(:import_error, nil)} def handle_event("import_schema", %{"schema_text" => text}, socket) do case JSON.decode(text) do {:ok, schema} when is_map(schema) -> socket = socket |> assign(:schema, schema) |> assign(:show_import_modal, false) |> assign(:import_error, nil) |> validate_and_assign_errors() {:noreply, socket} {:ok, _} -> {:noreply, assign(socket, :import_error, "Invalid schema: Must be a JSON object")} {: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 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))} def handle_event("change_type", %{"path" => path, "type" => type}, socket) do {:noreply, update_schema(socket, path, fn node -> base = Map.drop(node, ~w(type properties required items anyOf oneOf allOf)) case type do "object" -> Map.merge(base, %{"type" => "object", "properties" => %{}}) "array" -> Map.merge(base, %{"type" => "array", "items" => %{"type" => "string"}}) l when l in @logic_types -> Map.put(base, l, [%{"type" => "string"}]) _ -> Map.put(base, "type", type) end end)} end def handle_event("add_property", %{"path" => path}, socket) do {:noreply, update_schema(socket, path, fn node -> props = Map.get(node, "properties", %{}) Map.put( node, "properties", Map.put(props, SchemaUtils.generate_unique_key(props, "new_field"), %{"type" => "string"}) ) end)} end def handle_event("delete_property", %{"path" => path, "key" => key}, socket) do {:noreply, update_schema(socket, path, fn node -> node |> Map.update("properties", %{}, &Map.delete(&1, key)) |> Map.update("required", [], &List.delete(&1, key)) end)} end def handle_event("rename_property", %{"path" => path, "old_key" => old, "value" => new}, socket) do new = String.trim(new) if new == "" or new == old do {:noreply, socket} else {:noreply, update_schema(socket, path, fn node -> if get_in(node, ["properties", new]) do node else {val, props} = Map.pop(node["properties"], old) node |> Map.put("properties", Map.put(props, new, val)) |> Map.update("required", [], fn req -> Enum.map(req, &if(&1 == old, do: new, else: &1)) end) end end)} end end def handle_event("toggle_required", %{"path" => path, "key" => key}, socket) do {:noreply, update_schema(socket, path, fn node -> Map.update( node, "required", [key], &if(key in &1, do: List.delete(&1, key), else: &1 ++ [key]) ) end)} end # Consolidated field updaters def handle_event("change_schema", %{"value" => v}, socket), do: {:noreply, update_node_field(socket, JSON.encode!([]), "$schema", v)} def handle_event("change_format", %{"path" => p, "value" => v}, socket), do: {:noreply, update_node_field(socket, p, "format", v)} def handle_event("change_title", %{"path" => p, "value" => v}, socket), do: {:noreply, update_node_field(socket, p, "title", String.trim(v))} def handle_event("change_description", %{"path" => p, "value" => v}, socket), do: {:noreply, update_node_field(socket, p, "description", String.trim(v))} def handle_event("update_constraint", %{"path" => p, "field" => f, "value" => v}, socket), do: {:noreply, update_node_field(socket, p, f, SchemaUtils.cast_value(f, v))} def handle_event("update_const", %{"path" => p, "value" => v}, socket) do {:noreply, update_schema(socket, p, fn node -> if v == "", do: Map.delete(node, "const"), else: Map.put(node, "const", SchemaUtils.cast_value(node["type"] || "string", v)) end)} end def handle_event("toggle_additional_properties", %{"path" => p}, socket) do {:noreply, update_schema(socket, p, fn node -> if node["additionalProperties"] == false, do: Map.delete(node, "additionalProperties"), else: Map.put(node, "additionalProperties", false) end)} end def handle_event("add_enum_value", %{"path" => p}, socket) do {:noreply, update_schema(socket, p, fn node -> def_val = case node["type"] do "number" -> 0.0 "integer" -> 0 "boolean" -> true _ -> "new value" end Map.update(node, "enum", [def_val], &(&1 ++ [def_val])) end)} end def handle_event("remove_enum_value", %{"path" => p, "index" => idx}, socket) do {:noreply, update_schema(socket, p, fn node -> new_enum = List.delete_at(node["enum"] || [], String.to_integer(idx)) if new_enum == [], do: Map.delete(node, "enum"), else: Map.put(node, "enum", new_enum) end)} end def handle_event("update_enum_value", %{"path" => p, "index" => idx, "value" => v}, socket) do {:noreply, update_schema(socket, p, fn node -> Map.put( node, "enum", List.replace_at( node["enum"] || [], String.to_integer(idx), SchemaUtils.cast_value(node["type"] || "string", v) ) ) end)} end def handle_event("add_logic_branch", %{"path" => p, "type" => t}, socket), do: {:noreply, update_schema(socket, p, &Map.update(&1, t, [], fn b -> b ++ [%{"type" => "string"}] end))} def handle_event("remove_logic_branch", %{"path" => p, "type" => t, "index" => idx}, socket) do {:noreply, update_schema(socket, p, fn node -> new_branches = List.delete_at(node[t] || [], String.to_integer(idx)) if new_branches == [], do: Map.delete(node, t) |> Map.put("type", "string"), else: Map.put(node, t, new_branches) end)} end def handle_event("add_contains", %{"path" => p}, socket), do: {:noreply, update_schema(socket, p, &Map.put(&1, "contains", %{"type" => "string"}))} def handle_event("remove_contains", %{"path" => p}, socket), do: {:noreply, update_schema(socket, p, &Map.delete(&1, "contains"))} defp update_schema(socket, path_json, update_fn) do socket |> assign( :schema, SchemaUtils.update_in_path(socket.assigns.schema, JSON.decode!(path_json), update_fn) ) |> validate_and_assign_errors() end defp update_node_field(socket, path_json, field, value) do update_schema(socket, path_json, fn node -> if value in [nil, "", false], do: Map.delete(node, field), else: Map.put(node, field, value) end) end def render(assigns) do ~H"""
<%= if @active_tab == :editor do %>
<% end %> <%= if @active_tab == :preview do %>
Current Schema
<%= PrettyPrinter.format(@schema) %>
<% end %>
<%= if @show_import_modal do %>

Import Schema

<%= if @import_error do %>
{@import_error}
<% end %>
<% end %>
""" end end