From 32e82459bf9a1d880091aaf3b9a0da424ab20fac Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Thu, 10 Sep 2026 22:25:21 +0000 Subject: [PATCH] Add confirmed AJAX location reparenting and relationship modals --- PlotLine.Tests/Browser/README.md | 5 + PlotLine.Tests/Browser/location-reparent.cjs | 106 ++++++++++++ PlotLine.Tests/Browser/package.json | 2 +- PlotLine.Tests/Program.cs | 4 +- PlotLine.Tests/Sql/LocationReparent.sql | 160 ++++++++++++++++++ PlotLine/Controllers/LocationsController.cs | 44 ++++- PlotLine/Data/Repositories.cs | 17 ++ PlotLine/Services/CoreServices.cs | 4 + PlotLine/Sql/195_LocationReparent.sql | 53 ++++++ PlotLine/Views/Locations/Index.cshtml | 59 ++++--- PlotLine/Views/Locations/_LocationTree.cshtml | 24 +++ PlotLine/wwwroot/js/site.js | 130 +++++++++++++- 12 files changed, 573 insertions(+), 35 deletions(-) create mode 100644 PlotLine.Tests/Browser/location-reparent.cjs create mode 100644 PlotLine.Tests/Sql/LocationReparent.sql create mode 100644 PlotLine/Sql/195_LocationReparent.sql create mode 100644 PlotLine/Views/Locations/_LocationTree.cshtml diff --git a/PlotLine.Tests/Browser/README.md b/PlotLine.Tests/Browser/README.md index 816a6a7..55ec45e 100644 --- a/PlotLine.Tests/Browser/README.md +++ b/PlotLine.Tests/Browser/README.md @@ -15,3 +15,8 @@ Coverage includes all four timeline views and three densities, one and three boo The chart derives coordinates from rendered scene rectangles. A sticky, viewport-sized canvas lives inside the full-width frame in the existing timeline scroll container. The custom Chart.js linear scale uses CSS pixel positions directly, including axis and frame padding, without scaling scene positions to the chart area. ResizeObserver and the timeline layout event invalidate measurements; scroll events only update the visible chart window. When scene cards are collapsed after measurement, the last measured geometry remains available. If scene cards are disabled or initially collapsed, the chart retains the standalone ordinal layout until rendered scene geometry becomes available. Analytics and character charts retain their standalone layouts. Raw metric rows remain on their existing CSS grid; they are not part of this chart fix. + +## Location drag-and-drop regressions + +Run `npm run test:locations` (with `CHROMIUM_PATH` if using an existing Chromium). +This loads the full production site script and Bootstrap confirmation modal in an isolated location-tree fixture. AJAX responses are mocked. It checks parent assignment/reassignment, cancellation, cycle/no-op rejection, repeated dragging after tree replacement, scroll preservation, server-error display, and relationship modal/AJAX saves. Database behavior is covered separately by `PlotLine.Tests/Sql/LocationReparent.sql`, whose fixtures always roll back. diff --git a/PlotLine.Tests/Browser/location-reparent.cjs b/PlotLine.Tests/Browser/location-reparent.cjs new file mode 100644 index 0000000..b8a967b --- /dev/null +++ b/PlotLine.Tests/Browser/location-reparent.cjs @@ -0,0 +1,106 @@ +const { chromium } = require('playwright'); +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const repo = path.resolve(__dirname, '../..'); +(async () => { + const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH }); + try { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.route('https://plotdirector.test/**', route => route.fulfill({ contentType: 'text/html', body: '' })); + await page.goto('https://plotdirector.test/Locations'); + const layout = fs.readFileSync(path.join(repo, 'PlotLine/Views/Shared/_Layout.cshtml'), 'utf8'); + const modalStart = layout.indexOf(' -
+ @if (TempData["LocationMessage"] is string message) + { +
@message
+ } + @if (TempData["LocationError"] is string error) + { + + } +
+ + +

Drop onto the new parent, then confirm the move. Child locations move with it. You can also change the parent using Edit.

+ +

+
+
+ + + + +
+ +
+ + @if (!Model.Locations.Any()) { @@ -62,28 +93,6 @@ } else { -
- @foreach (var location in Model.Locations) - { -
-
- @location.LocationName - @if (!string.IsNullOrWhiteSpace(location.LocationTypeName)) - { - @location.LocationTypeName - } - @location.LocationPath -
-
- Edit -
- - - -
-
-
- } -
+ } diff --git a/PlotLine/Views/Locations/_LocationTree.cshtml b/PlotLine/Views/Locations/_LocationTree.cshtml new file mode 100644 index 0000000..0c86122 --- /dev/null +++ b/PlotLine/Views/Locations/_LocationTree.cshtml @@ -0,0 +1,24 @@ +@model LocationListViewModel +
+ @foreach (var location in Model.Locations) + { +
+
+ @location.LocationName + @if (!string.IsNullOrWhiteSpace(location.LocationTypeName)) + { + @location.LocationTypeName + } + @location.LocationPath +
+
+ Edit +
+ + + +
+
+
+ } +
diff --git a/PlotLine/wwwroot/js/site.js b/PlotLine/wwwroot/js/site.js index a254388..e02c739 100644 --- a/PlotLine/wwwroot/js/site.js +++ b/PlotLine/wwwroot/js/site.js @@ -2197,6 +2197,9 @@ return; } + const locationRelationshipModalElement = document.querySelector("[data-location-relationship-modal]"); + const locationRelationshipModal = locationRelationshipModalElement && window.bootstrap?.Modal + ? window.bootstrap.Modal.getOrCreateInstance(locationRelationshipModalElement) : null; const forms = new Map([...document.querySelectorAll("[data-drag-form]")].map((form) => [form.dataset.dragForm, form])); const title = panel.querySelector("[data-drag-panel-title]"); const kicker = panel.querySelector("[data-drag-panel-kicker]"); @@ -2260,10 +2263,15 @@ title.textContent = heading; kicker.textContent = actionLabel; summary.textContent = body; - panel.scrollIntoView({ behavior: "smooth", block: "center" }); + if (key === "location-relationship" && locationRelationshipModal) { + locationRelationshipModal.show(); + } else { + panel.scrollIntoView({ behavior: "smooth", block: "center" }); + } }; const hidePanel = () => { + locationRelationshipModal?.hide(); panel.classList.add("d-none"); forms.forEach((form) => form.classList.add("d-none")); }; @@ -2322,7 +2330,7 @@ element.classList.add("is-dragging"); }; - document.querySelectorAll("[data-drag-type][draggable='true']").forEach((item) => { + const bindDraggable = (item) => { item.addEventListener("dragstart", (event) => writePayload(event, item)); item.addEventListener("dragend", () => { item.classList.remove("is-dragging"); @@ -2330,7 +2338,8 @@ currentDrag = null; clearDropHighlights(); }); - }); + }; + document.querySelectorAll("[data-drag-type][draggable='true']").forEach(bindDraggable); panel.querySelectorAll("[data-drag-cancel]").forEach((button) => button.addEventListener("click", hidePanel)); forms.get("scene")?.addEventListener("submit", saveTimelineScroll); @@ -2512,12 +2521,31 @@ }); }); - document.querySelectorAll("[data-drag-type='location']").forEach((target) => { + const reparentForm = document.querySelector("[data-location-reparent-form]"); + const locationDragAction = document.querySelector("[data-location-drag-action]"); + const locationMoveError = document.querySelector("[data-location-move-error]"); + let locationRows = new Map([...document.querySelectorAll("[data-drag-type='location']")] + .map(row => [row.dataset.dragId, row])); + const changingParent = () => reparentForm && locationDragAction?.value === "parent"; + const parentMoveError = (sourceId, targetId) => { + const source = locationRows.get(sourceId); + if (!source || !locationRows.has(targetId)) return "Choose locations from this page."; + if (source.dataset.parentLocationId === targetId) return "This location already has that parent."; + const visited = new Set(); + for (let ancestor = targetId; ancestor; ancestor = locationRows.get(ancestor)?.dataset.parentLocationId) { + if (ancestor === sourceId || visited.has(ancestor)) + return "A location cannot be its own parent or be moved beneath one of its descendants."; + visited.add(ancestor); + } + return ""; + }; + + const bindLocationDropTarget = (target) => { target.addEventListener("dragover", (event) => { const payload = readPayload(event); if (payload.type === "location") { event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; + event.dataTransfer.dropEffect = changingParent() ? (parentMoveError(payload.id, target.dataset.dragId) ? "none" : "move") : "copy"; target.classList.add("drop-target-active"); } }); @@ -2531,17 +2559,107 @@ } event.preventDefault(); + if (locationSaving) return; target.classList.remove("drop-target-active"); + if (changingParent()) { + const error = parentMoveError(payload.id, targetId); + if (locationMoveError) locationMoveError.textContent = error; + if (error) return; + const source = locationRows.get(payload.id); + const oldParent = source.dataset.parentLocationName || "top level"; + setValue(reparentForm, "locationId", payload.id); + setValue(reparentForm, "parentLocationId", targetId); + setValue(reparentForm, "expectedParentLocationId", source.dataset.parentLocationId); + reparentForm.dataset.confirmed = "false"; + reparentForm.dataset.confirmMessage = `Change location parent?\n\nMove “${payload.label}” from ${oldParent} to “${targetLabel}”? Any child locations will move with it.`; + // Reuse the shared confirmation modal. Do not submit if the modal is unavailable. + if (!window.bootstrap?.Modal || !document.getElementById("plotlineConfirmModal")) { + if (locationMoveError) locationMoveError.textContent = "Unable to open confirmation. Reload the page or use Edit to change the parent."; + return; + } + hidePanel(); + reparentForm.requestSubmit(); + return; + } if (payload.id === targetId) { - showForm("location-relationship", "Cannot create relationship", "Drop one location onto a different location.", "Location relationship"); + if (locationMoveError) locationMoveError.textContent = "Drop one location onto a different location."; return; } + if (locationMoveError) locationMoveError.textContent = ""; + const relationshipError = document.querySelector("[data-location-relationship-error]"); + if (relationshipError) relationshipError.textContent = ""; const form = forms.get("location-relationship"); setValue(form, "FromLocationID", payload.id); setValue(form, "ToLocationID", targetId); showForm("location-relationship", "Create location relationship", `${payload.label} -> ${targetLabel}`, "Location drop"); }); + }; + locationRows.forEach(bindLocationDropTarget); + + + let locationSaving = false; + const moveStatus = document.querySelector("[data-location-move-status]"); + const preserveScroll = (position) => { + const previousBehavior = document.documentElement.style.scrollBehavior; + document.documentElement.style.scrollBehavior = "auto"; + window.scrollTo(position.x, position.y); + document.documentElement.style.scrollBehavior = previousBehavior; + }; + const saveLocationForm = async (form, refreshTree) => { + if (locationSaving) return; + locationSaving = true; + const position = { x: window.scrollX, y: window.scrollY }; + const errorElement = refreshTree ? locationMoveError : document.querySelector("[data-location-relationship-error]"); + if (errorElement) errorElement.textContent = ""; + if (moveStatus) moveStatus.textContent = "Saving…"; + const buttons = [...form.querySelectorAll("button[type='submit']")]; + buttons.forEach(button => button.disabled = true); + try { + const response = await fetch(form.action, { + method: "POST", body: new FormData(form), + headers: { "X-Requested-With": "XMLHttpRequest" } + }); + if (!response.ok || response.redirected) { + const problem = await response.json().catch(() => null); + throw new Error(problem?.error || "Unable to save. Your session may have expired; reload the page and try again."); + } + if (refreshTree) { + const html = await response.text(); + const documentFragment = new DOMParser().parseFromString(html, "text/html"); + const replacement = documentFragment.querySelector("[data-location-tree]"); + const tree = document.querySelector("[data-location-tree]"); + if (!replacement || !tree) throw new Error("Unable to refresh the location tree. Reload the page to check the saved parent."); + tree.replaceWith(replacement); + locationRows = new Map([...replacement.querySelectorAll("[data-drag-type='location']")].map(row => [row.dataset.dragId, row])); + locationRows.forEach(row => { bindDraggable(row); bindLocationDropTarget(row); }); + } else { + await response.json(); + hidePanel(); + } + if (moveStatus) moveStatus.textContent = refreshTree ? "Location moved to its new parent." : "Location relationship saved."; + } catch (error) { + if (errorElement) errorElement.textContent = error.message || "Unable to save. Please try again."; + if (moveStatus) moveStatus.textContent = ""; + } finally { + locationSaving = false; + buttons.forEach(button => button.disabled = false); + // Do not reset data-confirmed during the submit event's propagation. + form.dataset.confirmed = "false"; + requestAnimationFrame(() => preserveScroll(position)); + } + }; + reparentForm?.addEventListener("submit", event => { + if (reparentForm.dataset.confirmed !== "true") return; + event.preventDefault(); + saveLocationForm(reparentForm, true); }); + if (locationRelationshipModal) { + const relationshipForm = forms.get("location-relationship"); + relationshipForm?.addEventListener("submit", event => { + event.preventDefault(); + saveLocationForm(relationshipForm, false); + }); + } })();