Fix maintenance window form crash on mount

The form template iterated over changeset error tuples directly
(e.g. changeset.errors[:name] returns a tuple, not a list), causing
Protocol.UndefinedError for Enumerable on Tuple.

Rewrote form to use to_form/2 and <.input> components per LiveView
conventions. Errors are now handled properly by the input component.
This commit is contained in:
Graham McIntire 2026-02-16 16:02:10 -06:00
parent 0cc519b6bf
commit e09b306f9c
No known key found for this signature in database
2 changed files with 70 additions and 132 deletions

View file

@ -1,4 +1,5 @@
defmodule ToweropsWeb.MaintenanceLive.Form do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Maintenance
@ -21,13 +22,14 @@ defmodule ToweropsWeb.MaintenanceLive.Form do
def handle_params(params, _url, socket) do
case socket.assigns.live_action do
:new ->
changeset = MaintenanceWindow.changeset(%MaintenanceWindow{suppress_alerts: true}, %{})
window = %MaintenanceWindow{suppress_alerts: true, recurring: false}
changeset = MaintenanceWindow.changeset(window, %{})
{:noreply,
socket
|> assign(:page_title, t("New Maintenance Window"))
|> assign(:window, %MaintenanceWindow{suppress_alerts: true, recurring: false})
|> assign(:changeset, changeset)
|> assign(:window, window)
|> assign(:form, to_form(changeset))
|> assign(:scope_type, "org")}
:edit ->
@ -46,7 +48,7 @@ defmodule ToweropsWeb.MaintenanceLive.Form do
socket
|> assign(:page_title, t("Edit Maintenance Window"))
|> assign(:window, window)
|> assign(:changeset, changeset)
|> assign(:form, to_form(changeset))
|> assign(:scope_type, scope_type)}
end
end
@ -58,7 +60,7 @@ defmodule ToweropsWeb.MaintenanceLive.Form do
|> MaintenanceWindow.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :changeset, changeset)}
{:noreply, assign(socket, :form, to_form(changeset))}
end
def handle_event("change_scope", %{"scope_type" => scope_type}, socket) do
@ -97,7 +99,7 @@ defmodule ToweropsWeb.MaintenanceLive.Form do
|> push_navigate(to: ~p"/maintenance/#{window.id}")}
{:error, changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
@ -110,7 +112,7 @@ defmodule ToweropsWeb.MaintenanceLive.Form do
|> push_navigate(to: ~p"/maintenance/#{window.id}")}
{:error, changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
end

View file

@ -17,48 +17,35 @@
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-6">
<.form
for={@changeset}
for={@form}
phx-change="validate"
phx-submit="save"
id="maintenance-form"
>
<div class="space-y-6">
<%!-- Name --%>
<div>
<label for="maintenance_window_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Name")} <span class="text-red-500">*</span>
</label>
<input
type="text"
name="maintenance_window[name]"
id="maintenance_window_name"
value={Ecto.Changeset.get_field(@changeset, :name) || ""}
required
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
placeholder={t("e.g., Tower firmware upgrade")}
/>
<%= for {msg, _} <- (@changeset.errors[:name] || []) do %>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{msg}</p>
<% end %>
</div>
<.input
field={@form[:name]}
type="text"
label={t("Name") <> " *"}
required
placeholder={t("e.g., Tower firmware upgrade")}
/>
<%!-- Reason --%>
<div>
<label for="maintenance_window_reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Reason")}
</label>
<textarea
name="maintenance_window[reason]"
id="maintenance_window_reason"
rows="3"
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
placeholder={t("Optional description of the maintenance work")}
>{Ecto.Changeset.get_field(@changeset, :reason) || ""}</textarea>
</div>
<.input
field={@form[:reason]}
type="textarea"
label={t("Reason")}
rows="3"
placeholder={t("Optional description of the maintenance work")}
/>
<%!-- Scope --%>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{t("Scope")}</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("Scope")}
</label>
<div class="flex items-center gap-4 mb-3">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
@ -99,115 +86,64 @@
</div>
<%= if @scope_type == "site" do %>
<select
name="maintenance_window[site_id]"
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white"
>
<option value="">{t("Select a site...")}</option>
<%= for site <- @sites do %>
<option value={site.id} selected={Ecto.Changeset.get_field(@changeset, :site_id) == site.id}>
{site.name}
</option>
<% end %>
</select>
<.input
field={@form[:site_id]}
type="select"
prompt={t("Select a site...")}
options={Enum.map(@sites, &{&1.name, &1.id})}
/>
<% end %>
<%= if @scope_type == "device" do %>
<select
name="maintenance_window[device_id]"
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white"
>
<option value="">{t("Select a device...")}</option>
<%= for device <- @devices do %>
<option value={device.id} selected={Ecto.Changeset.get_field(@changeset, :device_id) == device.id}>
{device.name}
</option>
<% end %>
</select>
<.input
field={@form[:device_id]}
type="select"
prompt={t("Select a device...")}
options={Enum.map(@devices, &{&1.name, &1.id})}
/>
<% end %>
</div>
<%!-- Time Range --%>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="maintenance_window_starts_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Starts At")} <span class="text-red-500">*</span>
</label>
<input
type="datetime-local"
name="maintenance_window[starts_at]"
id="maintenance_window_starts_at"
value={format_datetime_local(Ecto.Changeset.get_field(@changeset, :starts_at))}
required
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white"
/>
<%= for {msg, _} <- (@changeset.errors[:starts_at] || []) do %>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{msg}</p>
<% end %>
</div>
<div>
<label for="maintenance_window_ends_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Ends At")} <span class="text-red-500">*</span>
</label>
<input
type="datetime-local"
name="maintenance_window[ends_at]"
id="maintenance_window_ends_at"
value={format_datetime_local(Ecto.Changeset.get_field(@changeset, :ends_at))}
required
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white"
/>
<%= for {msg, _} <- (@changeset.errors[:ends_at] || []) do %>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{msg}</p>
<% end %>
</div>
<.input
field={@form[:starts_at]}
type="datetime-local"
label={t("Starts At") <> " *"}
required
value={format_datetime_local(@form[:starts_at].value)}
/>
<.input
field={@form[:ends_at]}
type="datetime-local"
label={t("Ends At") <> " *"}
required
value={format_datetime_local(@form[:ends_at].value)}
/>
</div>
<%!-- Suppress Alerts --%>
<div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="hidden" name="maintenance_window[suppress_alerts]" value="false" />
<input
type="checkbox"
name="maintenance_window[suppress_alerts]"
value="true"
checked={Ecto.Changeset.get_field(@changeset, :suppress_alerts) == true}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:bg-gray-800 dark:border-white/10"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{t("Suppress alerts during this window")}</span>
</label>
</div>
<.input
field={@form[:suppress_alerts]}
type="checkbox"
label={t("Suppress alerts during this window")}
/>
<%!-- Recurring --%>
<div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="hidden" name="maintenance_window[recurring]" value="false" />
<input
type="checkbox"
name="maintenance_window[recurring]"
value="true"
checked={Ecto.Changeset.get_field(@changeset, :recurring) == true}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:bg-gray-800 dark:border-white/10"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{t("Recurring")}</span>
</label>
</div>
<.input
field={@form[:recurring]}
type="checkbox"
label={t("Recurring")}
/>
<%!-- Recurrence Rule --%>
<%= if Ecto.Changeset.get_field(@changeset, :recurring) == true do %>
<div>
<label for="maintenance_window_recurrence_rule" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Recurrence Rule")}
</label>
<input
type="text"
name="maintenance_window[recurrence_rule]"
id="maintenance_window_recurrence_rule"
value={Ecto.Changeset.get_field(@changeset, :recurrence_rule) || ""}
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-blue-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
placeholder={t("e.g., FREQ=WEEKLY;BYDAY=SU")}
/>
</div>
<%= if Phoenix.HTML.Form.input_value(@form, :recurring) == true do %>
<.input
field={@form[:recurrence_rule]}
type="text"
label={t("Recurrence Rule")}
placeholder={t("e.g., FREQ=WEEKLY;BYDAY=SU")}
/>
<% end %>
<%!-- Submit --%>