Add confirmed AJAX location reparenting and relationship modals

This commit is contained in:
Nick Beckley 2026-09-10 22:25:21 +00:00
parent 84ed078eba
commit 32e82459bf
12 changed files with 573 additions and 35 deletions

View File

@ -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. 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. 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.

View File

@ -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: '<html><body></body></html>' }));
await page.goto('https://plotdirector.test/Locations');
const layout = fs.readFileSync(path.join(repo, 'PlotLine/Views/Shared/_Layout.cshtml'), 'utf8');
const modalStart = layout.indexOf(' <div class="modal fade" id="plotlineConfirmModal"');
const modal = layout.slice(modalStart, layout.indexOf(' <script', modalStart));
const rows = [ ['1','Room','',''], ['2','Building','',''], ['3','Cupboard','1','Room'], ['4','Annex','2','Building'] ]
.map(([id,name,parent,parentName]) => `<div style="padding:24px;border:1px solid" draggable="true" data-drag-type="location" data-drag-id="${id}" data-drag-label="${name}" data-parent-location-id="${parent}" data-parent-location-name="${parentName}"><a href="/Locations/Details/${id}">${name}</a></div>`).join('');
await page.setContent(`<select data-location-drag-action><option value="parent">Change parent</option><option value="relationship">Create relationship</option></select>
<p data-location-move-error></p><p data-location-move-status></p>
<form action="/Locations/Reparent" method="post" data-location-reparent-form data-confirm-message="Change parent?" data-confirm-button-text="Move location" data-confirm-button-class="btn-primary">
<input type="hidden" name="projectId" value="1"><input type="hidden" name="locationId"><input type="hidden" name="parentLocationId"><input type="hidden" name="expectedParentLocationId">
</form>
<div class="modal fade" tabindex="-1" data-location-relationship-modal><div class="modal-dialog"><section class="modal-content d-none" data-drag-drop-panel><h2 data-drag-panel-title></h2><span data-drag-panel-kicker></span><p data-drag-panel-summary></p>
<p data-location-relationship-error></p><form action="/Locations/DropRelationship" data-drag-form="location-relationship"><input name="FromLocationID"><input name="ToLocationID"><button type="submit">Save relationship</button></form></section></div></div><div style="height:900px"></div><section data-location-tree>${rows}</section><div style="height:2000px"></div>${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; });

View File

@ -1,5 +1,5 @@
{ {
"private": true, "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" } "devDependencies": { "chart.js": "4.4.9", "playwright": "1.58.2" }
} }

View File

@ -2985,7 +2985,9 @@ static void LocationsPageUsesHierarchicalSortOrder()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs")); 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("<partial name=\"_LocationTree\" model=\"Model\"", StringComparison.Ordinal), "Locations page and AJAX updates should share the location tree.");
var view = File.ReadAllText(Path.Combine(root, "Views/Locations/_LocationTree.cshtml"));
var listMethod = ExtractBetween(service, "public async Task<LocationListViewModel?> GetLocationsAsync", "public async Task<LocationEditViewModel?> GetCreateAsync"); var listMethod = ExtractBetween(service, "public async Task<LocationListViewModel?> GetLocationsAsync", "public async Task<LocationEditViewModel?> GetCreateAsync");
var treeMethod = ExtractBetween(service, "private IReadOnlyList<LocationItem> OrderLocationsAsTree", "private async Task SyncLocationAliasesAsync"); var treeMethod = ExtractBetween(service, "private IReadOnlyList<LocationItem> OrderLocationsAsTree", "private async Task SyncLocationAliasesAsync");

View File

@ -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

View File

@ -62,6 +62,34 @@ public sealed class LocationsController(ILocationService locations) : Controller
return View(model); return View(model);
} }
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> Save(LocationEditViewModel model) public async Task<IActionResult> Save(LocationEditViewModel model)
@ -129,13 +157,25 @@ public sealed class LocationsController(ILocationService locations) : Controller
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> DropRelationship(LocationRelationshipEditViewModel model) public async Task<IActionResult> DropRelationship(LocationRelationshipEditViewModel model)
{ {
if (model.FromLocationID != model.ToLocationID) if (!ModelState.IsValid || model.FromLocationID == model.ToLocationID)
{ {
await locations.SaveRelationshipAsync(model); if (IsAjaxRequest()) return BadRequest(new { error = "Choose two different locations and a relationship type." });
}
return RedirectToAction(nameof(Index), new { projectId = model.ProjectID }); 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] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]

View File

@ -261,6 +261,7 @@ public interface ICharacterRepository
public interface ILocationRepository public interface ILocationRepository
{ {
Task ReparentAsync(int projectId, int locationId, int parentLocationId, int? expectedParentLocationId, int? userId);
Task<EntityMergeResult?> MergeAsync(int sourceId, int targetId, int? userId); Task<EntityMergeResult?> MergeAsync(int sourceId, int targetId, int? userId);
Task<LocationLookupData> GetLookupsAsync(); Task<LocationLookupData> GetLookupsAsync();
Task<IReadOnlyList<LocationItem>> ListByProjectAsync(int projectId); Task<IReadOnlyList<LocationItem>> ListByProjectAsync(int projectId);
@ -488,6 +489,22 @@ public sealed class TimelineSceneOverviewRow
public sealed class LocationRepository(ISqlConnectionFactory connectionFactory) : ILocationRepository 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<EntityMergeResult?> MergeAsync(int sourceId, int targetId, int? userId) public async Task<EntityMergeResult?> MergeAsync(int sourceId, int targetId, int? userId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -210,6 +210,7 @@ public interface IAssetService
public interface ILocationService public interface ILocationService
{ {
Task ReparentAsync(int projectId, int locationId, int parentLocationId, int? expectedParentLocationId);
Task<EntityMergeViewModel?> GetMergeAsync(int sourceId); Task<EntityMergeViewModel?> GetMergeAsync(int sourceId);
Task<EntityMergeResult?> MergeAsync(EntityMergeForm form); Task<EntityMergeResult?> MergeAsync(EntityMergeForm form);
Task<LocationListViewModel?> GetLocationsAsync(int projectId); Task<LocationListViewModel?> GetLocationsAsync(int projectId);
@ -8257,6 +8258,9 @@ public sealed class LocationService(
ICurrentUserService currentUser, ICurrentUserService currentUser,
ILogger<LocationService> logger) : ILocationService ILogger<LocationService> logger) : ILocationService
{ {
public Task ReparentAsync(int projectId, int locationId, int parentLocationId, int? expectedParentLocationId)
=> locations.ReparentAsync(projectId, locationId, parentLocationId, expectedParentLocationId, currentUser.UserId);
public async Task<EntityMergeViewModel?> GetMergeAsync(int sourceId) public async Task<EntityMergeViewModel?> GetMergeAsync(int sourceId)
{ {
var source = await locations.GetAsync(sourceId); var source = await locations.GetAsync(sourceId);

View File

@ -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

View File

@ -21,15 +21,44 @@
<a class="btn btn-primary" asp-action="Create" asp-route-projectId="@Model.Project.ProjectID">New location</a> <a class="btn btn-primary" asp-action="Create" asp-route-projectId="@Model.Project.ProjectID">New location</a>
</div> </div>
<section class="drag-drop-panel d-none" data-drag-drop-panel> @if (TempData["LocationMessage"] is string message)
{
<div class="alert alert-success" role="status">@message</div>
}
@if (TempData["LocationError"] is string error)
{
<div class="alert alert-danger" role="alert">@error</div>
}
<div class="edit-panel">
<label for="locationDragAction" class="form-label">Drag a location onto another location to:</label>
<select id="locationDragAction" class="form-select" data-location-drag-action>
<option value="parent">Change its parent</option>
<option value="relationship">Create a relationship</option>
</select>
<p class="muted mt-2 mb-0">Drop onto the new parent, then confirm the move. Child locations move with it. You can also change the parent using Edit.</p>
<p class="text-danger mb-0" role="alert" data-location-move-error></p>
<p class="mb-0" role="status" data-location-move-status></p>
</div>
<form asp-action="Reparent" method="post" data-location-reparent-form
data-confirm-message="Change location parent?" data-confirm-button-text="Move location" data-confirm-button-class="btn-primary">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="locationId" />
<input type="hidden" name="parentLocationId" />
<input type="hidden" name="expectedParentLocationId" />
</form>
<div class="modal fade" tabindex="-1" aria-labelledby="locationRelationshipTitle" aria-hidden="true" data-location-relationship-modal>
<div class="modal-dialog modal-dialog-centered modal-lg">
<section class="modal-content p-3 d-none" data-drag-drop-panel>
<div class="drag-drop-panel-header"> <div class="drag-drop-panel-header">
<div> <div>
<p class="eyebrow" data-drag-panel-kicker>Location relationship</p> <p class="eyebrow" data-drag-panel-kicker>Location relationship</p>
<h2 data-drag-panel-title>Create location relationship</h2> <h2 id="locationRelationshipTitle" data-drag-panel-title>Create location relationship</h2>
</div> </div>
<button type="button" class="btn btn-outline-secondary btn-sm" data-drag-cancel>Cancel</button> <button type="button" class="btn btn-outline-secondary btn-sm" data-drag-cancel>Cancel</button>
</div> </div>
<p class="muted" data-drag-panel-summary></p> <p class="muted" data-drag-panel-summary></p>
<p class="text-danger" role="alert" data-location-relationship-error></p>
<form asp-controller="Locations" asp-action="DropRelationship" method="post" class="drag-drop-form" data-drag-form="location-relationship"> <form asp-controller="Locations" asp-action="DropRelationship" method="post" class="drag-drop-form" data-drag-form="location-relationship">
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" /> <input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="FromLocationID" /> <input type="hidden" name="FromLocationID" />
@ -52,6 +81,8 @@
</div> </div>
</form> </form>
</section> </section>
</div>
</div>
@if (!Model.Locations.Any()) @if (!Model.Locations.Any())
{ {
@ -62,28 +93,6 @@
} }
else else
{ {
<section class="edit-panel location-tree"> <partial name="_LocationTree" model="Model" />
@foreach (var location in Model.Locations)
{
<div class="location-tree-row" draggable="true" data-drag-type="location" data-drag-id="@location.LocationID" data-drag-label="@location.LocationName" style="--location-depth:@location.Depth">
<div>
<a asp-action="Details" asp-route-id="@location.LocationID"><strong>@location.LocationName</strong></a>
@if (!string.IsNullOrWhiteSpace(location.LocationTypeName))
{
<span class="location-badge">@location.LocationTypeName</span>
}
<span class="muted">@location.LocationPath</span>
</div>
<div class="button-row compact-buttons">
<a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-id="@location.LocationID">Edit</a>
<form asp-action="Archive" method="post" data-confirm-message="Archive this location?&#10;&#10;This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
<input type="hidden" name="id" value="@location.LocationID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<button class="btn btn-outline-danger btn-sm" type="submit">Archive</button>
</form>
</div>
</div>
}
</section>
} }
</div> </div>

View File

@ -0,0 +1,24 @@
@model LocationListViewModel
<section class="edit-panel location-tree" data-location-tree>
@foreach (var location in Model.Locations)
{
<div class="location-tree-row" draggable="true" data-drag-type="location" data-drag-id="@location.LocationID" data-drag-label="@location.LocationName" data-parent-location-id="@location.ParentLocationID" data-parent-location-name="@location.ParentLocationName" style="--location-depth:@location.Depth">
<div>
<a asp-action="Details" asp-route-id="@location.LocationID"><strong>@location.LocationName</strong></a>
@if (!string.IsNullOrWhiteSpace(location.LocationTypeName))
{
<span class="location-badge">@location.LocationTypeName</span>
}
<span class="muted">@location.LocationPath</span>
</div>
<div class="button-row compact-buttons">
<a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-id="@location.LocationID">Edit</a>
<form asp-action="Archive" method="post" data-confirm-message="Archive this location?&#10;&#10;This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
<input type="hidden" name="id" value="@location.LocationID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<button class="btn btn-outline-danger btn-sm" type="submit">Archive</button>
</form>
</div>
</div>
}
</section>

View File

@ -2197,6 +2197,9 @@
return; 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 forms = new Map([...document.querySelectorAll("[data-drag-form]")].map((form) => [form.dataset.dragForm, form]));
const title = panel.querySelector("[data-drag-panel-title]"); const title = panel.querySelector("[data-drag-panel-title]");
const kicker = panel.querySelector("[data-drag-panel-kicker]"); const kicker = panel.querySelector("[data-drag-panel-kicker]");
@ -2260,10 +2263,15 @@
title.textContent = heading; title.textContent = heading;
kicker.textContent = actionLabel; kicker.textContent = actionLabel;
summary.textContent = body; summary.textContent = body;
if (key === "location-relationship" && locationRelationshipModal) {
locationRelationshipModal.show();
} else {
panel.scrollIntoView({ behavior: "smooth", block: "center" }); panel.scrollIntoView({ behavior: "smooth", block: "center" });
}
}; };
const hidePanel = () => { const hidePanel = () => {
locationRelationshipModal?.hide();
panel.classList.add("d-none"); panel.classList.add("d-none");
forms.forEach((form) => form.classList.add("d-none")); forms.forEach((form) => form.classList.add("d-none"));
}; };
@ -2322,7 +2330,7 @@
element.classList.add("is-dragging"); 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("dragstart", (event) => writePayload(event, item));
item.addEventListener("dragend", () => { item.addEventListener("dragend", () => {
item.classList.remove("is-dragging"); item.classList.remove("is-dragging");
@ -2330,7 +2338,8 @@
currentDrag = null; currentDrag = null;
clearDropHighlights(); clearDropHighlights();
}); });
}); };
document.querySelectorAll("[data-drag-type][draggable='true']").forEach(bindDraggable);
panel.querySelectorAll("[data-drag-cancel]").forEach((button) => button.addEventListener("click", hidePanel)); panel.querySelectorAll("[data-drag-cancel]").forEach((button) => button.addEventListener("click", hidePanel));
forms.get("scene")?.addEventListener("submit", saveTimelineScroll); 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) => { target.addEventListener("dragover", (event) => {
const payload = readPayload(event); const payload = readPayload(event);
if (payload.type === "location") { if (payload.type === "location") {
event.preventDefault(); 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"); target.classList.add("drop-target-active");
} }
}); });
@ -2531,17 +2559,107 @@
} }
event.preventDefault(); event.preventDefault();
if (locationSaving) return;
target.classList.remove("drop-target-active"); 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) { 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; return;
} }
if (locationMoveError) locationMoveError.textContent = "";
const relationshipError = document.querySelector("[data-location-relationship-error]");
if (relationshipError) relationshipError.textContent = "";
const form = forms.get("location-relationship"); const form = forms.get("location-relationship");
setValue(form, "FromLocationID", payload.id); setValue(form, "FromLocationID", payload.id);
setValue(form, "ToLocationID", targetId); setValue(form, "ToLocationID", targetId);
showForm("location-relationship", "Create location relationship", `${payload.label} -> ${targetLabel}`, "Location drop"); 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);
});
}
})(); })();