Three deferred followups from the chunk-4 plan, all together: A. Restrictions editor - Resolver: time_of_week support (weekday set + time-of-day window), in addition to the existing time_of_day. - New OnCall.update_layer_restrictions/2 + clear_layer_restrictions/1. - schedule_live/show: per-layer Restrict on-call shifts form (Always on call / Time of day / Time of week with Mon-Sun checkboxes), changes patched live and persisted via the resolver. B. Drag-and-drop reorder - New OnCall.reorder_layers/2 + reorder_escalation_rules/2 (atomic transaction; validates id count + membership). - assets/js/sortable_list.ts: minimal HTML5 drag-and-drop hook (no external dep; mirrors the existing DeviceListReorder pattern), registered as SortableList in app.ts. - schedule_live/show + escalation_policy_live/show: layer/level cards now carry data-id + draggable + a visible drag handle. Up/down arrow buttons remain as a fallback for users on assistive tech. C. Unified new-schedule UX - schedule_live/show pre-opens the Add Layer form when the schedule has zero layers, so the new-schedule -> add-layers flow lands ready to act on. Two existing tests updated to seed a layer first; two new tests verify the auto-open behaviour both ways. Full suite 10,231 / 0 failures. Format/dialyzer clean.
114 lines
4.2 KiB
TypeScript
114 lines
4.2 KiB
TypeScript
/**
|
|
* SortableList — minimal HTML5 drag-and-drop hook for reorderable lists.
|
|
*
|
|
* Markup contract:
|
|
* <div phx-hook="SortableList" id="..." data-event="reorder_layers" data-group-key="schedule_id">
|
|
* <div data-id="<uuid>" draggable="true">...</div>
|
|
* <div data-id="<uuid>" draggable="true">...</div>
|
|
* </div>
|
|
*
|
|
* On drop, the hook pushes one event named by `data-event` with payload
|
|
* `{ ordered_ids: [...] }` carrying the new order of every direct child
|
|
* with a `data-id` attribute.
|
|
*
|
|
* No external dependency — uses native HTML5 drag-and-drop. Mirrors the
|
|
* existing DeviceListReorder hook in style.
|
|
*/
|
|
|
|
export const SortableList = {
|
|
draggedEl: null as HTMLElement | null,
|
|
handleDragStart: null as ((e: DragEvent) => void) | null,
|
|
handleDragEnd: null as ((e: DragEvent) => void) | null,
|
|
handleDragOver: null as ((e: DragEvent) => void) | null,
|
|
handleDrop: null as ((e: DragEvent) => void) | null,
|
|
|
|
mounted(this: any) {
|
|
const container: HTMLElement = this.el
|
|
const eventName: string = container.dataset.event || "reorder"
|
|
|
|
const itemFromTarget = (t: EventTarget | null): HTMLElement | null => {
|
|
const el = t as HTMLElement | null
|
|
return el ? (el.closest<HTMLElement>("[data-id]") || null) : null
|
|
}
|
|
|
|
const itemIsDirectChild = (item: HTMLElement | null): item is HTMLElement => {
|
|
return !!item && item.parentElement === container
|
|
}
|
|
|
|
this.handleDragStart = (e: DragEvent) => {
|
|
const item = itemFromTarget(e.target)
|
|
if (!itemIsDirectChild(item) || item.getAttribute("draggable") !== "true") return
|
|
this.draggedEl = item
|
|
item.classList.add("opacity-50")
|
|
e.dataTransfer!.effectAllowed = "move"
|
|
}
|
|
|
|
this.handleDragEnd = (_e: DragEvent) => {
|
|
if (this.draggedEl) {
|
|
this.draggedEl.classList.remove("opacity-50")
|
|
this.draggedEl = null
|
|
}
|
|
container.querySelectorAll(".sortable-drop-target").forEach((el: Element) => {
|
|
el.classList.remove("sortable-drop-target", "border-t-4", "border-b-4", "border-blue-500")
|
|
})
|
|
}
|
|
|
|
this.handleDragOver = (e: DragEvent) => {
|
|
e.preventDefault()
|
|
if (!this.draggedEl) return
|
|
|
|
const target = itemFromTarget(e.target)
|
|
if (!itemIsDirectChild(target) || target === this.draggedEl) return
|
|
|
|
const rect = target.getBoundingClientRect()
|
|
const isBottomHalf = e.clientY > rect.top + rect.height / 2
|
|
|
|
container.querySelectorAll(".sortable-drop-target").forEach((el: Element) => {
|
|
el.classList.remove("sortable-drop-target", "border-t-4", "border-b-4", "border-blue-500")
|
|
})
|
|
target.classList.add(
|
|
"sortable-drop-target",
|
|
"border-blue-500",
|
|
isBottomHalf ? "border-b-4" : "border-t-4"
|
|
)
|
|
}
|
|
|
|
this.handleDrop = (e: DragEvent) => {
|
|
e.preventDefault()
|
|
if (!this.draggedEl) return
|
|
|
|
const target = itemFromTarget(e.target)
|
|
if (!itemIsDirectChild(target) || target === this.draggedEl) return
|
|
|
|
const rect = target.getBoundingClientRect()
|
|
const isBottomHalf = e.clientY > rect.top + rect.height / 2
|
|
|
|
// Reorder DOM optimistically so the user sees the result
|
|
// immediately; the server-side patch will reconcile.
|
|
if (isBottomHalf) {
|
|
container.insertBefore(this.draggedEl, target.nextSibling)
|
|
} else {
|
|
container.insertBefore(this.draggedEl, target)
|
|
}
|
|
|
|
const orderedIds = Array.from(
|
|
container.querySelectorAll<HTMLElement>(":scope > [data-id]")
|
|
).map(el => el.dataset.id!)
|
|
|
|
this.pushEvent(eventName, { ordered_ids: orderedIds })
|
|
}
|
|
|
|
container.addEventListener("dragstart", this.handleDragStart)
|
|
container.addEventListener("dragend", this.handleDragEnd)
|
|
container.addEventListener("dragover", this.handleDragOver)
|
|
container.addEventListener("drop", this.handleDrop)
|
|
},
|
|
|
|
destroyed(this: any) {
|
|
if (this.handleDragStart) this.el.removeEventListener("dragstart", this.handleDragStart)
|
|
if (this.handleDragEnd) this.el.removeEventListener("dragend", this.handleDragEnd)
|
|
if (this.handleDragOver) this.el.removeEventListener("dragover", this.handleDragOver)
|
|
if (this.handleDrop) this.el.removeEventListener("drop", this.handleDrop)
|
|
this.draggedEl = null
|
|
}
|
|
}
|