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 library also includes a dedicated `JSONSchemaEditor.JSONEditor` component for editing JSON data according to a provided schema. The components include "Test Lab" for live validation, "JSON Preview" with syntax highlighting, and visual tree editors. ## 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. * `on_change` (optional) - A 1-arity callback function invoked when the schema changes. It receives the updated schema as a map. Note: This callback is NOT invoked if the schema is invalid. * `class` (optional) - Additional CSS classes to apply to the root container. * `header_class` (optional) - Additional CSS classes to apply to the header section. * `toolbar_class` (optional) - Additional CSS classes to apply to the toolbar/actions section. ## 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" header_class="bg-gray-100" /> """ 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, :on_change, :ui_state, :active_tab, :myself, :class, :header_class, :toolbar_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(:header_class, fn -> nil end) |> assign_new(:toolbar_class, fn -> nil end) |> assign_new(:ui_state, fn -> %{} end) |> assign_new(:schema, fn -> %{} end) |> assign_new(:on_save, fn -> nil end) |> assign_new(:on_change, fn -> nil 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 defp update_schema_and_notify(socket, new_schema) do socket = socket |> push_history() |> assign(:schema, new_schema) |> validate_and_assign_errors() if socket.assigns.on_change && Enum.empty?(socket.assigns.validation_errors) do socket.assigns.on_change.(new_schema) end socket 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() if socket.assigns.on_change && Enum.empty?(socket.assigns.validation_errors) do socket.assigns.on_change.(previous) end {: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() if socket.assigns.on_change && Enum.empty?(socket.assigns.validation_errors) do socket.assigns.on_change.(next) end {: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 |> update_schema_and_notify(schema) |> assign(:show_import_modal, false) |> assign(:import_error, nil) {: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 |> update_schema_and_notify(new_schema) |> update(:ui_state, &UIState.add_property(&1, path, current_props, new_key))} end def handle_event("delete_property", %{"path" => path, "key" => key}, socket) do {:noreply, socket |> update_schema_and_notify(SchemaMutator.delete_property(socket.assigns.schema, path, key)) |> update(:ui_state, &UIState.remove_property(&1, path, key))} 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 |> update_schema_and_notify(new_schema) |> update( :ui_state, &UIState.rename_property(&1, path, current_props, old, String.trim(new)) )} _ -> {:noreply, socket} end end def handle_event("toggle_ui", %{"path" => p, "type" => t}, socket) do new_ui_state = Map.update(socket.assigns.ui_state, "#{t}:#{p}", true, fn v -> !v end) # If we are expanding constraints, logic or description, ensure the node itself is not collapsed final_ui_state = if t in ["expanded_constraints", "expanded_logic", "expanded_description"] and Map.get(new_ui_state, "#{t}:#{p}") do Map.put(new_ui_state, "collapsed_node:#{p}", false) else new_ui_state end {:noreply, assign(socket, :ui_state, final_ui_state)} end # --- Simple Mutations (Schema update only) --- def handle_event(event, params, socket) do case get_mutation(event, params) do {func, args} -> {:noreply, update_schema_and_notify( socket, apply(SchemaMutator, func, [socket.assigns.schema | args]) )} 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"""