diff --git a/assets/js/app.ts b/assets/js/app.ts index 75a37eca..bec8621e 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -32,6 +32,7 @@ import "./passkey_settings" import "./passkey_login" import "./passkey_discoverable" import type { SensorChartHook } from "./types/liveview" +import { DeviceListReorder } from "./device_list_reorder" // Helper function to convert range string to milliseconds function getTimeRangeMs(range: string): number { @@ -637,7 +638,7 @@ const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken, timezone: userTimezone }, - hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin, CopyToClipboard, ScrollToTop, AutoDismissFlash, NetworkMap }, + hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin, CopyToClipboard, ScrollToTop, AutoDismissFlash, NetworkMap, DeviceListReorder }, }) // Show progress bar on live navigation and form submits diff --git a/assets/js/device_list_reorder.ts b/assets/js/device_list_reorder.ts new file mode 100644 index 00000000..06761ea1 --- /dev/null +++ b/assets/js/device_list_reorder.ts @@ -0,0 +1,124 @@ +/** + * Minimal hook for HTML5 drag-and-drop events. + * Only captures events and pushes to LiveView - no business logic. + */ + +interface DragData { + type: "site" | "device" + id: string + siteId: string + position: number +} + +export const DeviceListReorder = { + draggedElement: null as HTMLElement | null, + dragData: null as DragData | null, + + mounted(this: any) { + // Capture drag start + this.el.addEventListener("dragstart", (e: DragEvent) => { + const target = (e.target as HTMLElement).closest("[data-site-id]") as HTMLElement + if (!target) return + + // Check if element is actually draggable + if (target.getAttribute("draggable") !== "true") return + + const siteId = target.dataset.siteId + const position = parseInt(target.dataset.sitePosition || target.dataset.devicePosition || "0") + + if (target.classList.contains("site-header")) { + this.draggedElement = target + this.dragData = { type: "site", id: siteId!, siteId: siteId!, position } + target.classList.add("opacity-50") + } else if (target.classList.contains("device-row")) { + const deviceId = target.dataset.deviceId + this.draggedElement = target + this.dragData = { type: "device", id: deviceId!, siteId: siteId!, position } + target.classList.add("opacity-50") + } + + e.dataTransfer!.effectAllowed = "move" + }) + + // Clean up on drag end + this.el.addEventListener("dragend", (_e: DragEvent) => { + if (this.draggedElement) { + this.draggedElement.classList.remove("opacity-50") + this.draggedElement = null + } + this.dragData = null + + // Remove drop indicators + this.el.querySelectorAll(".border-blue-500").forEach((el: Element) => { + el.classList.remove("border-blue-500", "border-t-4", "border-b-4") + }) + }) + + // Show drop indicator + this.el.addEventListener("dragover", (e: DragEvent) => { + e.preventDefault() + if (!this.dragData) return + + const target = (e.target as HTMLElement).closest("[data-site-id], [data-device-id]") as HTMLElement + if (!target || target === this.draggedElement) return + + // Check if in bottom half of element + const rect = target.getBoundingClientRect() + const middleY = rect.top + (rect.height / 2) + const isBottomHalf = e.clientY > middleY + + // Visual feedback - show border on top or bottom depending on drop position + this.el.querySelectorAll(".border-blue-500").forEach((el: Element) => { + el.classList.remove("border-blue-500", "border-t-4", "border-b-4") + }) + target.classList.add("border-blue-500", isBottomHalf ? "border-b-4" : "border-t-4") + }) + + // Handle drop + this.el.addEventListener("drop", (e: DragEvent) => { + e.preventDefault() + if (!this.dragData) return + + const target = (e.target as HTMLElement).closest("[data-site-id], [data-device-id]") as HTMLElement + if (!target || target === this.draggedElement) return + + const dropSiteId = target.dataset.siteId + let dropPosition = parseInt(target.dataset.sitePosition || target.dataset.devicePosition || "0") + + // Check if dropping in bottom half of element - if so, insert after instead of before + const rect = target.getBoundingClientRect() + const middleY = rect.top + (rect.height / 2) + if (e.clientY > middleY) { + dropPosition += 1 + } + + // Push to LiveView based on type + if (this.dragData.type === "site" && target.classList.contains("site-header")) { + this.pushEvent("reorder_site", { + site_id: this.dragData.id, + new_position: dropPosition + }) + } else if (this.dragData.type === "device" && target.classList.contains("device-row")) { + // Only allow within same site + if (dropSiteId === this.dragData.siteId) { + this.pushEvent("reorder_device", { + device_id: this.dragData.id, + new_position: dropPosition + }) + } + } + + // Clean up + this.el.querySelectorAll(".border-blue-500").forEach((el: Element) => { + el.classList.remove("border-blue-500", "border-t-4", "border-b-4") + }) + }) + + // Prevent drag handle from triggering row click + this.el.querySelectorAll(".drag-handle").forEach((handle: Element) => { + handle.addEventListener("click", (e: Event) => { + e.stopPropagation() + }) + }) + } +} diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index c07a1ff6..bf550e93 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -13,13 +13,20 @@ defmodule Towerops.Devices do @doc """ Returns the list of devices for a site. + Ordered by custom display_order (if set), then alphabetically by name. """ def list_site_devices(site_id) do - Repo.all(from(e in DeviceSchema, where: e.site_id == ^site_id, order_by: [asc: e.name])) + Repo.all( + from(e in DeviceSchema, + where: e.site_id == ^site_id, + order_by: [asc: e.display_order, asc: e.name] + ) + ) end @doc """ Returns the list of all devices for an organization (via sites). + Ordered by custom display_order (if set), then alphabetically by name. Supports filtering by: - site_id: Filter by specific site @@ -30,7 +37,7 @@ defmodule Towerops.Devices do from(e in DeviceSchema, join: s in assoc(e, :site), where: s.organization_id == ^organization_id, - order_by: [asc: e.name], + order_by: [asc: e.display_order, asc: e.name], preload: [site: s] ) @@ -400,4 +407,57 @@ defmodule Towerops.Devices do ) ) end + + ## Display Order Management + + @doc """ + Reorders a device to a new position within its site. + + Updates display_order for all devices in the site to maintain continuous ordering (1, 2, 3, ...). + The new_position is 1-based (1 = first position). + + Returns {:ok, device} on success, {:error, changeset} on failure. + """ + def reorder_device(device_id, new_position) when is_integer(new_position) and new_position > 0 do + Repo.transaction(fn -> + device = Repo.get!(DeviceSchema, device_id) + site_id = device.site_id + + # Get all devices in site excluding the one being moved, in current order + other_devices = + Repo.all( + from(d in DeviceSchema, + where: d.site_id == ^site_id and d.id != ^device_id, + order_by: [asc: d.display_order, asc: d.name] + ) + ) + + # Insert at new position (1-based index, convert to 0-based for List.insert_at) + new_order = List.insert_at(other_devices, new_position - 1, device) + + # Update display_order for all devices + new_order + |> Enum.with_index(1) + |> Enum.each(fn {d, order} -> + Repo.update!(Ecto.Changeset.change(d, display_order: order)) + end) + + Repo.get!(DeviceSchema, device_id) + end) + end + + @doc """ + Reset all devices in organization to alphabetical order. + + Clears display_order field for all devices, allowing them to fall back to alphabetical sorting. + """ + def reset_organization_device_order(organization_id) do + Repo.update_all( + from(d in DeviceSchema, + join: s in assoc(d, :site), + where: s.organization_id == ^organization_id + ), + set: [display_order: nil, updated_at: DateTime.truncate(DateTime.utc_now(), :second)] + ) + end end diff --git a/lib/towerops/devices/device.ex b/lib/towerops/devices/device.ex index 30613a7b..81eca932 100644 --- a/lib/towerops/devices/device.ex +++ b/lib/towerops/devices/device.ex @@ -23,6 +23,7 @@ defmodule Towerops.Devices.Device do field :name, :string field :ip_address, :string field :description, :string + field :display_order, :integer field :status, Ecto.Enum, values: [:up, :down, :unknown], default: :unknown field :last_checked_at, :utc_datetime field :last_status_change_at, :utc_datetime @@ -50,6 +51,7 @@ defmodule Towerops.Devices.Device do name: String.t() | nil, ip_address: String.t(), description: String.t() | nil, + display_order: integer() | nil, status: :up | :down | :unknown, last_checked_at: DateTime.t() | nil, last_status_change_at: DateTime.t() | nil, @@ -76,6 +78,7 @@ defmodule Towerops.Devices.Device do :name, :ip_address, :description, + :display_order, :site_id, :monitoring_enabled, :check_interval_seconds, diff --git a/lib/towerops/sites.ex b/lib/towerops/sites.ex index 6c5de14f..2ceadfb0 100644 --- a/lib/towerops/sites.ex +++ b/lib/towerops/sites.ex @@ -12,10 +12,15 @@ defmodule Towerops.Sites do @doc """ Returns the list of sites for an organization. + Ordered by custom display_order (if set), then alphabetically by name. """ def list_organization_sites(organization_id) do Repo.all( - from(s in Site, where: s.organization_id == ^organization_id, order_by: [asc: s.name], preload: [:parent_site]) + from(s in Site, + where: s.organization_id == ^organization_id, + order_by: [asc: s.display_order, asc: s.name], + preload: [:parent_site] + ) ) end @@ -31,22 +36,29 @@ defmodule Towerops.Sites do @doc """ Returns the list of root sites (sites without a parent) for an organization. + Ordered by custom display_order (if set), then alphabetically by name. """ def list_root_sites(organization_id) do Repo.all( from(s in Site, where: s.organization_id == ^organization_id, where: is_nil(s.parent_site_id), - order_by: [asc: s.name] + order_by: [asc: s.display_order, asc: s.name] ) ) end @doc """ Returns the list of child sites for a parent site. + Ordered by custom display_order (if set), then alphabetically by name. """ def list_child_sites(parent_site_id) do - Repo.all(from(s in Site, where: s.parent_site_id == ^parent_site_id, order_by: [asc: s.name])) + Repo.all( + from(s in Site, + where: s.parent_site_id == ^parent_site_id, + order_by: [asc: s.display_order, asc: s.name] + ) + ) end @doc """ @@ -184,4 +196,54 @@ defmodule Towerops.Sites do Repo.delete_all(from(a in AgentAssignment, where: a.device_id in ^device_ids)) end end + + ## Display Order Management + + @doc """ + Reorders a site to a new position within the organization. + + Updates display_order for all sites to maintain continuous ordering (1, 2, 3, ...). + The new_position is 1-based (1 = first position). + + Returns {:ok, site} on success, {:error, changeset} on failure. + """ + def reorder_site(site_id, new_position) when is_integer(new_position) and new_position > 0 do + Repo.transaction(fn -> + site = Repo.get!(Site, site_id) + organization_id = site.organization_id + + # Get all sites excluding the one being moved, in current order + other_sites = + Repo.all( + from(s in Site, + where: s.organization_id == ^organization_id and s.id != ^site_id, + order_by: [asc: s.display_order, asc: s.name] + ) + ) + + # Insert at new position (1-based index, convert to 0-based for List.insert_at) + new_order = List.insert_at(other_sites, new_position - 1, site) + + # Update display_order for all sites + new_order + |> Enum.with_index(1) + |> Enum.each(fn {s, order} -> + Repo.update!(Ecto.Changeset.change(s, display_order: order)) + end) + + Repo.get!(Site, site_id) + end) + end + + @doc """ + Reset all sites in organization to alphabetical order. + + Clears display_order field for all sites, allowing them to fall back to alphabetical sorting. + """ + def reset_site_order(organization_id) do + Repo.update_all( + from(s in Site, where: s.organization_id == ^organization_id), + set: [display_order: nil, updated_at: DateTime.truncate(DateTime.utc_now(), :second)] + ) + end end diff --git a/lib/towerops/sites/site.ex b/lib/towerops/sites/site.ex index 4b1b74df..01bc2e50 100644 --- a/lib/towerops/sites/site.ex +++ b/lib/towerops/sites/site.ex @@ -20,6 +20,7 @@ defmodule Towerops.Sites.Site do field :name, :string field :description, :string field :location, :string + field :display_order, :integer # SNMP configuration (overrides organization default) field :snmp_version, :string @@ -39,6 +40,7 @@ defmodule Towerops.Sites.Site do name: String.t(), description: String.t() | nil, location: String.t() | nil, + display_order: integer() | nil, snmp_version: String.t() | nil, snmp_community: String.t() | nil, organization_id: Ecto.UUID.t(), @@ -60,6 +62,7 @@ defmodule Towerops.Sites.Site do :name, :description, :location, + :display_order, :organization_id, :agent_token_id, :parent_site_id, diff --git a/lib/towerops_web/live/device_live/index.ex b/lib/towerops_web/live/device_live/index.ex index cd8c0db2..2afe2137 100644 --- a/lib/towerops_web/live/device_live/index.ex +++ b/lib/towerops_web/live/device_live/index.ex @@ -19,7 +19,8 @@ defmodule ToweropsWeb.DeviceLive.Index do |> assign(:page_title, "Devices") |> assign(:device, device) |> assign(:grouped_devices, grouped_devices) - |> assign(:has_sites, sites != [])} + |> assign(:has_sites, sites != []) + |> assign(:reorder_mode, false)} end @impl true @@ -45,6 +46,73 @@ defmodule ToweropsWeb.DeviceLive.Index do end end + @impl true + def handle_event("reorder_site", %{"site_id" => site_id, "new_position" => new_position}, socket) do + # Handle both integer and string input + position = if is_integer(new_position), do: new_position, else: String.to_integer(new_position) + + case Sites.reorder_site(site_id, position) do + {:ok, _site} -> + # Reload devices with updated order + devices = Devices.list_organization_devices(socket.assigns.current_organization.id) + grouped_devices = group_devices_by_site(devices) + + {:noreply, + socket + |> assign(:device, devices) + |> assign(:grouped_devices, grouped_devices) + |> put_flash(:info, "Site order updated")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Failed to reorder site")} + end + end + + @impl true + def handle_event("reorder_device", %{"device_id" => device_id, "new_position" => new_position}, socket) do + # Handle both integer and string input + position = if is_integer(new_position), do: new_position, else: String.to_integer(new_position) + + case Devices.reorder_device(device_id, position) do + {:ok, _device} -> + # Reload devices with updated order + devices = Devices.list_organization_devices(socket.assigns.current_organization.id) + grouped_devices = group_devices_by_site(devices) + + {:noreply, + socket + |> assign(:device, devices) + |> assign(:grouped_devices, grouped_devices) + |> put_flash(:info, "Device order updated")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Failed to reorder device")} + end + end + + @impl true + def handle_event("toggle_reorder_mode", _params, socket) do + {:noreply, assign(socket, :reorder_mode, !socket.assigns.reorder_mode)} + end + + @impl true + def handle_event("reset_order", _params, socket) do + organization_id = socket.assigns.current_organization.id + + Sites.reset_site_order(organization_id) + Devices.reset_organization_device_order(organization_id) + + # Reload devices with alphabetical order + devices = Devices.list_organization_devices(organization_id) + grouped_devices = group_devices_by_site(devices) + + {:noreply, + socket + |> assign(:device, devices) + |> assign(:grouped_devices, grouped_devices) + |> put_flash(:info, "Order reset to alphabetical")} + end + def handle_event("add_discovered_device", params, socket) do identifier = Jason.decode!(params["identifier"]) discovered = Enum.find(socket.assigns.discovered_devices, &(&1.identifier == identifier)) @@ -67,13 +135,19 @@ defmodule ToweropsWeb.DeviceLive.Index do end # Group devices by site with statistics + # Sites and devices are sorted by display_order (with alphabetical fallback) defp group_devices_by_site(devices) do devices |> Enum.group_by(& &1.site) |> Enum.map(fn {site, site_devices} -> {site, site_devices, calculate_site_stats(site_devices)} end) - |> Enum.sort_by(fn {site, _devices, _stats} -> site.name end, :asc) + |> Enum.sort_by( + fn {site, _devices, _stats} -> + {site.display_order || 999_999, site.name} + end, + :asc + ) end defp calculate_site_stats(devices) do diff --git a/lib/towerops_web/live/device_live/index.html.heex b/lib/towerops_web/live/device_live/index.html.heex index d6d37cb9..d3826177 100644 --- a/lib/towerops_web/live/device_live/index.html.heex +++ b/lib/towerops_web/live/device_live/index.html.heex @@ -10,13 +10,36 @@ <:subtitle>Monitor and manage all your network devices <:actions> <%= if @device != [] do %> - <.button - type="button" - phx-click="force_rediscover_all" - data-confirm="This will trigger SNMP discovery for all SNMP-enabled devices. Continue?" - > - <.icon name="hero-magnifying-glass" class="h-4 w-4" /> Force Rediscover All - + <%= if @reorder_mode do %> + <.button + type="button" + phx-click="reset_order" + data-confirm="Reset all sites and devices to alphabetical order?" + > + <.icon name="hero-arrow-path" class="h-4 w-4" /> Reset Order + + <.button + type="button" + phx-click="toggle_reorder_mode" + variant="primary" + > + <.icon name="hero-check" class="h-4 w-4" /> Done + + <% else %> + <.button + type="button" + phx-click="toggle_reorder_mode" + > + <.icon name="hero-bars-3" class="h-4 w-4" /> Reorder + + <.button + type="button" + phx-click="force_rediscover_all" + data-confirm="This will trigger SNMP discovery for all SNMP-enabled devices. Continue?" + > + <.icon name="hero-magnifying-glass" class="h-4 w-4" /> Force Rediscover All + + <% end %> <% end %> <.button :if={@has_sites} @@ -66,12 +89,23 @@
- +
+ @@ -97,15 +131,35 @@ <%= for {{site, devices, stats}, index} <- Enum.with_index(@grouped_devices) do %> - + + <%= for {device, device_index} <- Enum.with_index(devices) do %> - + - - - diff --git a/priv/repo/migrations/20260126155907_add_display_order_to_sites.exs b/priv/repo/migrations/20260126155907_add_display_order_to_sites.exs new file mode 100644 index 00000000..b247d703 --- /dev/null +++ b/priv/repo/migrations/20260126155907_add_display_order_to_sites.exs @@ -0,0 +1,12 @@ +defmodule Towerops.Repo.Migrations.AddDisplayOrderToSites do + use Ecto.Migration + + def change do + alter table(:sites) do + add :display_order, :integer + end + + # Composite index for efficient sorting (organization + display_order + name) + create index(:sites, [:organization_id, :display_order, :name]) + end +end diff --git a/priv/repo/migrations/20260126155937_add_display_order_to_devices.exs b/priv/repo/migrations/20260126155937_add_display_order_to_devices.exs new file mode 100644 index 00000000..d1e3d42a --- /dev/null +++ b/priv/repo/migrations/20260126155937_add_display_order_to_devices.exs @@ -0,0 +1,12 @@ +defmodule Towerops.Repo.Migrations.AddDisplayOrderToDevices do + use Ecto.Migration + + def change do + alter table(:devices) do + add :display_order, :integer + end + + # Composite index for efficient sorting within sites (site + display_order + name) + create index(:devices, [:site_id, :display_order, :name]) + end +end diff --git a/test/towerops/devices_test.exs b/test/towerops/devices_test.exs index fc97a54d..fd7f2a02 100644 --- a/test/towerops/devices_test.exs +++ b/test/towerops/devices_test.exs @@ -234,6 +234,134 @@ defmodule Towerops.EquipmentTest do # Empty strings are stored as nil in the database assert device.snmp_community == nil || device.snmp_community == "" end + + test "reorder_device/2 reorders device to first position", %{site: site} do + {:ok, device1} = Devices.create_device(%{name: "Device 1", ip_address: "192.168.1.1", site_id: site.id}) + {:ok, device2} = Devices.create_device(%{name: "Device 2", ip_address: "192.168.1.2", site_id: site.id}) + {:ok, device3} = Devices.create_device(%{name: "Device 3", ip_address: "192.168.1.3", site_id: site.id}) + + # Move device3 to first position + {:ok, _updated} = Devices.reorder_device(device3.id, 1) + + # Verify order + devices = Devices.list_site_devices(site.id) + assert Enum.at(devices, 0).id == device3.id + assert Enum.at(devices, 0).display_order == 1 + + assert Enum.at(devices, 1).id == device1.id + assert Enum.at(devices, 1).display_order == 2 + + assert Enum.at(devices, 2).id == device2.id + assert Enum.at(devices, 2).display_order == 3 + end + + test "reorder_device/2 reorders device to last position", %{site: site} do + {:ok, device1} = Devices.create_device(%{name: "Device 1", ip_address: "192.168.1.1", site_id: site.id}) + {:ok, device2} = Devices.create_device(%{name: "Device 2", ip_address: "192.168.1.2", site_id: site.id}) + {:ok, device3} = Devices.create_device(%{name: "Device 3", ip_address: "192.168.1.3", site_id: site.id}) + + # Move device1 to last position + {:ok, _updated} = Devices.reorder_device(device1.id, 3) + + # Verify order + devices = Devices.list_site_devices(site.id) + assert Enum.at(devices, 0).id == device2.id + assert Enum.at(devices, 0).display_order == 1 + + assert Enum.at(devices, 1).id == device3.id + assert Enum.at(devices, 1).display_order == 2 + + assert Enum.at(devices, 2).id == device1.id + assert Enum.at(devices, 2).display_order == 3 + end + + test "reorder_device/2 reorders device to middle position", %{site: site} do + {:ok, device1} = Devices.create_device(%{name: "Device 1", ip_address: "192.168.1.1", site_id: site.id}) + {:ok, device2} = Devices.create_device(%{name: "Device 2", ip_address: "192.168.1.2", site_id: site.id}) + {:ok, device3} = Devices.create_device(%{name: "Device 3", ip_address: "192.168.1.3", site_id: site.id}) + + # Move device3 to middle position + {:ok, _updated} = Devices.reorder_device(device3.id, 2) + + # Verify order + devices = Devices.list_site_devices(site.id) + assert Enum.at(devices, 0).id == device1.id + assert Enum.at(devices, 0).display_order == 1 + + assert Enum.at(devices, 1).id == device3.id + assert Enum.at(devices, 1).display_order == 2 + + assert Enum.at(devices, 2).id == device2.id + assert Enum.at(devices, 2).display_order == 3 + end + + test "reorder_device/2 maintains continuous numbering", %{site: site} do + {:ok, _device1} = Devices.create_device(%{name: "Device 1", ip_address: "192.168.1.1", site_id: site.id}) + {:ok, device2} = Devices.create_device(%{name: "Device 2", ip_address: "192.168.1.2", site_id: site.id}) + {:ok, _device3} = Devices.create_device(%{name: "Device 3", ip_address: "192.168.1.3", site_id: site.id}) + {:ok, device4} = Devices.create_device(%{name: "Device 4", ip_address: "192.168.1.4", site_id: site.id}) + + # Reorder multiple times + {:ok, _} = Devices.reorder_device(device2.id, 1) + {:ok, _} = Devices.reorder_device(device4.id, 2) + + # Verify all have continuous display_order values + devices = Devices.list_site_devices(site.id) + display_orders = Enum.map(devices, & &1.display_order) + assert display_orders == [1, 2, 3, 4] + end + + test "reorder_device/2 only affects devices in same site", %{organization: organization, site: site} do + # Create devices in first site + {:ok, device1} = Devices.create_device(%{name: "Device 1", ip_address: "192.168.1.1", site_id: site.id}) + {:ok, device2} = Devices.create_device(%{name: "Device 2", ip_address: "192.168.1.2", site_id: site.id}) + + # Create second site and device + {:ok, site2} = Towerops.Sites.create_site(%{name: "Site 2", organization_id: organization.id}) + {:ok, device3} = Devices.create_device(%{name: "Device 3", ip_address: "192.168.2.1", site_id: site2.id}) + + # Reorder device in first site + {:ok, _} = Devices.reorder_device(device1.id, 2) + + # Verify first site is reordered + site1_devices = Devices.list_site_devices(site.id) + assert Enum.at(site1_devices, 0).id == device2.id + assert Enum.at(site1_devices, 1).id == device1.id + + # Verify second site is unchanged (no display_order set) + site2_devices = Devices.list_site_devices(site2.id) + assert length(site2_devices) == 1 + assert hd(site2_devices).id == device3.id + assert is_nil(hd(site2_devices).display_order) + end + + test "reset_organization_device_order/1 clears all display_order values", %{ + organization: organization, + site: site + } do + {:ok, device1} = Devices.create_device(%{name: "Zebra Device", ip_address: "192.168.1.1", site_id: site.id}) + {:ok, device2} = Devices.create_device(%{name: "Alpha Device", ip_address: "192.168.1.2", site_id: site.id}) + + # Set custom order + {:ok, _} = Devices.reorder_device(device1.id, 1) + {:ok, _} = Devices.reorder_device(device2.id, 2) + + # Verify custom order is set + devices_before = Devices.list_site_devices(site.id) + assert List.first(devices_before).id == device1.id + assert List.first(devices_before).display_order == 1 + + # Reset order + {count, _} = Devices.reset_organization_device_order(organization.id) + assert count == 2 + + # Verify display_order is cleared and falls back to alphabetical + devices_after = Devices.list_site_devices(site.id) + assert List.first(devices_after).name == "Alpha Device" + assert List.first(devices_after).display_order == nil + assert List.last(devices_after).name == "Zebra Device" + assert List.last(devices_after).display_order == nil + end end describe "SNMP configuration inheritance" do diff --git a/test/towerops/sites_test.exs b/test/towerops/sites_test.exs index 5fb6887d..1d9f7f2a 100644 --- a/test/towerops/sites_test.exs +++ b/test/towerops/sites_test.exs @@ -333,5 +333,106 @@ defmodule Towerops.SitesTest do # Verify assignment was deleted assert is_nil(Towerops.Agents.get_device_assignment(device.id)) end + + test "reorder_site/2 reorders site to first position", %{organization: organization} do + {:ok, site1} = Sites.create_site(%{name: "Site 1", organization_id: organization.id}) + {:ok, site2} = Sites.create_site(%{name: "Site 2", organization_id: organization.id}) + {:ok, site3} = Sites.create_site(%{name: "Site 3", organization_id: organization.id}) + + # Move site3 to first position + {:ok, _updated} = Sites.reorder_site(site3.id, 1) + + # Verify order + sites = Sites.list_organization_sites(organization.id) + assert Enum.at(sites, 0).id == site3.id + assert Enum.at(sites, 0).display_order == 1 + + assert Enum.at(sites, 1).id == site1.id + assert Enum.at(sites, 1).display_order == 2 + + assert Enum.at(sites, 2).id == site2.id + assert Enum.at(sites, 2).display_order == 3 + end + + test "reorder_site/2 reorders site to last position", %{organization: organization} do + {:ok, site1} = Sites.create_site(%{name: "Site 1", organization_id: organization.id}) + {:ok, site2} = Sites.create_site(%{name: "Site 2", organization_id: organization.id}) + {:ok, site3} = Sites.create_site(%{name: "Site 3", organization_id: organization.id}) + + # Move site1 to last position + {:ok, _updated} = Sites.reorder_site(site1.id, 3) + + # Verify order + sites = Sites.list_organization_sites(organization.id) + assert Enum.at(sites, 0).id == site2.id + assert Enum.at(sites, 0).display_order == 1 + + assert Enum.at(sites, 1).id == site3.id + assert Enum.at(sites, 1).display_order == 2 + + assert Enum.at(sites, 2).id == site1.id + assert Enum.at(sites, 2).display_order == 3 + end + + test "reorder_site/2 reorders site to middle position", %{organization: organization} do + {:ok, site1} = Sites.create_site(%{name: "Site 1", organization_id: organization.id}) + {:ok, site2} = Sites.create_site(%{name: "Site 2", organization_id: organization.id}) + {:ok, site3} = Sites.create_site(%{name: "Site 3", organization_id: organization.id}) + + # Move site3 to middle position + {:ok, _updated} = Sites.reorder_site(site3.id, 2) + + # Verify order + sites = Sites.list_organization_sites(organization.id) + assert Enum.at(sites, 0).id == site1.id + assert Enum.at(sites, 0).display_order == 1 + + assert Enum.at(sites, 1).id == site3.id + assert Enum.at(sites, 1).display_order == 2 + + assert Enum.at(sites, 2).id == site2.id + assert Enum.at(sites, 2).display_order == 3 + end + + test "reorder_site/2 maintains continuous numbering", %{organization: organization} do + {:ok, _site1} = Sites.create_site(%{name: "Site 1", organization_id: organization.id}) + {:ok, site2} = Sites.create_site(%{name: "Site 2", organization_id: organization.id}) + {:ok, _site3} = Sites.create_site(%{name: "Site 3", organization_id: organization.id}) + {:ok, site4} = Sites.create_site(%{name: "Site 4", organization_id: organization.id}) + + # Reorder multiple times + {:ok, _} = Sites.reorder_site(site2.id, 1) + {:ok, _} = Sites.reorder_site(site4.id, 2) + + # Verify all have continuous display_order values + sites = Sites.list_organization_sites(organization.id) + display_orders = Enum.map(sites, & &1.display_order) + assert display_orders == [1, 2, 3, 4] + end + + test "reset_site_order/1 clears all display_order values", %{organization: organization} do + {:ok, site1} = Sites.create_site(%{name: "Zebra Site", organization_id: organization.id}) + {:ok, site2} = Sites.create_site(%{name: "Alpha Site", organization_id: organization.id}) + + # Set custom order + {:ok, _} = Sites.reorder_site(site1.id, 1) + {:ok, _} = Sites.reorder_site(site2.id, 2) + + # Verify custom order is set + sites_before = Sites.list_organization_sites(organization.id) + assert List.first(sites_before).id == site1.id + assert List.first(sites_before).display_order == 1 + + # Reset order + {count, _} = Sites.reset_site_order(organization.id) + assert count == 2 + + # Verify display_order is cleared and falls back to alphabetical + sites_after = Sites.list_organization_sites(organization.id) + assert List.first(sites_after).name == "Alpha Site" + assert List.first(sites_after).display_order == nil + assert List.last(sites_after).name == "Zebra Site" + assert List.last(sites_after).display_order == nil + end end end
+ Drag handle + Name
+ +
@@ -140,22 +194,47 @@
+ + + {device.name} + {device.ip_address} + to_string() |> String.upcase()} + <.timestamp datetime={device.last_checked_at} timezone={@timezone} />