defmodule JSONSchemaEditor do @moduledoc """ A Phoenix LiveComponent for visually editing JSON Schemas. ## Attributes * `schema` - The JSON Schema to edit (required). * `on_save` - A callback function that will be called when the schema is updated. It receives the updated schema as a map. * `id` - The unique identifier for the component. ## Examples <.live_component module={JSONSchemaEditor} id="json-editor" schema={@my_schema} on_save={fn updated_schema -> send(self(), {:schema_saved, updated_schema}) end} /> """ use Phoenix.LiveComponent alias JSONSchemaEditor.SchemaUtils alias JSONSchemaEditor.Validator alias JSONSchemaEditor.PrettyPrinter alias JSONSchemaEditor.Components @types ["string", "number", "integer", "boolean", "object", "array"] @logic_types ["anyOf", "oneOf", "allOf"] @formats ["email", "date-time", "date", "time", "uri", "uuid", "ipv4", "ipv6", "hostname"] @doc """ Initializes the component with the given assigns. """ def update(assigns, socket) do # Extract class and rest from assigns class = Map.get(assigns, :class) rest = Map.get(assigns, :rest, %{}) # Known keys that should NOT go into rest known_keys = [ :id, :schema, :on_save, :ui_state, :active_tab, :myself, :flash, :class, :rest, :socket, :__changed__ ] # Collect unknown keys into rest unknown_keys = Map.drop(assigns, known_keys) rest = Map.merge(rest, unknown_keys) socket = socket |> assign(Map.take(assigns, [:id, :schema, :on_save, :ui_state, :active_tab, :myself, :flash])) |> assign(:class, class) |> assign(:rest, rest) |> assign_new(:ui_state, fn -> %{} end) |> assign_new(:schema, fn -> %{} end) |> update(:schema, fn s -> Map.put_new(s, "$schema", "https://json-schema.org/draft-07/schema") end) |> assign(:types, @types) |> assign(:formats, @formats) |> assign_new(:active_tab, fn -> :editor end) |> assign(:logic_types, @logic_types) |> validate_and_assign_errors() {:ok, socket} end defp validate_and_assign_errors(socket) do errors = Validator.validate_schema(socket.assigns.schema) assign(socket, :validation_errors, errors) end def handle_event("change_schema", %{"value" => schema_uri}, socket) do socket = update_schema(socket, JSON.encode!([]), fn node -> if schema_uri == "" do Map.delete(node, "$schema") else Map.put(node, "$schema", schema_uri) end end) {:noreply, socket} end def handle_event("change_format", %{"path" => path_json, "value" => format}, socket) do socket = update_schema(socket, path_json, fn node -> if format == "" do Map.delete(node, "format") else Map.put(node, "format", format) end end) {:noreply, socket} end def handle_event("switch_tab", %{"tab" => tab}, socket) do {:noreply, assign(socket, :active_tab, String.to_existing_atom(tab))} end def handle_event("change_type", %{"path" => path_json, "type" => new_type}, socket) do socket = update_schema(socket, path_json, fn node -> # Clean up existing conflicting keys base_node = Map.drop(node, ["type", "properties", "required", "items", "anyOf", "oneOf", "allOf"]) case new_type do "object" -> Map.put(base_node, "type", "object") |> Map.put("properties", %{}) "array" -> Map.put(base_node, "type", "array") |> Map.put("items", %{"type" => "string"}) logic when logic in @logic_types -> Map.put(base_node, logic, [%{"type" => "string"}]) _ -> Map.put(base_node, "type", new_type) end end) {:noreply, socket} end def handle_event("add_property", %{"path" => path_json}, socket) do socket = update_schema(socket, path_json, fn node -> props = Map.get(node, "properties", %{}) new_key = SchemaUtils.generate_unique_key(props, "new_field") Map.put(node, "properties", Map.put(props, new_key, %{"type" => "string"})) end) {:noreply, socket} end def handle_event("delete_property", %{"path" => path_json, "key" => key}, socket) do socket = update_schema(socket, path_json, fn node -> new_props = Map.delete(Map.get(node, "properties", %{}), key) new_required = List.delete(Map.get(node, "required", []), key) node |> Map.put("properties", new_props) |> Map.put("required", new_required) end) {:noreply, socket} end def handle_event("toggle_required", %{"path" => path_json, "key" => key}, socket) do socket = update_schema(socket, path_json, fn node -> current_required = Map.get(node, "required", []) new_required = if key in current_required do List.delete(current_required, key) else current_required ++ [key] end Map.put(node, "required", new_required) end) {:noreply, socket} end def handle_event( "rename_property", %{"path" => path_json, "old_key" => old_key, "value" => new_key}, socket ) do new_key = String.trim(new_key) if new_key == "" or new_key == old_key do {:noreply, socket} else socket = update_schema(socket, path_json, fn node -> current_props = Map.get(node, "properties", %{}) if Map.has_key?(current_props, new_key) do node else {value, remaining} = Map.pop(current_props, old_key) new_props = Map.put(remaining, new_key, value) current_required = Map.get(node, "required", []) new_required = Enum.map(current_required, fn k -> if k == old_key, do: new_key, else: k end) node |> Map.put("properties", new_props) |> Map.put("required", new_required) end end) {:noreply, socket} end end def handle_event("change_title", %{"path" => path_json, "value" => title}, socket) do {:noreply, update_node_field(socket, path_json, "title", title)} end def handle_event("change_description", %{"path" => path_json, "value" => description}, socket) do {:noreply, update_node_field(socket, path_json, "description", description)} end def handle_event("toggle_ui", %{"path" => path_json, "type" => type}, socket) do ui_state = socket.assigns.ui_state key = "#{type}:#{path_json}" new_expanded = !Map.get(ui_state, key, false) ui_state = Map.put(ui_state, key, new_expanded) {:noreply, assign(socket, :ui_state, ui_state)} end def handle_event( "update_constraint", %{"path" => path_json, "field" => field, "value" => value}, socket ) do casted_value = SchemaUtils.cast_constraint_value(field, value) socket = update_schema(socket, path_json, fn node -> if casted_value in [nil, "", false] do Map.delete(node, field) else Map.put(node, field, casted_value) end end) {:noreply, socket} end def handle_event("add_enum_value", %{"path" => path_json}, socket) do socket = update_schema(socket, path_json, fn node -> current_enum = Map.get(node, "enum", []) type = Map.get(node, "type", "string") default_value = case type do "number" -> 0.0 "integer" -> 0 "boolean" -> true _ -> "new value" end Map.put(node, "enum", current_enum ++ [default_value]) end) {:noreply, socket} end def handle_event("remove_enum_value", %{"path" => path_json, "index" => index}, socket) do index = String.to_integer(index) socket = update_schema(socket, path_json, fn node -> current_enum = Map.get(node, "enum", []) new_enum = List.delete_at(current_enum, index) if new_enum == [] do Map.delete(node, "enum") else Map.put(node, "enum", new_enum) end end) {:noreply, socket} end def handle_event( "update_enum_value", %{"path" => path_json, "index" => index, "value" => value}, socket ) do index = String.to_integer(index) socket = update_schema(socket, path_json, fn node -> current_enum = Map.get(node, "enum", []) type = Map.get(node, "type", "string") casted_value = SchemaUtils.cast_value_by_type(type, value) new_enum = List.replace_at(current_enum, index, casted_value) Map.put(node, "enum", new_enum) end) {:noreply, socket} end def handle_event("add_logic_branch", %{"path" => path_json, "type" => logic_type}, socket) do socket = update_schema(socket, path_json, fn node -> branches = Map.get(node, logic_type, []) Map.put(node, logic_type, branches ++ [%{"type" => "string"}]) end) {:noreply, socket} end def handle_event( "remove_logic_branch", %{"path" => path_json, "type" => logic_type, "index" => index}, socket ) do index = String.to_integer(index) socket = update_schema(socket, path_json, fn node -> branches = Map.get(node, logic_type, []) new_branches = List.delete_at(branches, index) if new_branches == [] do # Revert to a basic type if all branches are gone Map.delete(node, logic_type) |> Map.put("type", "string") else Map.put(node, logic_type, new_branches) end end) {:noreply, socket} end def handle_event("toggle_additional_properties", %{"path" => path_json}, socket) do socket = update_schema(socket, path_json, fn node -> case Map.get(node, "additionalProperties") do false -> Map.delete(node, "additionalProperties") _ -> Map.put(node, "additionalProperties", false) end end) {:noreply, socket} end def handle_event("save", _params, socket) do if Enum.empty?(socket.assigns.validation_errors) and socket.assigns[:on_save] do socket.assigns.on_save.(socket.assigns.schema) end {:noreply, socket} end defp update_schema(socket, path_json, update_fn) do path = JSON.decode!(path_json) schema = SchemaUtils.update_in_path(socket.assigns.schema, path, update_fn) socket |> assign(:schema, schema) |> validate_and_assign_errors() end defp update_node_field(socket, path_json, field, value) do value = String.trim(value) update_schema(socket, path_json, fn node -> if value == "" do Map.delete(node, field) else Map.put(node, field, value) end end) end def render(assigns) do assigns = assigns |> assign(:types, @types) |> assign(:logic_types, @logic_types) ~H"""
<%= if @active_tab == :editor do %>
<% end %> <%= if @active_tab == :preview do %>
Current Schema
<%= PrettyPrinter.format(@schema) %>
<% end %>
""" end end