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(' `
`).join('');
+ await page.setContent(`
Change parent Create relationship
+
+
+
${modal}`);
+ await page.addStyleTag({ path: path.join(repo,'PlotLine/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css') });
+ await page.addScriptTag({ path: path.join(repo,'PlotLine/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js') });
+ // Load the entire production script: an earlier initialization error can disable dragging.
+ await page.addScriptTag({ path: path.join(repo,'PlotLine/wwwroot/js/site.js') });
+ await page.evaluate(() => {
+ window.savedMoves = [];
+ window.relationshipSaves = 0;
+ window.failNextSave = false;
+ window.fetch = async (url, options) => {
+ if (window.failNextSave) {
+ window.failNextSave = false;
+ return new Response(JSON.stringify({error:'The parent has changed. Review the tree.'}), {status:400});
+ }
+ if (url.endsWith('/DropRelationship')) {
+ window.relationshipSaves++;
+ return new Response(JSON.stringify({message:'Saved'}));
+ }
+ const values = Object.fromEntries(options.body);
+ window.savedMoves.push(values);
+ const tree = document.querySelector('[data-location-tree]').cloneNode(true);
+ const source = tree.querySelector(`[data-drag-id="${values.locationId}"]`);
+ const target = tree.querySelector(`[data-drag-id="${values.parentLocationId}"]`);
+ source.dataset.parentLocationId = values.parentLocationId;
+ source.dataset.parentLocationName = target.dataset.dragLabel;
+ tree.append(source);
+ return new Response(tree.outerHTML, {headers:{'Content-Type':'text/html'}});
+ };
+ });
+ const drag = async (source, target) => page.locator(`[data-drag-id="${source}"]`).dragTo(page.locator(`[data-drag-id="${target}"]`));
+ const confirm = page.locator('#plotlineConfirmModal');
+ await drag(1,2);
+ await confirm.waitFor({state:'visible'});
+ assert.match(await confirm.innerText(), /from top level to “Building”/);
+ assert.equal(await page.evaluate(() => savedMoves.length),0);
+ await confirm.locator('[data-bs-dismiss="modal"]').last().click();
+ await confirm.waitFor({state:'hidden'});
+ await page.locator('.modal-backdrop').waitFor({state:'hidden'});
+ assert.equal(await page.evaluate(() => savedMoves.length),0);
+ await drag(1,2);
+ const scrollBeforeSave = await page.evaluate(() => window.scrollY);
+ await confirm.locator('[data-confirm-submit]').click();
+ await confirm.waitFor({state:'hidden'});
+ await page.locator('.modal-backdrop').waitFor({state:'hidden'});
+ await page.waitForFunction(() => document.querySelector('[data-location-move-status]').textContent.includes('moved'));
+ assert.ok(Math.abs(await page.evaluate(() => window.scrollY) - scrollBeforeSave) < 2, 'AJAX update must preserve scroll');
+ assert.deepEqual(await page.evaluate(() => savedMoves[0]), {projectId:'1',locationId:'1',parentLocationId:'2',expectedParentLocationId:''});
+ await drag(4,1);
+ await confirm.waitFor({state:'visible'});
+ assert.match(await confirm.innerText(), /from Building to “Room”/);
+ await confirm.locator('[data-confirm-submit]').click();
+ await confirm.waitFor({state:'hidden'});
+ await page.locator('.modal-backdrop').waitFor({state:'hidden'});
+ assert.equal(await page.evaluate(() => savedMoves[1].expectedParentLocationId),'2');
+ for (const [source,target] of [[1,1],[1,3],[1,2]]) {
+ await drag(source,target);
+ assert.equal(await confirm.isVisible(),false);
+ assert.equal(await page.evaluate(() => savedMoves.length),2);
+ }
+ // Server validation failures leave the existing tree intact and explain the error.
+ await page.evaluate(() => window.failNextSave = true);
+ await drag(4,2);
+ await confirm.locator('[data-confirm-submit]').click();
+ await confirm.waitFor({state:'hidden'});
+ await page.locator('.modal-backdrop').waitFor({state:'hidden'});
+ assert.match(await page.locator('[data-location-move-error]').innerText(), /parent has changed/);
+ assert.equal(await page.locator('[data-drag-id="4"]').getAttribute('data-parent-location-id'),'1');
+ await page.selectOption('[data-location-drag-action]','relationship');
+ await drag(1,2);
+ await page.locator('[data-drag-drop-panel]').waitFor({state:'visible'});
+ assert.equal(await page.locator('[name="FromLocationID"]').inputValue(),'1');
+ assert.equal(await page.locator('[name="ToLocationID"]').inputValue(),'2');
+ const relationshipScroll = await page.evaluate(() => window.scrollY);
+ await page.locator('[data-drag-form="location-relationship"] button').click();
+ await page.locator('[data-location-relationship-modal]').waitFor({state:'hidden'});
+ await page.locator('.modal-backdrop').waitFor({state:'hidden'});
+ assert.ok(Math.abs(await page.evaluate(() => window.scrollY) - relationshipScroll) < 2);
+ assert.equal(await page.evaluate(() => relationshipSaves),1);
+ assert.deepEqual(errors,[]);
+ console.log('PASS: drag assignment/reassignment, confirmation, cancellation, invalid targets, AJAX refresh, scroll preservation, server errors, relationship modal/AJAX, full site.js initialization');
+ } finally { await browser.close(); }
+})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/PlotLine.Tests/Browser/package.json b/PlotLine.Tests/Browser/package.json
index 1ebc861..f2fa2ba 100644
--- a/PlotLine.Tests/Browser/package.json
+++ b/PlotLine.Tests/Browser/package.json
@@ -1,5 +1,5 @@
{
"private": true,
- "scripts": { "test": "node timeline-metric-layout.cjs" },
+ "scripts": { "test": "node timeline-metric-layout.cjs", "test:locations": "node location-reparent.cjs" },
"devDependencies": { "chart.js": "4.4.9", "playwright": "1.58.2" }
}
diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs
index f1dd120..276dbb5 100644
--- a/PlotLine.Tests/Program.cs
+++ b/PlotLine.Tests/Program.cs
@@ -2985,7 +2985,9 @@ static void LocationsPageUsesHierarchicalSortOrder()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
- var view = File.ReadAllText(Path.Combine(root, "Views/Locations/Index.cshtml"));
+ var index = File.ReadAllText(Path.Combine(root, "Views/Locations/Index.cshtml"));
+ Assert(index.Contains("
GetLocationsAsync", "public async Task GetCreateAsync");
var treeMethod = ExtractBetween(service, "private IReadOnlyList OrderLocationsAsTree", "private async Task SyncLocationAliasesAsync");
diff --git a/PlotLine.Tests/Sql/LocationReparent.sql b/PlotLine.Tests/Sql/LocationReparent.sql
new file mode 100644
index 0000000..12d8f0c
--- /dev/null
+++ b/PlotLine.Tests/Sql/LocationReparent.sql
@@ -0,0 +1,160 @@
+-- Regression fixtures are always rolled back. Requires a migrated database and active project user.
+SET XACT_ABORT ON;
+BEGIN TRY
+ BEGIN TRANSACTION;
+ DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
+ SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
+ INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
+ INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
+
+ EXEC dbo.Location_Reparent @ProjectID,@Source,@Parent,NULL,@UserID;
+ IF NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID=@Source AND ParentLocationID=@Parent) THROW 51001,'Initial parent not assigned.',1;
+ EXEC dbo.Location_Reparent @ProjectID,@Source,@Other,@Parent,@UserID;
+ IF NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID=@Source AND ParentLocationID=@Other AND Description=N'Preserve details') THROW 51001,'Reassignment changed details or failed.',1;
+ IF NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID=@Child AND ParentLocationID=@Source) THROW 51001,'Subtree changed.',1;
+ IF NOT EXISTS (SELECT 1 FROM dbo.LocationAliases WHERE LocationID=@Source AND Alias=N'Room alias') THROW 51001,'Alias changed.',1;
+ IF (SELECT COUNT(*) FROM dbo.ProjectActivity WHERE EntityType=N'Location' AND EntityID=@Source AND ActivityType=N'Updated')<>2 THROW 51001,'Audit entries missing.',1;
+ ROLLBACK TRANSACTION;
+ SELECT N'PASS: assign, reassign, preserve subtree/details/aliases, audit' AS Result;
+END TRY
+BEGIN CATCH
+ IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
+ THROW;
+END CATCH;
+GO
+BEGIN TRY
+ BEGIN TRANSACTION;
+ DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
+ SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
+ INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
+ INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
+
+ EXEC dbo.Location_Reparent @ProjectID,@Source,@Source,NULL,@UserID;
+ THROW 51001,'Invalid move was accepted.',1;
+END TRY
+BEGIN CATCH
+ IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
+ IF ERROR_NUMBER()<>51000 THROW;
+ SELECT N'PASS: reject self; fixtures rolled back' AS Result;
+END CATCH;
+GO
+BEGIN TRY
+ BEGIN TRANSACTION;
+ DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
+ SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
+ INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
+ INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
+
+ EXEC dbo.Location_Reparent @ProjectID,@Source,@Child,NULL,@UserID;
+ THROW 51001,'Invalid move was accepted.',1;
+END TRY
+BEGIN CATCH
+ IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
+ IF ERROR_NUMBER()<>51000 THROW;
+ SELECT N'PASS: reject descendant; fixtures rolled back' AS Result;
+END CATCH;
+GO
+BEGIN TRY
+ BEGIN TRANSACTION;
+ DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
+ SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
+ INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
+ INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
+
+ EXEC dbo.Location_Reparent @ProjectID,@Source,@Parent,@Other,@UserID;
+ THROW 51001,'Invalid move was accepted.',1;
+END TRY
+BEGIN CATCH
+ IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
+ IF ERROR_NUMBER()<>51000 THROW;
+ SELECT N'PASS: reject stale parent; fixtures rolled back' AS Result;
+END CATCH;
+GO
+BEGIN TRY
+ BEGIN TRANSACTION;
+ DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
+ SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
+ INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
+ INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
+ UPDATE dbo.Locations SET ParentLocationID=@Parent WHERE LocationID=@Source;
+ EXEC dbo.Location_Reparent @ProjectID,@Source,@Parent,@Parent,@UserID;
+ THROW 51001,'Invalid move was accepted.',1;
+END TRY
+BEGIN CATCH
+ IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
+ IF ERROR_NUMBER()<>51000 THROW;
+ SELECT N'PASS: reject same parent; fixtures rolled back' AS Result;
+END CATCH;
+GO
+BEGIN TRY
+ BEGIN TRANSACTION;
+ DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
+ SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
+ INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
+ INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
+ UPDATE dbo.Locations SET IsArchived=1 WHERE LocationID=@Parent;
+ EXEC dbo.Location_Reparent @ProjectID,@Source,@Parent,NULL,@UserID;
+ THROW 51001,'Invalid move was accepted.',1;
+END TRY
+BEGIN CATCH
+ IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
+ IF ERROR_NUMBER()<>51000 THROW;
+ SELECT N'PASS: reject archived parent; fixtures rolled back' AS Result;
+END CATCH;
+GO
+BEGIN TRY
+ BEGIN TRANSACTION;
+ DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
+ SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
+ INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
+ INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
+ INSERT dbo.Projects(ProjectName) VALUES (N'Reparent other project'); DECLARE @OtherProject int=SCOPE_IDENTITY(); UPDATE dbo.Locations SET ProjectID=@OtherProject WHERE LocationID=@Parent;
+ EXEC dbo.Location_Reparent @ProjectID,@Source,@Parent,NULL,@UserID;
+ THROW 51001,'Invalid move was accepted.',1;
+END TRY
+BEGIN CATCH
+ IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
+ IF ERROR_NUMBER()<>51000 THROW;
+ SELECT N'PASS: reject cross-project; fixtures rolled back' AS Result;
+END CATCH;
+GO
+BEGIN TRY
+ BEGIN TRANSACTION;
+ DECLARE @ProjectID int,@UserID int,@Source int,@Parent int,@Child int,@Other int;
+ SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1;
+ INSERT dbo.Locations(ProjectID,LocationName,Description) VALUES (@ProjectID,N'Reparent test room',N'Preserve details'); SET @Source=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test building'); SET @Parent=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Reparent test cupboard',@Source); SET @Child=SCOPE_IDENTITY();
+ INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Reparent test other building'); SET @Other=SCOPE_IDENTITY();
+ INSERT dbo.LocationAliases(LocationID,Alias) VALUES (@Source,N'Room alias');
+ SET @UserID=NULL;
+ EXEC dbo.Location_Reparent @ProjectID,@Source,@Parent,NULL,@UserID;
+ THROW 51001,'Invalid move was accepted.',1;
+END TRY
+BEGIN CATCH
+ IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
+ IF ERROR_NUMBER()<>51000 THROW;
+ SELECT N'PASS: reject unauthorized; fixtures rolled back' AS Result;
+END CATCH;
+GO
diff --git a/PlotLine/Controllers/LocationsController.cs b/PlotLine/Controllers/LocationsController.cs
index 5744cc6..e7afd2d 100644
--- a/PlotLine/Controllers/LocationsController.cs
+++ b/PlotLine/Controllers/LocationsController.cs
@@ -62,6 +62,34 @@ public sealed class LocationsController(ILocationService locations) : Controller
return View(model);
}
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Reparent(int projectId, int locationId, int parentLocationId, int? expectedParentLocationId)
+ {
+ if (!ModelState.IsValid)
+ {
+ if (IsAjaxRequest()) return BadRequest(new { error = "Invalid location selection. Please try again." });
+ TempData["LocationError"] = "Invalid location selection. Please try again.";
+ return RedirectToAction(nameof(Index), new { projectId });
+ }
+ try
+ {
+ await locations.ReparentAsync(projectId, locationId, parentLocationId, expectedParentLocationId);
+ if (IsAjaxRequest())
+ {
+ var model = await locations.GetLocationsAsync(projectId);
+ return model is null ? NotFound() : PartialView("_LocationTree", model);
+ }
+ TempData["LocationMessage"] = "Location moved to its new parent.";
+ }
+ catch (InvalidOperationException ex)
+ {
+ if (IsAjaxRequest()) return BadRequest(new { error = ex.Message });
+ TempData["LocationError"] = ex.Message;
+ }
+ return RedirectToAction(nameof(Index), new { projectId });
+ }
+
[HttpPost]
[ValidateAntiForgeryToken]
public async Task Save(LocationEditViewModel model)
@@ -129,14 +157,26 @@ public sealed class LocationsController(ILocationService locations) : Controller
[ValidateAntiForgeryToken]
public async Task DropRelationship(LocationRelationshipEditViewModel model)
{
- if (model.FromLocationID != model.ToLocationID)
+ if (!ModelState.IsValid || model.FromLocationID == model.ToLocationID)
+ {
+ if (IsAjaxRequest()) return BadRequest(new { error = "Choose two different locations and a relationship type." });
+ return RedirectToAction(nameof(Index), new { projectId = model.ProjectID });
+ }
+ try
{
await locations.SaveRelationshipAsync(model);
+ if (IsAjaxRequest()) return Json(new { message = "Location relationship saved." });
+ }
+ catch (InvalidOperationException ex)
+ {
+ if (IsAjaxRequest()) return BadRequest(new { error = ex.Message });
+ TempData["LocationError"] = ex.Message;
}
-
return RedirectToAction(nameof(Index), new { projectId = model.ProjectID });
}
+ private bool IsAjaxRequest() => Request.Headers["X-Requested-With"] == "XMLHttpRequest";
+
[HttpPost]
[ValidateAntiForgeryToken]
public async Task ArchiveRelationship(int id, int returnLocationId)
diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs
index 27d70e1..4817181 100644
--- a/PlotLine/Data/Repositories.cs
+++ b/PlotLine/Data/Repositories.cs
@@ -261,6 +261,7 @@ public interface ICharacterRepository
public interface ILocationRepository
{
+ Task ReparentAsync(int projectId, int locationId, int parentLocationId, int? expectedParentLocationId, int? userId);
Task MergeAsync(int sourceId, int targetId, int? userId);
Task GetLookupsAsync();
Task> ListByProjectAsync(int projectId);
@@ -488,6 +489,22 @@ public sealed class TimelineSceneOverviewRow
public sealed class LocationRepository(ISqlConnectionFactory connectionFactory) : ILocationRepository
{
+ public async Task ReparentAsync(int projectId, int locationId, int parentLocationId, int? expectedParentLocationId, int? userId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ try
+ {
+ await connection.ExecuteAsync("dbo.Location_Reparent",
+ new { ProjectID = projectId, LocationID = locationId, ParentLocationID = parentLocationId,
+ ExpectedParentLocationID = expectedParentLocationId, UserID = userId },
+ commandType: CommandType.StoredProcedure);
+ }
+ catch (SqlException ex) when (ex.Number == 51000)
+ {
+ throw new InvalidOperationException(ex.Message, ex);
+ }
+ }
+
public async Task MergeAsync(int sourceId, int targetId, int? userId)
{
using var connection = connectionFactory.CreateConnection();
diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs
index 13528aa..f819343 100644
--- a/PlotLine/Services/CoreServices.cs
+++ b/PlotLine/Services/CoreServices.cs
@@ -210,6 +210,7 @@ public interface IAssetService
public interface ILocationService
{
+ Task ReparentAsync(int projectId, int locationId, int parentLocationId, int? expectedParentLocationId);
Task GetMergeAsync(int sourceId);
Task MergeAsync(EntityMergeForm form);
Task GetLocationsAsync(int projectId);
@@ -8257,6 +8258,9 @@ public sealed class LocationService(
ICurrentUserService currentUser,
ILogger logger) : ILocationService
{
+ public Task ReparentAsync(int projectId, int locationId, int parentLocationId, int? expectedParentLocationId)
+ => locations.ReparentAsync(projectId, locationId, parentLocationId, expectedParentLocationId, currentUser.UserId);
+
public async Task GetMergeAsync(int sourceId)
{
var source = await locations.GetAsync(sourceId);
diff --git a/PlotLine/Sql/195_LocationReparent.sql b/PlotLine/Sql/195_LocationReparent.sql
new file mode 100644
index 0000000..c409196
--- /dev/null
+++ b/PlotLine/Sql/195_LocationReparent.sql
@@ -0,0 +1,53 @@
+-- Change only hierarchy fields, preserving location details and aliases.
+CREATE OR ALTER PROCEDURE dbo.Location_Reparent
+ @ProjectID int,
+ @LocationID int,
+ @ParentLocationID int,
+ @ExpectedParentLocationID int = NULL,
+ @UserID int = NULL
+AS
+BEGIN
+ SET NOCOUNT ON;
+ SET XACT_ABORT ON;
+ BEGIN TRY
+ BEGIN TRANSACTION;
+ DECLARE @LockedProjectID int, @OldParentID int, @Name nvarchar(200), @ParentName nvarchar(200);
+ SELECT @LockedProjectID=ProjectID FROM dbo.Projects WITH (UPDLOCK,HOLDLOCK) WHERE ProjectID=@ProjectID AND IsArchived=0;
+ IF @LockedProjectID IS NULL OR @UserID IS NULL OR NOT EXISTS
+ (SELECT 1 FROM dbo.ProjectUserAccess WHERE ProjectID=@ProjectID AND UserID=@UserID AND IsActive=1)
+ THROW 51000, 'You do not have access to this project.', 1;
+ SELECT @Name=LocationName,@OldParentID=ParentLocationID FROM dbo.Locations WITH (UPDLOCK,HOLDLOCK)
+ WHERE LocationID=@LocationID AND ProjectID=@ProjectID AND IsArchived=0;
+ SELECT @ParentName=LocationName FROM dbo.Locations WITH (UPDLOCK,HOLDLOCK)
+ WHERE LocationID=@ParentLocationID AND ProjectID=@ProjectID AND IsArchived=0;
+ IF @Name IS NULL OR @ParentName IS NULL
+ THROW 51000, 'Choose active locations from the same project.', 1;
+ IF ISNULL(@OldParentID,0)<>ISNULL(@ExpectedParentLocationID,0)
+ THROW 51000, 'The parent has changed since this page was loaded. Review the updated tree and try again.', 1;
+ IF @OldParentID=@ParentLocationID
+ THROW 51000, 'This location already has that parent.', 1;
+ DECLARE @AncestorID int=@ParentLocationID;
+ DECLARE @Visited TABLE (LocationID int PRIMARY KEY);
+ WHILE @AncestorID IS NOT NULL
+ BEGIN
+ IF @AncestorID=@LocationID OR EXISTS (SELECT 1 FROM @Visited WHERE LocationID=@AncestorID)
+ THROW 51000, 'A location cannot be its own parent or be moved beneath one of its descendants.', 1;
+ INSERT @Visited VALUES (@AncestorID);
+ DECLARE @NextAncestorID int=NULL;
+ SELECT @NextAncestorID=ParentLocationID FROM dbo.Locations WITH (UPDLOCK,HOLDLOCK) WHERE LocationID=@AncestorID;
+ SET @AncestorID=@NextAncestorID;
+ END;
+ UPDATE dbo.Locations SET ParentLocationID=@ParentLocationID,UpdatedDate=SYSUTCDATETIME(),
+ ModifiedDateUTC=SYSUTCDATETIME(),ModifiedByUserID=@UserID WHERE LocationID=@LocationID;
+ DECLARE @Description nvarchar(1000)=CONCAT(N'Parent changed from ',
+ COALESCE((SELECT LocationName FROM dbo.Locations WHERE LocationID=@OldParentID),N'top level'),N' to ',@ParentName,N'.');
+ EXEC dbo.ProjectAudit_Record @ProjectID=@ProjectID,@UserID=@UserID,@ActivityType=N'Updated',
+ @EntityType=N'Location',@EntityID=@LocationID,@EntityName=@Name,@Description=@Description;
+ COMMIT TRANSACTION;
+ END TRY
+ BEGIN CATCH
+ IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
+ THROW;
+ END CATCH;
+END;
+GO
diff --git a/PlotLine/Views/Locations/Index.cshtml b/PlotLine/Views/Locations/Index.cshtml
index 8a88abb..f40185b 100644
--- a/PlotLine/Views/Locations/Index.cshtml
+++ b/PlotLine/Views/Locations/Index.cshtml
@@ -21,15 +21,44 @@
New location
-