Skip to content

Commit 2658068

Browse files
authored
Recoverable forms for Support Scripts and Device settings (#2379)
Now, if your live socket connection dies, you won't lose your form progress. After making some recommended changes to `core_components.ex`, I also uncovered an issue with Deployment Group conditions, which was the reason for the larger set of changes related to switching the conditions from a map to an embedded schema. While it would have been nice to separate these changes, you will notice the two CI failures until this was addressed. I have a bigger change in the works for improving Deployment Group params, but first I want to get this merged in.
1 parent c865c76 commit 2658068

File tree

24 files changed

+169
-186
lines changed

24 files changed

+169
-186
lines changed

lib/nerves_hub/devices.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -857,7 +857,7 @@ defmodule NervesHub.Devices do
857857
"""
858858
def matches_deployment_group?(
859859
%Device{tags: tags, firmware_metadata: %FirmwareMetadata{version: version}},
860-
%DeploymentGroup{conditions: %{"version" => requirement, "tags" => dep_tags}}
860+
%DeploymentGroup{conditions: %{version: requirement, tags: dep_tags}}
861861
) do
862862
if version_match?(version, requirement) and tags_match?(tags, dep_tags) do
863863
true

lib/nerves_hub/managed_deployments.ex

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,9 @@ defmodule NervesHub.ManagedDeployments do
368368

369369
# Check that a device version matches for a deployment's conditions
370370
# A deployment not having a version condition returns true
371-
defp version_match?(_device, %{conditions: %{"version" => ""}}), do: true
371+
defp version_match?(_device, %{conditions: %{version: ""}}), do: true
372372

373-
defp version_match?(device, %{conditions: %{"version" => version}}) when not is_nil(version) do
373+
defp version_match?(device, %{conditions: %{version: version}}) when not is_nil(version) do
374374
Version.match?(device.firmware_metadata.version, version)
375375
end
376376

@@ -437,11 +437,11 @@ defmodule NervesHub.ManagedDeployments do
437437
|> Repo.one()
438438

439439
bad_version =
440-
if deployment_group.conditions["version"] == "" do
440+
if deployment_group.conditions.version == "" do
441441
false
442442
else
443443
try do
444-
!Version.match?(device_version, deployment_group.conditions["version"])
444+
!Version.match?(device_version, deployment_group.conditions.version)
445445
rescue
446446
_ ->
447447
true
@@ -642,7 +642,7 @@ defmodule NervesHub.ManagedDeployments do
642642
end
643643

644644
# no tags, but version
645-
defp do_matched_devices(%DeploymentGroup{conditions: %{"tags" => [], "version" => version}}, query, work_type)
645+
defp do_matched_devices(%DeploymentGroup{conditions: %{tags: [], version: version}}, query, work_type)
646646
when version != "" do
647647
case work_type do
648648
:count ->
@@ -661,7 +661,7 @@ defmodule NervesHub.ManagedDeployments do
661661
end
662662

663663
# tags but no version
664-
defp do_matched_devices(%DeploymentGroup{conditions: %{"tags" => tags, "version" => ""}}, query, work_type) do
664+
defp do_matched_devices(%DeploymentGroup{conditions: %{tags: tags, version: ""}}, query, work_type) do
665665
query = where(query, [d], fragment("?::text[] && tags::text[]", ^tags))
666666

667667
case work_type do
@@ -676,7 +676,7 @@ defmodule NervesHub.ManagedDeployments do
676676
end
677677

678678
# version and tags
679-
defp do_matched_devices(%DeploymentGroup{conditions: %{"tags" => tags, "version" => version}}, query, work_type) do
679+
defp do_matched_devices(%DeploymentGroup{conditions: %{tags: tags, version: version}}, query, work_type) do
680680
query = where(query, [d], fragment("?::text[] && tags::text[]", ^tags))
681681

682682
case work_type do

lib/nerves_hub/managed_deployments/deployment_group.ex

Lines changed: 36 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ defmodule NervesHub.ManagedDeployments.DeploymentGroup do
2121
:org_id,
2222
:firmware_id,
2323
:name,
24-
:conditions,
2524
:is_active,
2625
:product_id,
2726
:concurrent_updates,
@@ -56,7 +55,11 @@ defmodule NervesHub.ManagedDeployments.DeploymentGroup do
5655
has_many(:deployment_releases, DeploymentRelease)
5756
has_many(:update_stats, UpdateStat, on_delete: :nilify_all, foreign_key: :deployment_id)
5857

59-
field(:conditions, :map)
58+
embeds_one :conditions, __MODULE__.Conditions, primary_key: false, on_replace: :update do
59+
field(:version, :string, default: "")
60+
field(:tags, NervesHub.Types.Tag, default: [])
61+
end
62+
6063
field(:device_failure_threshold, :integer, default: 3)
6164
field(:device_failure_rate_seconds, :integer, default: 180)
6265
field(:device_failure_rate_amount, :integer, default: 5)
@@ -148,47 +151,39 @@ defmodule NervesHub.ManagedDeployments.DeploymentGroup do
148151
|> cast(params, @required_fields ++ @optional_fields)
149152
|> validate_required(@required_fields)
150153
|> unique_constraint(:name, name: :deployments_product_id_name_index)
151-
|> validate_conditions()
154+
|> cast_embed(:conditions, required: true, with: &conditions_changeset/2)
152155
end
153156

154-
defp validate_conditions(changeset) do
155-
validate_change(changeset, :conditions, fn
156-
:conditions, conditions when conditions == %{} ->
157-
[conditions: "can't be blank"]
158-
159-
:conditions, %{"version" => nil} ->
160-
[version: "can't be blank"]
161-
162-
:conditions, conditions ->
163-
types = %{tags: {:array, :string}, version: :string}
164-
# merge the new conditions with the existing ones so that we can
165-
# update tags and version independently
166-
conditions = Map.merge(changeset.data.conditions || %{}, conditions)
167-
168-
changeset =
169-
{%{}, types}
170-
# allow "" as valid value for `version`
171-
|> cast(conditions, Map.keys(types), empty_values: [nil])
172-
|> validate_required([:tags])
173-
|> validate_change(
174-
:version,
175-
fn
176-
:version, "" ->
177-
[]
178-
179-
:version, nil ->
180-
[version: "can't be nil"]
181-
182-
:version, version ->
183-
if Version.parse_requirement(version) == :error do
184-
[version: "must be valid Elixir version requirement string"]
185-
else
186-
[]
187-
end
188-
end
189-
)
190-
191-
changeset.errors
157+
def conditions_changeset(conditions, attrs) do
158+
conditions
159+
|> cast(attrs, [:tags, :version], empty_values: [""])
160+
|> then(fn changeset ->
161+
if Map.has_key?(changeset.changes, :version) && is_nil(changeset.changes.version) do
162+
put_change(changeset, :version, "")
163+
else
164+
changeset
165+
end
192166
end)
167+
|> then(fn changeset ->
168+
if Map.has_key?(changeset.changes, :tags) && is_nil(changeset.changes.tags) do
169+
put_change(changeset, :tags, [])
170+
else
171+
changeset
172+
end
173+
end)
174+
|> validate_change(
175+
:version,
176+
fn
177+
:version, "" ->
178+
[]
179+
180+
:version, version ->
181+
if Version.parse_requirement(version) == :error do
182+
[version: "must be valid Elixir version requirement string"]
183+
else
184+
[]
185+
end
186+
end
187+
)
193188
end
194189
end

lib/nerves_hub/scripts.ex

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,7 @@ defmodule NervesHub.Scripts do
4848

4949
@spec create(Product.t(), User.t(), map()) :: {:ok, Script.t()} | {:error, Changeset.t()}
5050
def create(product, user, params) do
51-
%Script{}
52-
|> Script.create_changeset(product, user, params)
51+
Script.create_changeset(product, user, params)
5352
|> Repo.insert()
5453
|> case do
5554
{:ok, script} ->

lib/nerves_hub/scripts/script.ex

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,25 @@ defmodule NervesHub.Scripts.Script do
2323
timestamps()
2424
end
2525

26-
def create_changeset(%__MODULE__{} = struct, product, created_by, params) do
26+
def validate_changeset(struct \\ %__MODULE__{}, params) do
2727
struct
2828
|> cast(params, @required ++ @optional)
29+
|> validate_required(@required)
30+
|> validate_length(:name, lte: 255)
31+
end
32+
33+
def create_changeset(product, created_by, params) do
34+
validate_changeset(params)
2935
|> put_assoc(:product, product)
36+
|> foreign_key_constraint(:product_id)
3037
|> put_assoc(:created_by, created_by)
31-
|> validate_required(@required ++ [:created_by])
32-
|> validate_length(:name, lte: 255)
3338
|> foreign_key_constraint(:created_by_id)
3439
end
3540

3641
def update_changeset(%__MODULE__{} = struct, edited_by, params \\ %{}) do
3742
struct
38-
|> cast(params, @required ++ @optional)
43+
|> validate_changeset(params)
3944
|> put_change(:last_updated_by_id, edited_by.id)
40-
|> validate_required(@required)
41-
|> validate_length(:name, lte: 255)
4245
|> foreign_key_constraint(:last_updated_by_id)
4346
end
4447
end

lib/nerves_hub_web/components/core_components.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,9 +347,11 @@ defmodule NervesHubWeb.CoreComponents do
347347
slot(:rich_hint)
348348

349349
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
350+
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
351+
350352
assigns
351353
|> assign(field: nil, id: assigns.id || field.id)
352-
|> assign(:errors, Enum.map(field.errors, &translate_error/1))
354+
|> assign(:errors, Enum.map(errors, &translate_error/1))
353355
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
354356
|> assign_new(:value, fn -> field.value end)
355357
|> input()

lib/nerves_hub_web/components/deployment_group_page/settings.ex

Lines changed: 9 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,15 @@ defmodule NervesHubWeb.Components.DeploymentGroupPage.Settings do
9292
The matching is undertaken when a device connects to the platform.
9393
</p>
9494
</div>
95-
<div class="w-1/2">
96-
<.input field={@form[:tags]} value={tags_to_string(@form[:conditions])} label="Tag(s) distributed to" placeholder="eg. batch-123" />
97-
</div>
98-
<div class="w-1/2">
99-
<.input field={@form[:version]} value={@form[:conditions].value["version"]} label="Version requirement" placeholder="eg. 1.2.3" />
100-
</div>
95+
<.inputs_for :let={conditions} field={@form[:conditions]}>
96+
<div class="w-1/2">
97+
<.input field={conditions[:tags]} label="Tag(s) distributed to" placeholder="eg. batch-123" />
98+
</div>
99+
100+
<div class="w-1/2">
101+
<.input field={conditions[:version]} label="Version requirement" placeholder="eg. 1.2.3" />
102+
</div>
103+
</.inputs_for>
101104
</div>
102105
</div>
103106
@@ -272,7 +275,6 @@ defmodule NervesHubWeb.Components.DeploymentGroupPage.Settings do
272275

273276
authorized!(:"deployment_group:update", org_user)
274277

275-
params = inject_conditions_map(params)
276278
firmware_changed? = params["firmware_id"] != to_string(deployment_group.firmware_id)
277279
%{firmware: %{id: old_firmware_id}} = deployment_group
278280

@@ -324,28 +326,6 @@ defmodule NervesHubWeb.Components.DeploymentGroupPage.Settings do
324326
|> noreply()
325327
end
326328

327-
defp inject_conditions_map(%{"version" => version, "tags" => tags} = params) do
328-
params
329-
|> Map.put("conditions", %{
330-
"version" => version,
331-
"tags" =>
332-
tags
333-
|> tags_as_list()
334-
|> MapSet.new()
335-
|> MapSet.to_list()
336-
})
337-
end
338-
339-
defp inject_conditions_map(params), do: params
340-
341-
defp tags_as_list(""), do: []
342-
343-
defp tags_as_list(tags) do
344-
tags
345-
|> String.split(",")
346-
|> Enum.map(&String.trim/1)
347-
end
348-
349329
def firmware_dropdown_options(firmwares) do
350330
firmwares
351331
|> Enum.sort_by(
@@ -400,13 +380,4 @@ defmodule NervesHubWeb.Components.DeploymentGroupPage.Settings do
400380
defp firmware_display_name(%Firmware{} = f) do
401381
"#{f.version} - #{f.platform} - #{f.architecture} (#{String.slice(f.uuid, 0..7)})"
402382
end
403-
404-
@doc """
405-
Convert tags from a list to a comma-separated list (in a string)
406-
"""
407-
def tags_to_string(%Phoenix.HTML.FormField{} = field) do
408-
field.value
409-
|> Map.get("tags", [])
410-
|> Enum.join(", ")
411-
end
412383
end

lib/nerves_hub_web/components/deployment_group_page/summary.ex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -303,14 +303,14 @@ defmodule NervesHubWeb.Components.DeploymentGroupPage.Summary do
303303
</div>
304304
<div class="flex gap-4 items-center">
305305
<span class="text-sm text-nerves-gray-500 w-36">Tag selection:</span>
306-
<span :if={Enum.empty?(@deployment_group.conditions["tags"])} class="text-sm text-nerves-gray-500">No tags configured</span>
307-
<span :if={Enum.any?(@deployment_group.conditions["tags"])} class="flex gap-1">
308-
<span :for={tag <- @deployment_group.conditions["tags"]} class="text-sm text-zinc-300 px-2 py-1 border border-zinc-800 bg-zinc-800 rounded">{tag}</span>
306+
<span :if={Enum.empty?(@deployment_group.conditions.tags || [])} class="text-sm text-nerves-gray-500">No tags configured</span>
307+
<span :if={Enum.any?(@deployment_group.conditions.tags || [])} class="flex gap-1">
308+
<span :for={tag <- @deployment_group.conditions.tags} class="text-sm text-zinc-300 px-2 py-1 border border-zinc-800 bg-zinc-800 rounded">{tag}</span>
309309
</span>
310310
</div>
311311
<div class="flex gap-4 items-center pb-2">
312312
<span class="text-sm text-nerves-gray-500 w-36">Version requirement:</span>
313-
<code class="text-sm text-zinc-300">{@deployment_group.conditions["version"]}</code>
313+
<code class="text-sm text-zinc-300">{@deployment_group.conditions.version}</code>
314314
</div>
315315
<div
316316
:if={@deployment_group.device_count > 0 || @unmatched_device_count > 0 || @matched_devices_outside_deployment_group_count > 0}

lib/nerves_hub_web/components/device_page/settings_tab.ex

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@ defmodule NervesHubWeb.Components.DevicePage.SettingsTab do
66

77
alias NervesHub.Certificate
88
alias NervesHub.Devices
9+
alias NervesHub.Devices.Device
910
alias NervesHub.Extensions
1011

1112
alias NervesHub.Repo
1213

1314
def tab_params(_params, _uri, socket) do
15+
changeset = Ecto.Changeset.change(socket.assigns.device)
16+
1417
socket
18+
|> assign(:settings_form, to_form(changeset))
1519
|> allow_upload(:certificate,
1620
accept: :any,
1721
auto_upload: true,
@@ -24,17 +28,11 @@ defmodule NervesHubWeb.Components.DevicePage.SettingsTab do
2428
def render(assigns) do
2529
device = Repo.preload(assigns.device, :device_certificates, force: true)
2630

27-
changeset = Ecto.Changeset.change(assigns.device)
28-
29-
assigns =
30-
assigns
31-
|> Map.put(:device, device)
32-
|> Map.put(:settings_form, to_form(changeset))
33-
|> Map.put(:available_extensions, extensions())
31+
assigns = Map.put(assigns, :device, device)
3432

3533
~H"""
3634
<div class="flex flex-col items-start justify-between gap-4 p-6">
37-
<.form for={@settings_form} class="w-full" phx-submit="update-device-settings">
35+
<.form id="settings-form" for={@settings_form} class="w-full" phx-change="validate-device-settings" phx-submit="update-device-settings">
3836
<div class="flex flex-col w-full bg-zinc-900 border border-zinc-700 rounded">
3937
<div class="flex justify-between items-center h-14 px-4 border-b border-zinc-700">
4038
<div class="text-base text-neutral-50 font-medium">General settings</div>
@@ -68,13 +66,13 @@ defmodule NervesHubWeb.Components.DevicePage.SettingsTab do
6866
</p>
6967
</div> --%>
7068
71-
<.input field={@settings_form[:description]} label="Description" placeholder="eg. sensor hub at customer X" />
69+
<.input field={@settings_form[:description]} label="Description" placeholder="eg. sensor hub at customer X" phx-debounce="blur" />
7270
73-
<.input field={@settings_form[:tags]} value={Utils.tags_to_string(@settings_form[:tags])} label="Tags" placeholder="eg. batch-123" />
71+
<.input field={@settings_form[:tags]} value={Utils.tags_to_string(@settings_form[:tags])} label="Tags" placeholder="eg. batch-123" phx-debounce="blur" />
7472
</div>
7573
7674
<div class="w-1/2 flex flex-col gap-2">
77-
<.input field={@settings_form[:connecting_code]} label="First connect code" type="textarea" rows="10" />
75+
<.input field={@settings_form[:connecting_code]} label="First connect code" type="textarea" rows="10" phx-debounce="2000" />
7876
<div class="text-xs tracking-wide text-zinc-400">Make sure this is valid Elixir and will not crash the device</div>
7977
</div>
8078
</div>
@@ -86,7 +84,7 @@ defmodule NervesHubWeb.Components.DevicePage.SettingsTab do
8684
<div class="text-base text-neutral-50 font-medium">Extensions</div>
8785
</div>
8886
<div class="py-2 px-4 flex flex-col gap-1">
89-
<div :for={{key, description} <- @available_extensions} class="flex items-center gap-6 h-16 p-2">
87+
<div :for={{key, description} <- available_extensions()} class="flex items-center gap-6 h-16 p-2">
9088
<div class="flex items-center h-8 py-1 px-2 bg-zinc-800 border border-zinc-700 rounded-full">
9189
<input
9290
id={"extension-#{key}"}
@@ -281,6 +279,14 @@ defmodule NervesHubWeb.Components.DevicePage.SettingsTab do
281279
"""
282280
end
283281

282+
def hooked_event("validate-device-settings", %{"device" => device_params}, socket) do
283+
changeset = Device.changeset(socket.assigns.device, device_params)
284+
285+
socket
286+
|> assign(:settings_form, to_form(changeset, action: :validate))
287+
|> halt()
288+
end
289+
284290
def hooked_event("update-device-settings", %{"device" => device_params}, socket) do
285291
authorized!(:"device:update", socket.assigns.org_user)
286292

@@ -457,7 +463,7 @@ defmodule NervesHubWeb.Components.DevicePage.SettingsTab do
457463
end
458464
end
459465

460-
defp extensions() do
466+
defp available_extensions() do
461467
for extension <- Extensions.list(),
462468
into: %{},
463469
do: {extension, Extensions.module(extension).description()}

0 commit comments

Comments
 (0)