From 7839c8fc6c562348c8111eeb3cea1243e43b8d2b Mon Sep 17 00:00:00 2001 From: Alexander Zekelin <128831809+zekelinAlex@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:30:01 +0200 Subject: [PATCH 1/3] feat: security role scaffold applier for pp-security-role --- .../Scaffolding/ComponentScaffold.cs | 9 +++ .../SecurityRole/SecurityRoleScaffold.cs | 22 +++++++ .../SecurityRoleScaffoldRequest.cs | 18 +++++ .../SecurityRoleScaffoldTests.cs | 65 +++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/SecurityRole/SecurityRoleScaffold.cs create mode 100644 src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/SecurityRole/SecurityRoleScaffoldRequest.cs create mode 100644 tests/TALXIS.Platform.Metadata.Tests/SecurityRoleScaffoldTests.cs diff --git a/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/ComponentScaffold.cs b/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/ComponentScaffold.cs index dd61733..478a169 100644 --- a/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/ComponentScaffold.cs +++ b/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/ComponentScaffold.cs @@ -24,6 +24,7 @@ public static class ComponentScaffold ["FormParameter"] = ApplyFormParameter, ["FormEventHandler"] = ApplyFormEventHandler, ["ControlParameter"] = ApplyControlParameter, + ["Role"] = ApplySecurityRole, }; public static IReadOnlyCollection SupportedComponentTypes => Appliers.Keys; @@ -203,6 +204,14 @@ private static ScaffoldResult ApplyControlParameter(ComponentScaffoldRequest req ParametersFilePath = RequiredFile(request, "parameters"), }); + // Parameters: role-id (required). + private static ScaffoldResult ApplySecurityRole(ComponentScaffoldRequest request) => + SecurityRoleScaffold.Apply(new SecurityRoleScaffoldRequest + { + SolutionRootPath = request.SolutionRootPath, + RoleId = RequiredParameter(request, "role-id").Trim('{', '}'), + }); + private static FormPlacement PlacementFrom(ComponentScaffoldRequest request) => new() { TabId = OptionalId(request.Parameters, "tab-id"), diff --git a/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/SecurityRole/SecurityRoleScaffold.cs b/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/SecurityRole/SecurityRoleScaffold.cs new file mode 100644 index 0000000..ca5b742 --- /dev/null +++ b/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/SecurityRole/SecurityRoleScaffold.cs @@ -0,0 +1,22 @@ +using TALXIS.Platform.Metadata.Solutions; + +namespace TALXIS.Platform.Metadata.Serialization.Xml.Scaffolding; + +/// +/// In-process replacement for the pp-security-role template post-action script: +/// registers the rendered role in Solution.xml as a root component (type 20, by id). +/// Pilot of the SolutionRootComponentPatcher consumers - the applier only wires the patcher. +/// +public static class SecurityRoleScaffold +{ + public static ScaffoldResult Apply(SecurityRoleScaffoldRequest request) + { + SolutionRootComponentPatcher.EnsureRootComponent(request.SolutionRootPath, new RootComponent + { + Type = ComponentType.Role, + Id = Guid.Parse(request.RoleId), + Behavior = 0, + }); + return new ScaffoldResult(); + } +} diff --git a/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/SecurityRole/SecurityRoleScaffoldRequest.cs b/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/SecurityRole/SecurityRoleScaffoldRequest.cs new file mode 100644 index 0000000..485df83 --- /dev/null +++ b/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/SecurityRole/SecurityRoleScaffoldRequest.cs @@ -0,0 +1,18 @@ +namespace TALXIS.Platform.Metadata.Serialization.Xml.Scaffolding; + +/// +/// Input for : the solution to patch and the +/// rendered role to register in its manifest. +/// +public sealed class SecurityRoleScaffoldRequest +{ + /// + /// Folder containing the unpacked solution files (Other/, Entities/, OptionSets/). + /// + public string SolutionRootPath { get; set; } = ""; + + /// + /// GUID of the rendered security role, without braces. + /// + public string RoleId { get; set; } = ""; +} diff --git a/tests/TALXIS.Platform.Metadata.Tests/SecurityRoleScaffoldTests.cs b/tests/TALXIS.Platform.Metadata.Tests/SecurityRoleScaffoldTests.cs new file mode 100644 index 0000000..ed7d48a --- /dev/null +++ b/tests/TALXIS.Platform.Metadata.Tests/SecurityRoleScaffoldTests.cs @@ -0,0 +1,65 @@ +using System.Xml.Linq; +using TALXIS.Platform.Metadata.Serialization.Xml.Scaffolding; + +namespace TALXIS.Platform.Metadata.Tests; + +public class SecurityRoleScaffoldTests : IDisposable +{ + private const string RoleId = "b1b2c3d4-e5f6-4a1b-8c2d-000000000020"; + + private readonly string _root = Directory.CreateTempSubdirectory("metadata-security-role-scaffold").FullName; + private readonly string _solutionXmlPath; + + public SecurityRoleScaffoldTests() + { + Directory.CreateDirectory(Path.Combine(_root, "Other")); + _solutionXmlPath = Path.Combine(_root, "Other", "Solution.xml"); + File.WriteAllText(_solutionXmlPath, """ + udpp_Sandbox + """); + } + + public void Dispose() => Directory.Delete(_root, recursive: true); + + private IEnumerable RootComponentNodes() => + XDocument.Load(_solutionXmlPath).Descendants("RootComponent"); + + [Fact] + public void Apply_RegistersRoleRootComponent() + { + SecurityRoleScaffold.Apply(new SecurityRoleScaffoldRequest + { + SolutionRootPath = _root, + RoleId = RoleId, + }); + + var node = RootComponentNodes().Single(); + Assert.Equal("20", node.Attribute("type")?.Value); + Assert.Contains(RoleId, node.Attribute("id")?.Value, StringComparison.OrdinalIgnoreCase); + Assert.Equal("0", node.Attribute("behavior")?.Value); + } + + [Fact] + public void Apply_SameRole_IsNotDuplicated() + { + var request = new SecurityRoleScaffoldRequest { SolutionRootPath = _root, RoleId = RoleId }; + SecurityRoleScaffold.Apply(request); + SecurityRoleScaffold.Apply(request); + + Assert.Single(RootComponentNodes()); + } + + [Fact] + public void Dispatcher_ResolvesSecurityRoleAliasAndBracedId() + { + ComponentScaffold.Apply(new ComponentScaffoldRequest + { + ComponentType = "SecurityRole", + SolutionRootPath = _root, + Parameters = new Dictionary { ["role-id"] = "{" + RoleId + "}" }, + }); + + var node = RootComponentNodes().Single(); + Assert.Equal("20", node.Attribute("type")?.Value); + } +} From 411fb28e467f37291da47c1ada89353faeb9d1d3 Mon Sep 17 00:00:00 2001 From: Alexander Zekelin <128831809+zekelinAlex@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:30:01 +0200 Subject: [PATCH 2/3] fix: patch only the solution manifest in SolutionRootComponentPatcher --- .../Scaffolding/Helpers/SolutionRootComponentPatcher.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/Helpers/SolutionRootComponentPatcher.cs b/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/Helpers/SolutionRootComponentPatcher.cs index 7ebef2f..08b2419 100644 --- a/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/Helpers/SolutionRootComponentPatcher.cs +++ b/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/Helpers/SolutionRootComponentPatcher.cs @@ -21,7 +21,9 @@ public static bool EnsureRootComponent(string solutionRootPath, RootComponent co if (solution.RootComponents.Any(rc => Matches(rc, component))) return false; solution.AddRootComponent(component); - new XmlWorkspaceWriter().Write(workspace, solutionRootPath); + // Only the manifest changed - a full workspace write would rewrite (and reformat) + // every other component file in the solution as collateral. + new XmlWorkspaceWriter().WriteSolutionManifest(workspace, solution.UniqueName, solutionRootPath); return true; } From dc57d1abf8a7a237af6ec572377fc3970c9efc9a Mon Sep 17 00:00:00 2001 From: Alexander Zekelin <128831809+zekelinAlex@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:30:01 +0200 Subject: [PATCH 3/3] fix: indent children added to a previously empty container on write --- .../SecurityRole/SecurityRoleScaffold.cs | 5 +- .../XmlWorkspaceWriter.cs | 11 ++++ .../SolutionRootComponentPatcherTests.cs | 54 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/SecurityRole/SecurityRoleScaffold.cs b/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/SecurityRole/SecurityRoleScaffold.cs index ca5b742..1f2ca09 100644 --- a/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/SecurityRole/SecurityRoleScaffold.cs +++ b/src/TALXIS.Platform.Metadata.Serialization.Xml/Scaffolding/SecurityRole/SecurityRoleScaffold.cs @@ -3,9 +3,8 @@ namespace TALXIS.Platform.Metadata.Serialization.Xml.Scaffolding; /// -/// In-process replacement for the pp-security-role template post-action script: -/// registers the rendered role in Solution.xml as a root component (type 20, by id). -/// Pilot of the SolutionRootComponentPatcher consumers - the applier only wires the patcher. +/// Registers the rendered security role in Solution.xml as a root component +/// (type 20, by id) using SolutionRootComponentPatcher. /// public static class SecurityRoleScaffold { diff --git a/src/TALXIS.Platform.Metadata.Serialization.Xml/XmlWorkspaceWriter.cs b/src/TALXIS.Platform.Metadata.Serialization.Xml/XmlWorkspaceWriter.cs index ced5066..f3654c6 100644 --- a/src/TALXIS.Platform.Metadata.Serialization.Xml/XmlWorkspaceWriter.cs +++ b/src/TALXIS.Platform.Metadata.Serialization.Xml/XmlWorkspaceWriter.cs @@ -2024,6 +2024,17 @@ private static void ReplaceChildElementsPreservingWhitespace(XElement parent, IE .Select(text => text.Value) .LastOrDefault(ContainsNewLine); + // A previously childless container has no whitespace pattern to mimic - + // derive it from the container's own indentation so first-time children + // come out on indented lines instead of one inline run. + if ((childIndent == null || closingIndent == null) + && parent.PreviousNode is XText parentIndentText + && ContainsNewLine(parentIndentText.Value)) + { + childIndent = parentIndentText.Value + " "; + closingIndent = parentIndentText.Value; + } + parent.RemoveNodes(); if (childIndent == null || closingIndent == null || replacements.Count == 0) diff --git a/tests/TALXIS.Platform.Metadata.Tests/SolutionRootComponentPatcherTests.cs b/tests/TALXIS.Platform.Metadata.Tests/SolutionRootComponentPatcherTests.cs index 88b5de6..0c39723 100644 --- a/tests/TALXIS.Platform.Metadata.Tests/SolutionRootComponentPatcherTests.cs +++ b/tests/TALXIS.Platform.Metadata.Tests/SolutionRootComponentPatcherTests.cs @@ -73,6 +73,60 @@ public void EnsureRootComponent_AddsIdComponent() Assert.Single(RootComponentNodes()); } + [Fact] + public void EnsureRootComponent_DoesNotTouchSiblingFiles() + { + var rolePath = Path.Combine(_root, "Roles", "Example.xml"); + Directory.CreateDirectory(Path.Combine(_root, "Roles")); + File.WriteAllText(rolePath, """ + + + 1 + + + + """); + var relationshipsPath = Path.Combine(_root, "Other", "Relationships.xml"); + File.WriteAllText(relationshipsPath, """ + + + """); + var roleBytes = File.ReadAllBytes(rolePath); + var relationshipsBytes = File.ReadAllBytes(relationshipsPath); + + SolutionRootComponentPatcher.EnsureRootComponent(_root, new RootComponent + { + Type = ComponentType.Role, + Id = RoleId, + }); + + Assert.Equal(roleBytes, File.ReadAllBytes(rolePath)); + Assert.Equal(relationshipsBytes, File.ReadAllBytes(relationshipsPath)); + } + + [Fact] + public void EnsureRootComponent_IndentsFirstComponentInEmptyContainer() + { + File.WriteAllText(_solutionXmlPath, """ + + + udpp_Sandbox + + + + """); + + SolutionRootComponentPatcher.EnsureRootComponent(_root, new RootComponent + { + Type = ComponentType.Role, + Id = RoleId, + }); + + var text = File.ReadAllText(_solutionXmlPath); + Assert.Contains("\n ", text.Replace("\r\n", "\n")); + } + [Fact] public void EnsureRootComponent_MissingManifest_Throws() {