Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[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<string> 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<string> Param { get; set; } = new();

protected override Task<int> 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<KeyValuePair<string, string>> ParsePairs(IEnumerable<string> 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<string, string>(pair.Substring(0, idx), pair.Substring(idx + 1));
}
}
}
1 change: 1 addition & 0 deletions src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ namespace TALXIS.CLI.Features.Workspace;
Children = new[]
{
typeof(ComponentCreateCliCommand),
typeof(ComponentApplyScaffoldCliCommand),
},
ShortFormAutoGenerate = CliNameAutoGenerate.None)]
public class ComponentCliCommand
Expand Down
Original file line number Diff line number Diff line change
@@ -1,47 +1,52 @@
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;

/// <summary>
/// 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
/// <c>ControlManifest.xml</c> supplies the parameter schema — resolved from a NuGet
/// package name (downloaded automatically, like <c>env pkg import</c>) 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 <c>env pkg import</c>), from a local
/// file (bare manifest, solution zip, pdpkg.zip, or nupkg), or from a control project
/// folder / <c>.csproj</c>; dataset binding is copied from the host subgrid; the
/// modified form is re-validated with the platform metadata schema validator.
/// </summary>
[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)]
Expand All @@ -50,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<string> 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();
Expand All @@ -59,19 +64,19 @@ protected override async Task<int> 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)
Expand All @@ -80,7 +85,7 @@ protected override async Task<int> ExecuteAsync()

try
{
return AttachFromManifest(manifestSource);
return BindFromManifest(manifestSource);
}
finally
{
Expand All @@ -89,11 +94,12 @@ protected override async Task<int> ExecuteAsync()
}
}

private int AttachFromManifest(string manifestSource)
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);
Expand All @@ -112,52 +118,104 @@ private int AttachFromManifest(string manifestSource)
var formFile = ResolveFormFile();
var preErrors = CountSchemaErrors(formFile);

var result = FormControlAttachmentService.Attach(new ControlAttachmentRequest
ControlBindingResult result;
try
{
result = BindToFormFile(formFile, new ControlBindingRequest
{
TargetControlId = TargetControlId,
Manifest = manifest,
ControlName = controlName,
Parameters = parameters,
Force = Force,
});
}
catch (InvalidOperationException ex) when (ex.Message.Contains("already bound"))
{
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)
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, 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()
?? throw new InvalidOperationException($"No <form> element in '{formFile}'.");

var form = new FormMetadata
{
FormId = doc.Root?.Element("systemform")?.Element("formid")?.Value ?? NormalizeFormFileName(formFile),
Body = MergeableNodeXmlConverter.FromXElement(formElement),
};

var result = FormControlBinding.Bind(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);
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<string> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ namespace TALXIS.CLI.Features.Workspace.Controls;
/// <c>ControlManifest.xml</c> at run time, so no per-control template is needed.
/// </summary>
[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
Expand Down
50 changes: 0 additions & 50 deletions src/TALXIS.CLI.Features.Workspace/Controls/ControlManifestInfo.cs

This file was deleted.

Loading