From 3841ee50cc7d6cc46c8fecf0091a771ab9498221 Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Mon, 17 Aug 2026 14:18:50 +0200 Subject: [PATCH 1/5] refactor: attach controls via platform-metadata library --- .../Controls/ControlAttachCliCommand.cs | 50 +++- .../Controls/ControlManifestInfo.cs | 50 ---- .../Controls/ControlManifestReader.cs | 157 ------------ .../Controls/FormControlAttachmentService.cs | 182 -------------- .../Controls/ControlManifestReaderTests.cs | 142 ----------- .../FormControlAttachmentServiceTests.cs | 231 ------------------ 6 files changed, 42 insertions(+), 770 deletions(-) delete mode 100644 src/TALXIS.CLI.Features.Workspace/Controls/ControlManifestInfo.cs delete mode 100644 src/TALXIS.CLI.Features.Workspace/Controls/ControlManifestReader.cs delete mode 100644 src/TALXIS.CLI.Features.Workspace/Controls/FormControlAttachmentService.cs delete mode 100644 tests/TALXIS.CLI.Tests/Workspace/Controls/ControlManifestReaderTests.cs delete mode 100644 tests/TALXIS.CLI.Tests/Workspace/Controls/FormControlAttachmentServiceTests.cs diff --git a/src/TALXIS.CLI.Features.Workspace/Controls/ControlAttachCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Controls/ControlAttachCliCommand.cs index 65feb63e..1d921861 100644 --- a/src/TALXIS.CLI.Features.Workspace/Controls/ControlAttachCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/Controls/ControlAttachCliCommand.cs @@ -1,8 +1,13 @@ +using System.Xml.Linq; using DotMake.CommandLine; using Microsoft.Extensions.Logging; using TALXIS.CLI.Core; using TALXIS.CLI.Core.Contracts.Packaging; using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata.Components; +using TALXIS.Platform.Metadata.Controls; +using TALXIS.Platform.Metadata.Serialization.Xml; +using TALXIS.Platform.Metadata.Serialization.Xml.Controls; using TALXIS.Platform.Metadata.Validation; namespace TALXIS.CLI.Features.Workspace.Controls; @@ -112,15 +117,23 @@ private int AttachFromManifest(string manifestSource) var formFile = ResolveFormFile(); var preErrors = CountSchemaErrors(formFile); - var result = FormControlAttachmentService.Attach(new ControlAttachmentRequest + ControlAttachmentResult result; + try + { + result = AttachToFormFile(formFile, new ControlAttachmentRequest + { + TargetControlId = TargetControlId, + Manifest = manifest, + ControlName = controlName, + Parameters = parameters, + Force = Force, + }); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("already attached")) { - FormFilePath = formFile, - TargetControlId = TargetControlId, - Manifest = manifest, - ControlName = controlName, - Parameters = parameters, - Force = Force, - }); + Logger.LogError("{Message} Use --force to replace it.", ex.Message); + return ExitValidationError; + } var postErrors = CountSchemaErrors(formFile); if (postErrors > preErrors) @@ -131,6 +144,27 @@ private int AttachFromManifest(string manifestSource) return ExitSuccess; } + // File-level adapter over the in-memory operation: load the form body into the + // metadata model, attach, and write the modified body back into the same document. + private static ControlAttachmentResult AttachToFormFile(string formFile, ControlAttachmentRequest request) + { + var doc = XDocument.Load(formFile); + var formElement = doc.Descendants("form").FirstOrDefault() + ?? throw new InvalidOperationException($"No
element in '{formFile}'."); + + var form = new FormMetadata + { + FormId = doc.Root?.Element("systemform")?.Element("formid")?.Value ?? NormalizeFormFileName(formFile), + Body = MergeableNodeXmlConverter.FromXElement(formElement), + }; + + var result = FormControlAttachment.Attach(form, request); + + formElement.ReplaceWith(MergeableNodeXmlConverter.ToXElement(form.Body!)); + doc.Save(formFile); + return result; + } + private string ResolveFormFile() { var formDir = Path.Combine(OutputPath, "Entities", EntityLogicalName, "FormXml", FormType); diff --git a/src/TALXIS.CLI.Features.Workspace/Controls/ControlManifestInfo.cs b/src/TALXIS.CLI.Features.Workspace/Controls/ControlManifestInfo.cs deleted file mode 100644 index d1460436..00000000 --- a/src/TALXIS.CLI.Features.Workspace/Controls/ControlManifestInfo.cs +++ /dev/null @@ -1,50 +0,0 @@ -namespace TALXIS.CLI.Features.Workspace.Controls; - -/// -/// Parsed PCF ControlManifest.xml — the runtime parameter schema of a custom control. -/// Replaces the per-control template approach: the manifest ships with the control itself, -/// so one generic attach command can validate and emit parameters for any control. -/// -public sealed class ControlManifestInfo -{ - /// Control namespace, e.g. TALXIS.PCF. - public required string Namespace { get; init; } - - /// Control constructor, e.g. Grid. - public required string Constructor { get; init; } - - /// Control version from the manifest. - public string? Version { get; init; } - - /// Unprefixed qualified name, e.g. TALXIS.PCF.Grid. - public string QualifiedName => $"{Namespace}.{Constructor}"; - - /// - /// Publisher-prefixed name used in FormXml (e.g. talxis_TALXIS.PCF.Grid). - /// Resolved from the accompanying solution's customizations.xml when the manifest - /// was read from a solution/package archive; null for a bare manifest file. - /// - public string? PrefixedName { get; set; } - - /// Dataset bindings declared by the control, in document order (e.g. Grid, RibbonGroupingDataset). - public IReadOnlyList DataSets { get; init; } = []; - - /// Input properties declared by the control. - public IReadOnlyList Properties { get; init; } = []; -} - -/// A single property element from a control manifest. -public sealed class ControlManifestProperty -{ - public required string Name { get; init; } - - /// Dataverse type from of-type (e.g. Enum, SingleLine.Text, Whole.None, Multiple). - public required string OfType { get; init; } - - public string? DefaultValue { get; init; } - - public bool Required { get; init; } - - /// Allowed values for Enum properties (the element texts of the value children). - public IReadOnlyList EnumValues { get; init; } = []; -} diff --git a/src/TALXIS.CLI.Features.Workspace/Controls/ControlManifestReader.cs b/src/TALXIS.CLI.Features.Workspace/Controls/ControlManifestReader.cs deleted file mode 100644 index 855664fc..00000000 --- a/src/TALXIS.CLI.Features.Workspace/Controls/ControlManifestReader.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.IO.Compression; -using System.Xml.Linq; - -namespace TALXIS.CLI.Features.Workspace.Controls; - -/// -/// Locates and parses a PCF ControlManifest.xml from a bare file or from inside -/// a control distribution archive (solution zip, Package Deployer pdpkg.zip, or NuGet nupkg — -/// archives are searched recursively, so a nupkg wrapping a pdpkg wrapping a solution works). -/// When the manifest comes from a solution archive, the publisher-prefixed control name -/// (e.g. talxis_TALXIS.PCF.Grid) is resolved from the solution's customizations.xml. -/// -public static class ControlManifestReader -{ - private const int MaxArchiveDepth = 3; - - public static ControlManifestInfo Read(string path) - { - if (!File.Exists(path)) - throw new FileNotFoundException($"Manifest source not found: {path}"); - - if (path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) - { - using var stream = File.OpenRead(path); - return ParseManifest(stream); - } - - using var archiveStream = File.OpenRead(path); - var result = SearchArchive(archiveStream, MaxArchiveDepth); - if (result.Manifest == null) - throw new InvalidOperationException($"No ControlManifest.xml found inside '{path}' (searched nested archives up to {MaxArchiveDepth} levels)."); - - result.Manifest.PrefixedName = ResolvePrefixedName(result); - return result.Manifest; - } - - private sealed class ArchiveSearchResult - { - public ControlManifestInfo? Manifest { get; set; } - /// The archive entry path the manifest was found at (e.g. Controls/talxis_TALXIS.PCF.Grid/ControlManifest.xml). - public string? ManifestEntryPath { get; set; } - /// CustomControl names declared in customizations.xml of the same solution archive. - public List CustomControlNames { get; } = []; - } - - private static ArchiveSearchResult SearchArchive(Stream stream, int depthBudget) - { - var result = new ArchiveSearchResult(); - using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); - - foreach (var entry in archive.Entries) - { - if (entry.Name.Equals("ControlManifest.xml", StringComparison.OrdinalIgnoreCase) && result.Manifest == null) - { - using var entryStream = entry.Open(); - result.Manifest = ParseManifest(entryStream); - result.ManifestEntryPath = entry.FullName; - } - else if (entry.Name.Equals("customizations.xml", StringComparison.OrdinalIgnoreCase)) - { - using var entryStream = entry.Open(); - result.CustomControlNames.AddRange(ReadCustomControlNames(entryStream)); - } - } - - if (result.Manifest != null || depthBudget <= 0) - return result; - - foreach (var entry in archive.Entries) - { - if (!IsNestedArchive(entry.Name)) - continue; - using var entryStream = entry.Open(); - using var buffered = CopyToMemory(entryStream); - var nested = SearchArchive(buffered, depthBudget - 1); - if (nested.Manifest != null) - return nested; - } - - return result; - } - - private static bool IsNestedArchive(string name) => - name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || - name.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase); - - // ZipArchive entry streams are not seekable, but nested ZipArchive needs a seekable stream. - private static MemoryStream CopyToMemory(Stream source) - { - var memory = new MemoryStream(); - source.CopyTo(memory); - memory.Position = 0; - return memory; - } - - private static string? ResolvePrefixedName(ArchiveSearchResult result) - { - if (result.Manifest == null) - return null; - - // The solution declares the prefixed name; match on the qualified-name suffix. - var suffix = "_" + result.Manifest.QualifiedName; - var fromCustomizations = result.CustomControlNames.FirstOrDefault(n => n.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); - if (fromCustomizations != null) - return fromCustomizations; - - // Fall back to the folder name in the solution layout (Controls//ControlManifest.xml). - var folder = Path.GetDirectoryName(result.ManifestEntryPath)?.Replace('\\', '/').Split('/').LastOrDefault(); - if (!string.IsNullOrEmpty(folder) && folder.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) - return folder; - - return null; - } - - private static IEnumerable ReadCustomControlNames(Stream customizationsStream) - { - var doc = XDocument.Load(customizationsStream); - return doc.Descendants("CustomControl") - .Select(c => c.Element("Name")?.Value) - .Where(n => !string.IsNullOrEmpty(n)) - .Select(n => n!) - .ToList(); - } - - private static ControlManifestInfo ParseManifest(Stream stream) - { - var doc = XDocument.Load(stream); - var control = doc.Descendants("control").FirstOrDefault() - ?? throw new InvalidOperationException("Invalid control manifest: no element."); - - var ns = control.Attribute("namespace")?.Value - ?? throw new InvalidOperationException("Invalid control manifest: has no 'namespace' attribute."); - var constructor = control.Attribute("constructor")?.Value - ?? throw new InvalidOperationException("Invalid control manifest: has no 'constructor' attribute."); - - var properties = control.Elements("property") - .Select(p => new ControlManifestProperty - { - Name = p.Attribute("name")?.Value ?? "", - OfType = p.Attribute("of-type")?.Value ?? p.Attribute("of-type-group")?.Value ?? "SingleLine.Text", - DefaultValue = p.Attribute("default-value")?.Value, - Required = string.Equals(p.Attribute("required")?.Value, "true", StringComparison.OrdinalIgnoreCase), - EnumValues = p.Elements("value").Select(v => v.Value.Trim()).ToList(), - }) - .Where(p => p.Name.Length > 0) - .ToList(); - - return new ControlManifestInfo - { - Namespace = ns, - Constructor = constructor, - Version = control.Attribute("version")?.Value, - DataSets = control.Elements("data-set").Select(d => d.Attribute("name")?.Value ?? "").Where(n => n.Length > 0).ToList(), - Properties = properties, - }; - } -} diff --git a/src/TALXIS.CLI.Features.Workspace/Controls/FormControlAttachmentService.cs b/src/TALXIS.CLI.Features.Workspace/Controls/FormControlAttachmentService.cs deleted file mode 100644 index dade298d..00000000 --- a/src/TALXIS.CLI.Features.Workspace/Controls/FormControlAttachmentService.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System.Xml.Linq; - -namespace TALXIS.CLI.Features.Workspace.Controls; - -/// Request to overlay a custom control on an existing form control. -public sealed class ControlAttachmentRequest -{ - public required string FormFilePath { get; init; } - /// FormXml control id of the host control (e.g. subgrid). - public required string TargetControlId { get; init; } - public required ControlManifestInfo Manifest { get; init; } - /// Publisher-prefixed control name for FormXml (e.g. talxis_TALXIS.PCF.Grid). - public required string ControlName { get; init; } - /// Control parameter values keyed by manifest property name. - public IReadOnlyDictionary Parameters { get; init; } = new Dictionary(); - /// Replace an existing attachment on the same host control instead of failing. - public bool Force { get; init; } -} - -public sealed class ControlAttachmentResult -{ - public required string FormFilePath { get; init; } - public required string ControlName { get; init; } - public required string HostControlUniqueId { get; init; } - public bool ReplacedExisting { get; init; } -} - -/// -/// Overlays a PCF custom control on an existing subgrid of a form by inserting the -/// controlDescriptions/controlDescription node (base control block + one -/// customControl block per form factor), mirroring the shape produced by the -/// Dataverse form designer. Parameter values are validated against the control manifest, -/// and dataset binding values (ViewId, TargetEntityType, RelationshipName) are read from -/// the host subgrid so they never need to be supplied by hand. -/// -public static class FormControlAttachmentService -{ - private const string SubgridClassId = "{E7A81278-8635-4D9E-8D4D-59480B391C5B}"; - - public static ControlAttachmentResult Attach(ControlAttachmentRequest request) - { - var doc = XDocument.Load(request.FormFilePath); - var form = doc.Descendants("form").FirstOrDefault() - ?? throw new InvalidOperationException($"No element in '{request.FormFilePath}'."); - - var host = FindHostControl(form, request.TargetControlId); - var uniqueId = host.Attribute("uniqueid")?.Value - ?? throw new InvalidOperationException($"Host control '{request.TargetControlId}' has no uniqueid attribute."); - - ValidateParameters(request.Manifest, request.Parameters); - - var parameters = BuildParametersElement(request, host); - var description = new XElement("controlDescription", - new XAttribute("forControl", uniqueId), - new XElement("customControl", - new XAttribute("id", SubgridClassId), - new XElement("parameters"))); - foreach (var formFactor in new[] { "0", "1", "2" }) - { - description.Add(new XElement("customControl", - new XAttribute("formFactor", formFactor), - new XAttribute("name", request.ControlName), - new XElement(parameters))); - } - - var replaced = InsertDescription(form, description, uniqueId, request.Force); - doc.Save(request.FormFilePath); - - return new ControlAttachmentResult - { - FormFilePath = request.FormFilePath, - ControlName = request.ControlName, - HostControlUniqueId = uniqueId, - ReplacedExisting = replaced, - }; - } - - private static XElement FindHostControl(XElement form, string targetControlId) - { - var host = form.Descendants("control").FirstOrDefault(c => c.Attribute("id")?.Value == targetControlId) - ?? throw new InvalidOperationException( - $"Control '{targetControlId}' not found on the form. Available controls: " + - string.Join(", ", form.Descendants("control").Select(c => c.Attribute("id")?.Value).Where(id => id != null))); - - if (!string.Equals(host.Attribute("indicationOfSubgrid")?.Value, "true", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Control '{targetControlId}' is not a subgrid. Only subgrid-bound controls are supported; " + - "field-bound controls (e.g. VirtualDataset on a placeholder column) are not supported yet."); - } - - return host; - } - - private static void ValidateParameters(ControlManifestInfo manifest, IReadOnlyDictionary values) - { - var byName = manifest.Properties.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); - var errors = new List(); - - foreach (var (name, value) in values) - { - if (!byName.TryGetValue(name, out var property)) - { - errors.Add($"'{name}' is not a parameter of {manifest.QualifiedName}. Valid parameters: {string.Join(", ", manifest.Properties.Select(p => p.Name))}."); - continue; - } - if (property.EnumValues.Count > 0 && !property.EnumValues.Contains(value, StringComparer.Ordinal)) - errors.Add($"'{name}': '{value}' is not an allowed value. Allowed: {string.Join(", ", property.EnumValues)}."); - else if (property.OfType.StartsWith("Whole.", StringComparison.Ordinal) && !long.TryParse(value, out _)) - errors.Add($"'{name}': '{value}' is not a whole number ({property.OfType})."); - else if (property.OfType == "TwoOptions" && value is not ("true" or "false")) - errors.Add($"'{name}': '{value}' must be 'true' or 'false' (TwoOptions)."); - } - - if (errors.Count > 0) - throw new ArgumentException("Invalid control parameters:\n " + string.Join("\n ", errors)); - } - - private static XElement BuildParametersElement(ControlAttachmentRequest request, XElement host) - { - var parameters = new XElement("parameters"); - - // Dataset binding mirrors the host subgrid; the control's primary data-set gets the same view. - var primaryDataSet = request.Manifest.DataSets.FirstOrDefault(); - if (primaryDataSet != null) - { - var hostParameters = host.Element("parameters"); - var viewId = hostParameters?.Element("ViewId")?.Value ?? ""; - parameters.Add(new XElement("data-set", - new XAttribute("name", primaryDataSet), - new XElement("ViewId", viewId), - new XElement("TargetEntityType", hostParameters?.Element("TargetEntityType")?.Value ?? ""), - new XElement("IsUserView", "false"), - new XElement("EnableViewPicker", hostParameters?.Element("EnableViewPicker")?.Value ?? "false"), - new XElement("RelationshipName", hostParameters?.Element("RelationshipName")?.Value ?? ""), - new XElement("FilteredViewIds", viewId))); - } - - var byName = request.Manifest.Properties.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); - foreach (var (name, value) in request.Parameters) - { - var property = byName[name]; - parameters.Add(new XElement(property.Name, - new XAttribute("type", property.OfType), - new XAttribute("static", "true"), - value)); - } - - return parameters; - } - - /// True when an existing attachment for the same host control was replaced. - private static bool InsertDescription(XElement form, XElement description, string uniqueId, bool force) - { - var container = form.Element("controlDescriptions"); - if (container == null) - { - container = new XElement("controlDescriptions"); - // Match the designer's element order: controlDescriptions precedes - // DisplayConditions / formLibraries / events. - var anchor = form.Element("DisplayConditions") ?? form.Element("formLibraries") ?? form.Element("events") as XNode; - if (anchor != null) anchor.AddBeforeSelf(container); - else form.Add(container); - } - - var existing = container.Elements("controlDescription") - .FirstOrDefault(d => string.Equals(d.Attribute("forControl")?.Value, uniqueId, StringComparison.OrdinalIgnoreCase)); - if (existing != null) - { - if (!force) - { - throw new InvalidOperationException( - $"A custom control is already attached to control '{uniqueId}'. Use --force to replace it."); - } - existing.ReplaceWith(description); - return true; - } - - container.Add(description); - return false; - } -} diff --git a/tests/TALXIS.CLI.Tests/Workspace/Controls/ControlManifestReaderTests.cs b/tests/TALXIS.CLI.Tests/Workspace/Controls/ControlManifestReaderTests.cs deleted file mode 100644 index f06dba2e..00000000 --- a/tests/TALXIS.CLI.Tests/Workspace/Controls/ControlManifestReaderTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.IO.Compression; -using System.Text; -using TALXIS.CLI.Features.Workspace.Controls; -using Xunit; - -namespace TALXIS.CLI.Tests.Workspace.Controls; - -public class ControlManifestReaderTests : IDisposable -{ - private const string GridManifestXml = """ - - - - - - - - - - true - false - - - - - """; - - private const string CustomizationsXml = """ - - - - - talxis_TALXIS.PCF.Grid - - - - """; - - private readonly string _tempDir = Directory.CreateTempSubdirectory("txc-manifest-tests").FullName; - - public void Dispose() => Directory.Delete(_tempDir, recursive: true); - - private string WriteFile(string name, byte[] content) - { - var path = Path.Combine(_tempDir, name); - File.WriteAllBytes(path, content); - return path; - } - - private static byte[] BuildZip(params (string EntryName, byte[] Content)[] entries) - { - using var stream = new MemoryStream(); - using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) - { - foreach (var (name, content) in entries) - { - using var entryStream = archive.CreateEntry(name).Open(); - entryStream.Write(content); - } - } - return stream.ToArray(); - } - - [Fact] - public void Read_BareManifestFile_ParsesControlAndProperties() - { - var path = WriteFile("ControlManifest.xml", Encoding.UTF8.GetBytes(GridManifestXml)); - - var manifest = ControlManifestReader.Read(path); - - Assert.Equal("TALXIS.PCF", manifest.Namespace); - Assert.Equal("Grid", manifest.Constructor); - Assert.Equal("TALXIS.PCF.Grid", manifest.QualifiedName); - Assert.Equal("0.0.59648", manifest.Version); - Assert.Equal(new[] { "Grid", "RibbonGroupingDataset" }, manifest.DataSets); - Assert.Null(manifest.PrefixedName); - - var enableEditing = manifest.Properties.Single(p => p.Name == "EnableEditing"); - Assert.Equal("Enum", enableEditing.OfType); - Assert.Equal(new[] { "true", "false" }, enableEditing.EnumValues); - - var rowHeight = manifest.Properties.Single(p => p.Name == "RowHeight"); - Assert.Equal("Whole.None", rowHeight.OfType); - Assert.Equal("42", rowHeight.DefaultValue); - } - - [Fact] - public void Read_SolutionZip_ResolvesPrefixedNameFromCustomizations() - { - var zip = BuildZip( - ("customizations.xml", Encoding.UTF8.GetBytes(CustomizationsXml)), - ("Controls/talxis_TALXIS.PCF.Grid/ControlManifest.xml", Encoding.UTF8.GetBytes(GridManifestXml))); - var path = WriteFile("Grid.Solution.zip", zip); - - var manifest = ControlManifestReader.Read(path); - - Assert.Equal("TALXIS.PCF.Grid", manifest.QualifiedName); - Assert.Equal("talxis_TALXIS.PCF.Grid", manifest.PrefixedName); - } - - [Fact] - public void Read_NestedArchives_FindsManifestThroughPdpkgAndSolution() - { - var solutionZip = BuildZip( - ("customizations.xml", Encoding.UTF8.GetBytes(CustomizationsXml)), - ("Controls/talxis_TALXIS.PCF.Grid/ControlManifest.xml", Encoding.UTF8.GetBytes(GridManifestXml))); - var pdpkgZip = BuildZip(("PkgAssets/Grid.Solution.zip", solutionZip)); - var nupkg = BuildZip(("contentFiles/any/any/Grid.pdpkg.zip", pdpkgZip)); - var path = WriteFile("grid.nupkg", nupkg); - - var manifest = ControlManifestReader.Read(path); - - Assert.Equal("TALXIS.PCF.Grid", manifest.QualifiedName); - Assert.Equal("talxis_TALXIS.PCF.Grid", manifest.PrefixedName); - } - - [Fact] - public void Read_SolutionZipWithoutCustomizations_FallsBackToControlsFolderName() - { - var zip = BuildZip(("Controls/talxis_TALXIS.PCF.Grid/ControlManifest.xml", Encoding.UTF8.GetBytes(GridManifestXml))); - var path = WriteFile("NoCustomizations.zip", zip); - - var manifest = ControlManifestReader.Read(path); - - Assert.Equal("talxis_TALXIS.PCF.Grid", manifest.PrefixedName); - } - - [Fact] - public void Read_ZipWithoutManifest_Throws() - { - var zip = BuildZip(("readme.txt", Encoding.UTF8.GetBytes("nothing here"))); - var path = WriteFile("empty.zip", zip); - - Assert.Throws(() => ControlManifestReader.Read(path)); - } - - [Fact] - public void Read_MissingFile_Throws() - { - Assert.Throws(() => ControlManifestReader.Read(Path.Combine(_tempDir, "missing.xml"))); - } -} diff --git a/tests/TALXIS.CLI.Tests/Workspace/Controls/FormControlAttachmentServiceTests.cs b/tests/TALXIS.CLI.Tests/Workspace/Controls/FormControlAttachmentServiceTests.cs deleted file mode 100644 index d61478ff..00000000 --- a/tests/TALXIS.CLI.Tests/Workspace/Controls/FormControlAttachmentServiceTests.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System.Xml.Linq; -using TALXIS.CLI.Features.Workspace.Controls; -using Xunit; - -namespace TALXIS.CLI.Tests.Workspace.Controls; - -public class FormControlAttachmentServiceTests : IDisposable -{ - private const string SubgridUniqueId = "{bbbb2222-0000-0000-0000-000000000002}"; - private const string ViewId = "{cccc3333-0000-0000-0000-000000000003}"; - - private const string FormXml = $$""" - - - - {aaaa1111-0000-0000-0000-000000000001} - - - - -
- - - - - - almlab_warehouseitem - {{ViewId}} - {{ViewId}} - false - almlab_location_item - - - - - - - - - - -
-
-
-
- - - -
-
- """; - - private readonly string _tempDir = Directory.CreateTempSubdirectory("txc-attach-tests").FullName; - private readonly string _formPath; - - public FormControlAttachmentServiceTests() - { - _formPath = Path.Combine(_tempDir, "{aaaa1111-0000-0000-0000-000000000001}.xml"); - File.WriteAllText(_formPath, FormXml); - } - - public void Dispose() => Directory.Delete(_tempDir, recursive: true); - - private static ControlManifestInfo GridManifest() => new() - { - Namespace = "TALXIS.PCF", - Constructor = "Grid", - PrefixedName = "talxis_TALXIS.PCF.Grid", - DataSets = ["Grid", "RibbonGroupingDataset"], - Properties = - [ - new ControlManifestProperty { Name = "Columns", OfType = "Multiple" }, - new ControlManifestProperty { Name = "RowHeight", OfType = "Whole.None", DefaultValue = "42" }, - new ControlManifestProperty { Name = "EnableGrouping", OfType = "Enum", EnumValues = ["true", "false"] }, - new ControlManifestProperty { Name = "ClientApiWebresourceName", OfType = "SingleLine.Text" }, - ], - }; - - private ControlAttachmentRequest Request( - IReadOnlyDictionary? parameters = null, - string targetControlId = "subgrid", - bool force = false) => new() - { - FormFilePath = _formPath, - TargetControlId = targetControlId, - Manifest = GridManifest(), - ControlName = "talxis_TALXIS.PCF.Grid", - Parameters = parameters ?? new Dictionary(), - Force = force, - }; - - [Fact] - public void Attach_InsertsControlDescriptionWithAllFormFactors() - { - var result = FormControlAttachmentService.Attach(Request(new Dictionary - { - ["EnableGrouping"] = "true", - ["RowHeight"] = "42", - })); - - Assert.Equal(SubgridUniqueId, result.HostControlUniqueId); - Assert.False(result.ReplacedExisting); - - var doc = XDocument.Load(_formPath); - var description = doc.Descendants("controlDescription").Single(); - Assert.Equal(SubgridUniqueId, description.Attribute("forControl")?.Value); - - var customControls = description.Elements("customControl").ToList(); - Assert.Equal(4, customControls.Count); - Assert.Equal("{E7A81278-8635-4D9E-8D4D-59480B391C5B}", customControls[0].Attribute("id")?.Value); - Assert.Equal(new[] { "0", "1", "2" }, customControls.Skip(1).Select(c => c.Attribute("formFactor")?.Value)); - Assert.All(customControls.Skip(1), c => Assert.Equal("talxis_TALXIS.PCF.Grid", c.Attribute("name")?.Value)); - } - - [Fact] - public void Attach_CopiesDatasetBindingFromHostSubgrid() - { - FormControlAttachmentService.Attach(Request()); - - var doc = XDocument.Load(_formPath); - var dataSet = doc.Descendants("customControl") - .First(c => c.Attribute("formFactor")?.Value == "0") - .Element("parameters")!.Element("data-set")!; - - Assert.Equal("Grid", dataSet.Attribute("name")?.Value); - Assert.Equal(ViewId, dataSet.Element("ViewId")?.Value); - Assert.Equal("almlab_warehouseitem", dataSet.Element("TargetEntityType")?.Value); - Assert.Equal("almlab_location_item", dataSet.Element("RelationshipName")?.Value); - Assert.Equal(ViewId, dataSet.Element("FilteredViewIds")?.Value); - Assert.Equal("false", dataSet.Element("IsUserView")?.Value); - } - - [Fact] - public void Attach_EmitsTypedParameterElements() - { - FormControlAttachmentService.Attach(Request(new Dictionary - { - ["EnableGrouping"] = "true", - ["ClientApiWebresourceName"] = "almlab_main.js", - })); - - var doc = XDocument.Load(_formPath); - var parameters = doc.Descendants("customControl") - .First(c => c.Attribute("formFactor")?.Value == "0") - .Element("parameters")!; - - var grouping = parameters.Element("EnableGrouping")!; - Assert.Equal("Enum", grouping.Attribute("type")?.Value); - Assert.Equal("true", grouping.Attribute("static")?.Value); - Assert.Equal("true", grouping.Value); - - var webresource = parameters.Element("ClientApiWebresourceName")!; - Assert.Equal("SingleLine.Text", webresource.Attribute("type")?.Value); - Assert.Equal("almlab_main.js", webresource.Value); - } - - [Fact] - public void Attach_InsertsContainerBeforeDisplayConditions() - { - FormControlAttachmentService.Attach(Request()); - - var doc = XDocument.Load(_formPath); - var formChildren = doc.Descendants("form").Single().Elements().Select(e => e.Name.LocalName).ToList(); - Assert.Equal(["tabs", "controlDescriptions", "DisplayConditions", "formLibraries"], formChildren); - } - - [Fact] - public void Attach_UnknownParameter_ThrowsWithValidParameterList() - { - var ex = Assert.Throws(() => - FormControlAttachmentService.Attach(Request(new Dictionary { ["Nope"] = "1" }))); - Assert.Contains("not a parameter", ex.Message); - Assert.Contains("EnableGrouping", ex.Message); - } - - [Fact] - public void Attach_InvalidEnumValue_Throws() - { - var ex = Assert.Throws(() => - FormControlAttachmentService.Attach(Request(new Dictionary { ["EnableGrouping"] = "banana" }))); - Assert.Contains("Allowed: true, false", ex.Message); - } - - [Fact] - public void Attach_NonNumericWholeValue_Throws() - { - var ex = Assert.Throws(() => - FormControlAttachmentService.Attach(Request(new Dictionary { ["RowHeight"] = "tall" }))); - Assert.Contains("whole number", ex.Message); - } - - [Fact] - public void Attach_MissingTargetControl_ThrowsListingAvailableControls() - { - var ex = Assert.Throws(() => - FormControlAttachmentService.Attach(Request(targetControlId: "nosuchgrid"))); - Assert.Contains("subgrid", ex.Message); - } - - [Fact] - public void Attach_FieldBoundControl_Throws() - { - var ex = Assert.Throws(() => - FormControlAttachmentService.Attach(Request(targetControlId: "almlab_name"))); - Assert.Contains("not a subgrid", ex.Message); - } - - [Fact] - public void Attach_ExistingAttachment_ThrowsWithoutForce() - { - FormControlAttachmentService.Attach(Request()); - - var ex = Assert.Throws(() => FormControlAttachmentService.Attach(Request())); - Assert.Contains("--force", ex.Message); - } - - [Fact] - public void Attach_ExistingAttachment_ReplacedWithForce() - { - FormControlAttachmentService.Attach(Request(new Dictionary { ["EnableGrouping"] = "true" })); - var result = FormControlAttachmentService.Attach(Request(new Dictionary { ["EnableGrouping"] = "false" }, force: true)); - - Assert.True(result.ReplacedExisting); - var doc = XDocument.Load(_formPath); - Assert.Single(doc.Descendants("controlDescription")); - var grouping = doc.Descendants("customControl") - .First(c => c.Attribute("formFactor")?.Value == "0") - .Element("parameters")!.Element("EnableGrouping")!; - Assert.Equal("false", grouping.Value); - } -} From 28c8e9029e7bbbbde2db042bbc03452b11e87a9d Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Mon, 17 Aug 2026 14:27:40 +0200 Subject: [PATCH 2/5] feat: add workspace entity attribute-import command --- .../EntityAttributeImportCliCommand.cs | 88 +++++++++++++++++++ .../Entities/EntityCliCommand.cs | 23 +++++ .../WorkspaceCliCommand.cs | 2 + 3 files changed, 113 insertions(+) create mode 100644 src/TALXIS.CLI.Features.Workspace/Entities/EntityAttributeImportCliCommand.cs create mode 100644 src/TALXIS.CLI.Features.Workspace/Entities/EntityCliCommand.cs diff --git a/src/TALXIS.CLI.Features.Workspace/Entities/EntityAttributeImportCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Entities/EntityAttributeImportCliCommand.cs new file mode 100644 index 00000000..f49ac6df --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/Entities/EntityAttributeImportCliCommand.cs @@ -0,0 +1,88 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata.Serialization.Xml.Scaffolding; + +namespace TALXIS.CLI.Features.Workspace.Entities; + +/// +/// Applies a rendered pp-entity-attribute scaffold to the solution in one in-process +/// transaction: option set options, attribute import into Entity.xml, money support +/// attributes, lookup relationship files, attribute sorting, and nil-tag normalization. +/// Replaces the template's PowerShell post-action scripts. +/// +[CliIdempotent] +[CliCommand( + Description = "Import a rendered attribute scaffold into an entity (used by the pp-entity-attribute template)", + Name = "attribute-import")] +public class EntityAttributeImportCliCommand : TxcLeafCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EntityAttributeImportCliCommand)); + + [CliOption(Name = "--solution-root", Description = "Folder containing the unpacked solution files (Other/, Entities/, OptionSets/)", Required = true)] + public string SolutionRoot { get; set; } = null!; + + [CliOption(Name = "--entity", Description = "Schema name of the entity that receives the attribute (e.g. udpp_warehouseitem)", Required = true)] + public string EntitySchemaName { get; set; } = null!; + + [CliOption(Name = "--attribute-file", Description = "Path to the rendered XML file", Required = true)] + public string AttributeFile { get; set; } = null!; + + [CliOption(Name = "--options", Description = "Choice options: comma-separated labels or Label:Value pairs (e.g. Active:100000000,Inactive)", Required = false)] + public string? Options { get; set; } + + [CliOption(Name = "--global-optionset-file", Description = "Path to the rendered global option set file; options are written there instead of the attribute", Required = false)] + public string? GlobalOptionSetFile { get; set; } + + [CliOption(Name = "--global-optionset-name", Description = "Schema name of the global option set to register as a RootComponent (type 9)", Required = false)] + public string? GlobalOptionSetName { get; set; } + + [CliOption(Name = "--money-base-file", Description = "Path to the rendered money base attribute file", Required = false)] + public string? MoneyBaseFile { get; set; } + + [CliOption(Name = "--currency-file", Description = "Path to the rendered transactioncurrencyid attribute file", Required = false)] + public string? CurrencyFile { get; set; } + + [CliOption(Name = "--exchange-rate-file", Description = "Path to the rendered exchangerate attribute file", Required = false)] + public string? ExchangeRateFile { get; set; } + + [CliOption(Name = "--relationship-file", Description = "Path to the rendered lookup EntityRelationship XML file", Required = false)] + public string? RelationshipFile { get; set; } + + [CliOption(Name = "--relationship-name", Description = "Name of the lookup relationship", Required = false)] + public string? RelationshipName { get; set; } + + [CliOption(Name = "--referenced-entity", Description = "Logical name of the entity the lookup points to (e.g. account)", Required = false)] + public string? ReferencedEntity { get; set; } + + protected override Task ExecuteAsync() + { + var request = new EntityAttributeScaffoldRequest + { + SolutionRootPath = Path.GetFullPath(SolutionRoot), + EntitySchemaName = EntitySchemaName, + AttributeFilePath = Path.GetFullPath(AttributeFile), + OptionSetOptions = Options, + GlobalOptionSetFilePath = FullPathOrNull(GlobalOptionSetFile), + GlobalOptionSetSchemaName = GlobalOptionSetName, + MoneyBaseAttributeFilePath = FullPathOrNull(MoneyBaseFile), + CurrencyAttributeFilePath = FullPathOrNull(CurrencyFile), + ExchangeRateAttributeFilePath = FullPathOrNull(ExchangeRateFile), + LookupRelationshipFilePath = FullPathOrNull(RelationshipFile), + LookupRelationshipName = RelationshipName, + ReferencedEntityName = ReferencedEntity, + }; + + var result = EntityAttributeScaffold.Apply(request); + foreach (var warning in result.Warnings) + { + Logger.LogWarning("{Warning}", warning); + } + + OutputFormatter.WriteResult("succeeded", $"Attribute scaffold applied to '{EntitySchemaName}'"); + return Task.FromResult(ExitSuccess); + } + + private static string? FullPathOrNull(string? path) => path == null ? null : Path.GetFullPath(path); +} diff --git a/src/TALXIS.CLI.Features.Workspace/Entities/EntityCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Entities/EntityCliCommand.cs new file mode 100644 index 00000000..b189fa86 --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/Entities/EntityCliCommand.cs @@ -0,0 +1,23 @@ +using DotMake.CommandLine; + +namespace TALXIS.CLI.Features.Workspace.Entities; + +/// +/// Entity operations on the local workspace, executed in-process through the +/// platform metadata library instead of template post-action scripts. +/// +[CliCommand( + Description = "Modify entities in your local workspace", + Name = "entity", + Children = new[] + { + typeof(EntityAttributeImportCliCommand), + }, + ShortFormAutoGenerate = CliNameAutoGenerate.None)] +public class EntityCliCommand +{ + public void Run(CliContext context) + { + context.ShowHelp(); + } +} diff --git a/src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs index 424d5f3c..3ec989d5 100644 --- a/src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs @@ -1,5 +1,6 @@ using DotMake.CommandLine; using TALXIS.CLI.Features.Workspace.Controls; +using TALXIS.CLI.Features.Workspace.Entities; namespace TALXIS.CLI.Features.Workspace; @@ -10,6 +11,7 @@ namespace TALXIS.CLI.Features.Workspace; { typeof(ComponentCliCommand), typeof(ControlCliCommand), + typeof(EntityCliCommand), typeof(ProjectCliCommand), typeof(WorkspaceExplainCliCommand), typeof(WorkspaceValidateCliCommand) From 549c3c6a6886ff3a1734f1bf04880d49ca75185e Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Mon, 17 Aug 2026 14:40:29 +0200 Subject: [PATCH 3/5] chore: bump platform metadata packages to 13.0.0 --- .../ComponentApplyScaffoldCliCommand.cs | 64 ++++++++++++++ .../ComponentCliCommand.cs | 1 + .../EntityAttributeImportCliCommand.cs | 88 ------------------- .../Entities/EntityCliCommand.cs | 23 ----- .../WorkspaceCliCommand.cs | 2 - 5 files changed, 65 insertions(+), 113 deletions(-) create mode 100644 src/TALXIS.CLI.Features.Workspace/ComponentApplyScaffoldCliCommand.cs delete mode 100644 src/TALXIS.CLI.Features.Workspace/Entities/EntityAttributeImportCliCommand.cs delete mode 100644 src/TALXIS.CLI.Features.Workspace/Entities/EntityCliCommand.cs diff --git a/src/TALXIS.CLI.Features.Workspace/ComponentApplyScaffoldCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentApplyScaffoldCliCommand.cs new file mode 100644 index 00000000..8eca8a92 --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/ComponentApplyScaffoldCliCommand.cs @@ -0,0 +1,64 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata.Serialization.Xml.Scaffolding; + +namespace TALXIS.CLI.Features.Workspace; + +/// +/// Applies a rendered component scaffold to the solution in one in-process transaction. +/// Component-agnostic: the component type selects the applier inside the platform metadata +/// library, and payload is passed as role=path files and name=value parameters. Replaces +/// template PowerShell post-action scripts during the template-to-metadata migration. +/// +[CliIdempotent] +[CliCommand( + Description = "Apply a rendered component scaffold to the solution (used by template post-actions)", + Name = "apply-scaffold")] +public class ComponentApplyScaffoldCliCommand : TxcLeafCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ComponentApplyScaffoldCliCommand)); + + [CliOption(Name = "--component-type", Description = "Component type matching the template's componentType tag (e.g. Attribute)", Required = true)] + public string ComponentType { get; set; } = null!; + + [CliOption(Name = "--solution-root", Description = "Folder containing the unpacked solution files (Other/, Entities/, OptionSets/)", Required = true)] + public string SolutionRoot { get; set; } = null!; + + [CliOption(Name = "--file", Description = "Rendered file in role=path format (e.g. attribute=.template.temp/attribute.xml). Can be specified multiple times.")] + public List File { get; set; } = new(); + + [CliOption(Name = "--param", Description = "Scalar parameter in name=value format (e.g. entity=udpp_warehouseitem). Can be specified multiple times.")] + public List Param { get; set; } = new(); + + protected override Task ExecuteAsync() + { + var result = ComponentScaffold.Apply(new ComponentScaffoldRequest + { + ComponentType = ComponentType, + SolutionRootPath = Path.GetFullPath(SolutionRoot), + Files = ParsePairs(File, "--file").ToDictionary(p => p.Key, p => Path.GetFullPath(p.Value), StringComparer.OrdinalIgnoreCase), + Parameters = ParsePairs(Param, "--param").ToDictionary(p => p.Key, p => p.Value, StringComparer.OrdinalIgnoreCase), + }); + + foreach (var warning in result.Warnings) + { + Logger.LogWarning("{Warning}", warning); + } + + OutputFormatter.WriteResult("succeeded", $"{ComponentType} scaffold applied"); + return Task.FromResult(ExitSuccess); + } + + private static IEnumerable> ParsePairs(IEnumerable pairs, string optionName) + { + foreach (var pair in pairs) + { + var idx = pair.IndexOf('='); + if (idx <= 0 || idx == pair.Length - 1) + throw new ArgumentException($"Invalid {optionName} format: '{pair}'. Use key=value."); + yield return new KeyValuePair(pair.Substring(0, idx), pair.Substring(idx + 1)); + } + } +} diff --git a/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs index 80c09d58..efacfdd1 100644 --- a/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs @@ -13,6 +13,7 @@ namespace TALXIS.CLI.Features.Workspace; Children = new[] { typeof(ComponentCreateCliCommand), + typeof(ComponentApplyScaffoldCliCommand), }, ShortFormAutoGenerate = CliNameAutoGenerate.None)] public class ComponentCliCommand diff --git a/src/TALXIS.CLI.Features.Workspace/Entities/EntityAttributeImportCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Entities/EntityAttributeImportCliCommand.cs deleted file mode 100644 index f49ac6df..00000000 --- a/src/TALXIS.CLI.Features.Workspace/Entities/EntityAttributeImportCliCommand.cs +++ /dev/null @@ -1,88 +0,0 @@ -using DotMake.CommandLine; -using Microsoft.Extensions.Logging; -using TALXIS.CLI.Core; -using TALXIS.CLI.Logging; -using TALXIS.Platform.Metadata.Serialization.Xml.Scaffolding; - -namespace TALXIS.CLI.Features.Workspace.Entities; - -/// -/// Applies a rendered pp-entity-attribute scaffold to the solution in one in-process -/// transaction: option set options, attribute import into Entity.xml, money support -/// attributes, lookup relationship files, attribute sorting, and nil-tag normalization. -/// Replaces the template's PowerShell post-action scripts. -/// -[CliIdempotent] -[CliCommand( - Description = "Import a rendered attribute scaffold into an entity (used by the pp-entity-attribute template)", - Name = "attribute-import")] -public class EntityAttributeImportCliCommand : TxcLeafCommand -{ - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(EntityAttributeImportCliCommand)); - - [CliOption(Name = "--solution-root", Description = "Folder containing the unpacked solution files (Other/, Entities/, OptionSets/)", Required = true)] - public string SolutionRoot { get; set; } = null!; - - [CliOption(Name = "--entity", Description = "Schema name of the entity that receives the attribute (e.g. udpp_warehouseitem)", Required = true)] - public string EntitySchemaName { get; set; } = null!; - - [CliOption(Name = "--attribute-file", Description = "Path to the rendered XML file", Required = true)] - public string AttributeFile { get; set; } = null!; - - [CliOption(Name = "--options", Description = "Choice options: comma-separated labels or Label:Value pairs (e.g. Active:100000000,Inactive)", Required = false)] - public string? Options { get; set; } - - [CliOption(Name = "--global-optionset-file", Description = "Path to the rendered global option set file; options are written there instead of the attribute", Required = false)] - public string? GlobalOptionSetFile { get; set; } - - [CliOption(Name = "--global-optionset-name", Description = "Schema name of the global option set to register as a RootComponent (type 9)", Required = false)] - public string? GlobalOptionSetName { get; set; } - - [CliOption(Name = "--money-base-file", Description = "Path to the rendered money base attribute file", Required = false)] - public string? MoneyBaseFile { get; set; } - - [CliOption(Name = "--currency-file", Description = "Path to the rendered transactioncurrencyid attribute file", Required = false)] - public string? CurrencyFile { get; set; } - - [CliOption(Name = "--exchange-rate-file", Description = "Path to the rendered exchangerate attribute file", Required = false)] - public string? ExchangeRateFile { get; set; } - - [CliOption(Name = "--relationship-file", Description = "Path to the rendered lookup EntityRelationship XML file", Required = false)] - public string? RelationshipFile { get; set; } - - [CliOption(Name = "--relationship-name", Description = "Name of the lookup relationship", Required = false)] - public string? RelationshipName { get; set; } - - [CliOption(Name = "--referenced-entity", Description = "Logical name of the entity the lookup points to (e.g. account)", Required = false)] - public string? ReferencedEntity { get; set; } - - protected override Task ExecuteAsync() - { - var request = new EntityAttributeScaffoldRequest - { - SolutionRootPath = Path.GetFullPath(SolutionRoot), - EntitySchemaName = EntitySchemaName, - AttributeFilePath = Path.GetFullPath(AttributeFile), - OptionSetOptions = Options, - GlobalOptionSetFilePath = FullPathOrNull(GlobalOptionSetFile), - GlobalOptionSetSchemaName = GlobalOptionSetName, - MoneyBaseAttributeFilePath = FullPathOrNull(MoneyBaseFile), - CurrencyAttributeFilePath = FullPathOrNull(CurrencyFile), - ExchangeRateAttributeFilePath = FullPathOrNull(ExchangeRateFile), - LookupRelationshipFilePath = FullPathOrNull(RelationshipFile), - LookupRelationshipName = RelationshipName, - ReferencedEntityName = ReferencedEntity, - }; - - var result = EntityAttributeScaffold.Apply(request); - foreach (var warning in result.Warnings) - { - Logger.LogWarning("{Warning}", warning); - } - - OutputFormatter.WriteResult("succeeded", $"Attribute scaffold applied to '{EntitySchemaName}'"); - return Task.FromResult(ExitSuccess); - } - - private static string? FullPathOrNull(string? path) => path == null ? null : Path.GetFullPath(path); -} diff --git a/src/TALXIS.CLI.Features.Workspace/Entities/EntityCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Entities/EntityCliCommand.cs deleted file mode 100644 index b189fa86..00000000 --- a/src/TALXIS.CLI.Features.Workspace/Entities/EntityCliCommand.cs +++ /dev/null @@ -1,23 +0,0 @@ -using DotMake.CommandLine; - -namespace TALXIS.CLI.Features.Workspace.Entities; - -/// -/// Entity operations on the local workspace, executed in-process through the -/// platform metadata library instead of template post-action scripts. -/// -[CliCommand( - Description = "Modify entities in your local workspace", - Name = "entity", - Children = new[] - { - typeof(EntityAttributeImportCliCommand), - }, - ShortFormAutoGenerate = CliNameAutoGenerate.None)] -public class EntityCliCommand -{ - public void Run(CliContext context) - { - context.ShowHelp(); - } -} diff --git a/src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs index 3ec989d5..424d5f3c 100644 --- a/src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/WorkspaceCliCommand.cs @@ -1,6 +1,5 @@ using DotMake.CommandLine; using TALXIS.CLI.Features.Workspace.Controls; -using TALXIS.CLI.Features.Workspace.Entities; namespace TALXIS.CLI.Features.Workspace; @@ -11,7 +10,6 @@ namespace TALXIS.CLI.Features.Workspace; { typeof(ComponentCliCommand), typeof(ControlCliCommand), - typeof(EntityCliCommand), typeof(ProjectCliCommand), typeof(WorkspaceExplainCliCommand), typeof(WorkspaceValidateCliCommand) From 05a616287c4e09f518e61f42ddf0e811a9f2a11b Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Wed, 19 Aug 2026 02:11:39 +0200 Subject: [PATCH 4/5] feat(workspace): rename control attach to bind and rework options --- ...CliCommand.cs => ControlBindCliCommand.cs} | 109 +++++++++++------- .../Controls/ControlCliCommand.cs | 4 +- 2 files changed, 68 insertions(+), 45 deletions(-) rename src/TALXIS.CLI.Features.Workspace/Controls/{ControlAttachCliCommand.cs => ControlBindCliCommand.cs} (64%) diff --git a/src/TALXIS.CLI.Features.Workspace/Controls/ControlAttachCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Controls/ControlBindCliCommand.cs similarity index 64% rename from src/TALXIS.CLI.Features.Workspace/Controls/ControlAttachCliCommand.cs rename to src/TALXIS.CLI.Features.Workspace/Controls/ControlBindCliCommand.cs index 1d921861..ec457259 100644 --- a/src/TALXIS.CLI.Features.Workspace/Controls/ControlAttachCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/Controls/ControlBindCliCommand.cs @@ -13,40 +13,40 @@ namespace TALXIS.CLI.Features.Workspace.Controls; /// -/// Overlays a PCF custom control on an existing subgrid of a form. The control's +/// Binds a PCF custom control to an existing subgrid of a form. The control's /// ControlManifest.xml supplies the parameter schema — resolved from a NuGet -/// package name (downloaded automatically, like env pkg import) or from a local -/// file (bare manifest, solution zip, pdpkg.zip, or nupkg); dataset binding is copied -/// from the host subgrid; the modified form is re-validated with the platform metadata -/// schema validator. +/// package name (downloaded automatically, like env pkg import), from a local +/// file (bare manifest, solution zip, pdpkg.zip, or nupkg), or from a control project +/// folder / .csproj; dataset binding is copied from the host subgrid; the +/// modified form is re-validated with the platform metadata schema validator. /// [CliIdempotent] [CliCommand( - Description = "Attach a custom control to a subgrid on a form, driven by the control's manifest", - Name = "attach")] -public class ControlAttachCliCommand : TxcLeafCommand + Description = "Bind a custom control to a subgrid on a form, driven by the control's manifest", + Name = "bind")] +public class ControlBindCliCommand : TxcLeafCommand { - protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ControlAttachCliCommand)); + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ControlBindCliCommand)); [CliOption(Name = "--output", Aliases = ["-o"], Description = "Solution project root containing the Entities folder", Required = true)] public string OutputPath { get; set; } = null!; - [CliOption(Name = "--entity", Description = "Logical name of the entity that owns the form (e.g. almlab_warehouselocation)", Required = true)] - public string EntityLogicalName { get; set; } = null!; + [CliOption(Name = "--entity", Description = "Logical name of the entity that owns the form (e.g. almlab_warehouselocation). Optional when the form can be resolved unambiguously without it.", Required = false)] + public string? EntityLogicalName { get; set; } [CliOption(Name = "--form-type", Description = "Form type folder name", Required = false)] public string FormType { get; set; } = "main"; - [CliOption(Name = "--form-id", Description = "Form GUID (without braces). Optional when the entity has exactly one form of the given type.", Required = false)] + [CliOption(Name = "--form-id", Description = "Form GUID (without braces). Optional when exactly one form of the given type is in scope.", Required = false)] public string? FormId { get; set; } - [CliOption(Name = "--target-control", Description = "FormXml id of the subgrid to overlay (e.g. subgrid)", Required = true)] + [CliOption(Name = "--target-control", Description = "FormXml id of the subgrid to bind to (e.g. subgrid)", Required = true)] public string TargetControlId { get; set; } = null!; - [CliOption(Name = "--package", Description = "NuGet package name of the control (downloaded automatically), or local path to its ControlManifest.xml / solution .zip / .pdpkg.zip / .nupkg", Required = true)] - public string Package { get; set; } = null!; + [CliOption(Name = "--source", Description = "NuGet package name of the control (downloaded automatically), local path to its ControlManifest.xml / solution .zip / .pdpkg.zip / .nupkg, or the control project's folder / .csproj", Required = true)] + public string Source { get; set; } = null!; - [CliOption(Name = "--version", Description = "NuGet package version (only when '--package' is a NuGet name).", Required = false)] + [CliOption(Name = "--version", Description = "NuGet package version (only when '--source' is a NuGet name).", Required = false)] public string PackageVersion { get; set; } = "latest"; [CliOption(Name = "--control-name", Description = "Publisher-prefixed control name for FormXml (e.g. talxis_TALXIS.PCF.Grid). Required only when it cannot be resolved from the manifest source.", Required = false)] @@ -55,7 +55,7 @@ public class ControlAttachCliCommand : TxcLeafCommand [CliOption(Description = "Control parameters in key=value format (validated against the manifest). Can be specified multiple times.")] public List Param { get; set; } = new(); - [CliOption(Description = "Replace an existing custom control attachment on the same subgrid.")] + [CliOption(Description = "Replace an existing custom control binding on the same subgrid.")] public bool Force { get; set; } private readonly NuGetPackageInstallerService _packageInstaller = new(); @@ -64,19 +64,19 @@ protected override async Task ExecuteAsync() { string manifestSource; string? tempWorkingDirectory = null; - if (File.Exists(Package)) + if (File.Exists(Source) || Directory.Exists(Source)) { - manifestSource = Path.GetFullPath(Package); + manifestSource = Path.GetFullPath(Source); } - else if (Package.IndexOfAny(['\\', '/']) >= 0 || HasManifestFileExtension(Package)) + else if (Source.IndexOfAny(['\\', '/']) >= 0 || HasManifestFileExtension(Source)) { // Looks like a file path (NuGet ids contain dots but never these extensions). - Logger.LogError("Manifest source not found: {Package}", Package); + Logger.LogError("Manifest source not found: {Source}", Source); return ExitValidationError; } else { - var install = await _packageInstaller.InstallAsync(new NuGetPackageInstallOptions(Package, PackageVersion, null)); + var install = await _packageInstaller.InstallAsync(new NuGetPackageInstallOptions(Source, PackageVersion, null)); Logger.LogInformation("Resolved {PackageName} version {Version}", install.PackageName, install.ResolvedVersion); manifestSource = install.DownloadedPackagePath; if (install.UsesTemporaryWorkingDirectory) @@ -85,7 +85,7 @@ protected override async Task ExecuteAsync() try { - return AttachFromManifest(manifestSource); + return BindFromManifest(manifestSource); } finally { @@ -94,7 +94,7 @@ protected override async Task ExecuteAsync() } } - private int AttachFromManifest(string manifestSource) + private int BindFromManifest(string manifestSource) { var manifest = ControlManifestReader.Read(manifestSource); @@ -117,10 +117,10 @@ private int AttachFromManifest(string manifestSource) var formFile = ResolveFormFile(); var preErrors = CountSchemaErrors(formFile); - ControlAttachmentResult result; + ControlBindingResult result; try { - result = AttachToFormFile(formFile, new ControlAttachmentRequest + result = BindToFormFile(formFile, new ControlBindingRequest { TargetControlId = TargetControlId, Manifest = manifest, @@ -129,7 +129,7 @@ private int AttachFromManifest(string manifestSource) Force = Force, }); } - catch (InvalidOperationException ex) when (ex.Message.Contains("already attached")) + catch (InvalidOperationException ex) when (ex.Message.Contains("already bound")) { Logger.LogError("{Message} Use --force to replace it.", ex.Message); return ExitValidationError; @@ -139,14 +139,14 @@ private int AttachFromManifest(string manifestSource) if (postErrors > preErrors) Logger.LogWarning("Schema validation reports {New} new issue(s) on {File} after the change — run 'txc workspace validate' for details.", postErrors - preErrors, formFile); - var action = result.ReplacedExisting ? "replaced on" : "attached to"; + var action = result.ReplacedExisting ? "replaced on" : "bound to"; OutputFormatter.WriteResult("succeeded", $"{controlName} {action} '{TargetControlId}' in {formFile}"); return ExitSuccess; } // File-level adapter over the in-memory operation: load the form body into the - // metadata model, attach, and write the modified body back into the same document. - private static ControlAttachmentResult AttachToFormFile(string formFile, ControlAttachmentRequest request) + // metadata model, bind, and write the modified body back into the same document. + private static ControlBindingResult BindToFormFile(string formFile, ControlBindingRequest request) { var doc = XDocument.Load(formFile); var formElement = doc.Descendants("form").FirstOrDefault() @@ -158,7 +158,7 @@ private static ControlAttachmentResult AttachToFormFile(string formFile, Control Body = MergeableNodeXmlConverter.FromXElement(formElement), }; - var result = FormControlAttachment.Attach(form, request); + var result = FormControlBinding.Bind(form, request); formElement.ReplaceWith(MergeableNodeXmlConverter.ToXElement(form.Body!)); doc.Save(formFile); @@ -167,31 +167,54 @@ private static ControlAttachmentResult AttachToFormFile(string formFile, Control private string ResolveFormFile() { - var formDir = Path.Combine(OutputPath, "Entities", EntityLogicalName, "FormXml", FormType); - if (!Directory.Exists(formDir)) - throw new InvalidOperationException($"Form folder not found: {formDir}"); + var candidates = CollectCandidateFormFiles(); if (!string.IsNullOrEmpty(FormId)) { - var wanted = FormId.Trim('{', '}'); - var match = Directory.GetFiles(formDir, "*.xml").FirstOrDefault(f => + var wanted = FormId!.Trim('{', '}'); + var match = candidates.FirstOrDefault(f => NormalizeFormFileName(f).Equals(wanted, StringComparison.OrdinalIgnoreCase)); - return match ?? throw new InvalidOperationException($"Form {FormId} not found in {formDir}"); + return match ?? throw new InvalidOperationException($"Form {FormId} not found in the searched form folders."); } - var forms = Directory.GetFiles(formDir, "*.xml"); - return forms.Length switch + return candidates.Count switch { - 1 => forms[0], - 0 => throw new InvalidOperationException($"No forms found in {formDir}"), - _ => throw new InvalidOperationException($"Multiple forms found in {formDir} — pass --form-id. Candidates: {string.Join(", ", forms.Select(Path.GetFileName))}"), + 1 => candidates[0], + 0 => throw new InvalidOperationException($"No '{FormType}' forms found under {Path.Combine(OutputPath, "Entities")}"), + _ => throw new InvalidOperationException($"Multiple forms found - pass --entity and/or --form-id. Candidates: {string.Join(", ", candidates.Select(RelativeToOutput))}"), }; } + // With --entity the scope is that entity's form folder; without it every entity in the project is searched. + private List CollectCandidateFormFiles() + { + if (!string.IsNullOrEmpty(EntityLogicalName)) + { + var formDir = Path.Combine(OutputPath, "Entities", EntityLogicalName, "FormXml", FormType); + if (!Directory.Exists(formDir)) + throw new InvalidOperationException($"Form folder not found: {formDir}"); + return Directory.GetFiles(formDir, "*.xml").ToList(); + } + + var entitiesRoot = Path.Combine(OutputPath, "Entities"); + if (!Directory.Exists(entitiesRoot)) + throw new InvalidOperationException($"Entities folder not found: {entitiesRoot}"); + + return Directory.GetDirectories(entitiesRoot) + .Select(entityDir => Path.Combine(entityDir, "FormXml", FormType)) + .Where(Directory.Exists) + .SelectMany(dir => Directory.GetFiles(dir, "*.xml")) + .ToList(); + } + + private string RelativeToOutput(string path) => + Path.GetRelativePath(OutputPath, path); + private static bool HasManifestFileExtension(string value) => value.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) || value.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || - value.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase); + value.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase) || + value.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase); // Managed-layer sources use a "{guid}_managed.xml" file name — match on the guid alone. private static string NormalizeFormFileName(string path) diff --git a/src/TALXIS.CLI.Features.Workspace/Controls/ControlCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Controls/ControlCliCommand.cs index 6190b68e..7bc2ca56 100644 --- a/src/TALXIS.CLI.Features.Workspace/Controls/ControlCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/Controls/ControlCliCommand.cs @@ -9,11 +9,11 @@ namespace TALXIS.CLI.Features.Workspace.Controls; /// ControlManifest.xml at run time, so no per-control template is needed. /// [CliCommand( - Description = "Attach custom controls (PCF) to forms in your local workspace", + Description = "Bind custom controls (PCF) to forms in your local workspace", Name = "control", Children = new[] { - typeof(ControlAttachCliCommand), + typeof(ControlBindCliCommand), }, ShortFormAutoGenerate = CliNameAutoGenerate.None)] public class ControlCliCommand From 8883d8faa4381d632aad1dafbf6f03ab0579cfc7 Mon Sep 17 00:00:00 2001 From: Alexander Zekelin Date: Wed, 19 Aug 2026 15:53:25 +0200 Subject: [PATCH 5/5] fix(workspace): adapt control bind to CustomControlReader API --- .../Controls/ControlBindCliCommand.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/TALXIS.CLI.Features.Workspace/Controls/ControlBindCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/Controls/ControlBindCliCommand.cs index ec457259..3a706019 100644 --- a/src/TALXIS.CLI.Features.Workspace/Controls/ControlBindCliCommand.cs +++ b/src/TALXIS.CLI.Features.Workspace/Controls/ControlBindCliCommand.cs @@ -96,9 +96,10 @@ protected override async Task ExecuteAsync() private int BindFromManifest(string manifestSource) { - var manifest = ControlManifestReader.Read(manifestSource); + var control = CustomControlReader.Read(manifestSource); + var manifest = control.Manifest; - var controlName = ControlName ?? manifest.PrefixedName; + var controlName = ControlName ?? control.Name; if (string.IsNullOrEmpty(controlName)) { Logger.LogError("The publisher-prefixed control name could not be resolved from '{Manifest}'. Pass it explicitly with --control-name (e.g. talxis_{Qualified}).", manifestSource, manifest.QualifiedName);