diff --git a/src/TALXIS.CLI.Features.Docs/Skills/form-xml-reference.md b/src/TALXIS.CLI.Features.Docs/Skills/form-xml-reference.md index 6a95df19..e73d6ab4 100644 --- a/src/TALXIS.CLI.Features.Docs/Skills/form-xml-reference.md +++ b/src/TALXIS.CLI.Features.Docs/Skills/form-xml-reference.md @@ -47,6 +47,8 @@ Build forms top-down. Each level is a separate template call: All form fragment templates require `FormId`, `FormType`, and `EntitySchemaName` parameters. Generate the `FormId` GUID once and reuse it across all calls for the same form. +To verify the resulting structure without reading the XML, call `workspace_component_inspect` (`--type Form --id `): it returns the tab / section / control tree with data field bindings. Works for entities too (`--type Entity --id ` lists attributes with types and required levels). + ### ControlType values for pp-form-control `Text`, `MultilineText`, `WholeNumber`, `Decimal`, `Float`, `Currency`, `DateTime`, `Lookup`, `OptionSet`, `SubGrid`, `Button` diff --git a/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentCliCommand.cs index 80c09d58..64643956 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(ComponentInspectCliCommand), }, ShortFormAutoGenerate = CliNameAutoGenerate.None)] public class ComponentCliCommand diff --git a/src/TALXIS.CLI.Features.Workspace/ComponentInspectCliCommand.cs b/src/TALXIS.CLI.Features.Workspace/ComponentInspectCliCommand.cs new file mode 100644 index 00000000..d47d5d8c --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/ComponentInspectCliCommand.cs @@ -0,0 +1,178 @@ +using DotMake.CommandLine; +using Microsoft.Extensions.Logging; +using TALXIS.CLI.Core; +using TALXIS.CLI.Core.Resolution; +using TALXIS.CLI.Logging; +using TALXIS.Platform.Metadata.Components; +using TALXIS.Platform.Metadata.Serialization.Xml; +using MetadataWorkspace = TALXIS.Platform.Metadata.Serialization.Xml.Workspace; + +namespace TALXIS.CLI.Features.Workspace; + +/// +/// CLI command that drills into the internal structure of a single workspace component. +/// Usage: txc workspace component inspect --type Form --id {guid} +/// +[CliReadOnly] +[CliCommand( + Name = "inspect", + Description = "Inspects the internal structure of a single component in the local workspace (no environment connection). " + + "Forms (including dialogs): tab > section > control tree with data field bindings. Entities: attribute list with types and required levels. " + + "Views: columns, sort order and fetch query. Identify forms by GUID, display name or dialog unique name; entities by logical name; views by GUID or name.")] +public class ComponentInspectCliCommand : TxcLeafCommand +{ + protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ComponentInspectCliCommand)); + + private const int MaxSuggestions = 20; + + [CliArgument(Description = "Path inside the solution workspace (defaults to current directory).")] + public string Path { get; set; } = "."; + + [CliOption(Name = "--type", Description = "Component type to inspect: Form, Entity or View. Dialog forms count as Form.")] + public required string Type { get; set; } + + [CliOption(Name = "--id", Description = "Component identifier - form GUID (with or without braces) or form display name for forms, entity logical name for entities.")] + public required string Id { get; set; } + + [CliOption(Name = "--depth", Required = false, Description = "Maximum form tree depth: 1 = tabs only, 2 = tabs and sections, 3 or omitted = full tree including controls. Ignored for entities.")] + public int? Depth { get; set; } + + [CliOption(Name = "--entity", Required = false, Description = "Entity logical name to disambiguate forms when --id is a display name shared by multiple entities. Ignored for entities.")] + public string? Entity { get; set; } + + protected override Task ExecuteAsync() + { + var fullPath = System.IO.Path.GetFullPath(Path); + var workspaceRoot = SolutionProjectResolver.FindWorkspaceRoot(fullPath); + if (workspaceRoot is null) + { + Logger.LogError("Could not find workspace root (Other/Solution.xml) from: {Path}.", fullPath); + return Task.FromResult(ExitValidationError); + } + + var workspace = new XmlWorkspaceReader().Load(workspaceRoot); + + return Type.Trim().ToLowerInvariant() switch + { + "entity" or "table" => Task.FromResult(InspectEntity(workspace)), + "form" or "systemform" or "dialog" => Task.FromResult(InspectForm(workspace)), + "view" or "savedquery" => Task.FromResult(InspectView(workspace)), + _ => Task.FromResult(UnsupportedType()), + }; + } + + private int UnsupportedType() + { + Logger.LogError("Unsupported component type '{Type}'. Supported types: Form, Entity, View.", Type); + return ExitValidationError; + } + + private int InspectEntity(MetadataWorkspace workspace) + { + var entity = workspace.Entities.FirstOrDefault(e => + string.Equals(e.LogicalName, Id, StringComparison.OrdinalIgnoreCase)); + + if (entity is null) + { + Logger.LogError("Entity '{Id}' not found in workspace. Available entities: {Entities}.", + Id, Summarize(workspace.Entities.Select(e => e.LogicalName ?? "?"))); + return ExitValidationError; + } + + var result = ComponentInspectHelpers.BuildEntityResult(entity); + OutputFormatter.WriteData(result, ComponentInspectHelpers.RenderEntityText); + return ExitSuccess; + } + + private int InspectForm(MetadataWorkspace workspace) + { + var normalizedId = ComponentInspectHelpers.NormalizeGuid(Id); + + var candidates = workspace.Forms.Select(f => new DialogFormInfo(f, null)) + .Concat(workspace.GenericComponents + .Where(g => string.Equals(g.ComponentTypeName, "Dialog", StringComparison.OrdinalIgnoreCase)) + .Select(ComponentInspectHelpers.ParseDialogForm) + .Where(d => d is not null) + .Select(d => d!)) + .ToList(); + + if (Entity is not null) + { + candidates = candidates + .Where(c => string.Equals(c.Form.EntityLogicalName, Entity, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + var matches = normalizedId is not null + ? candidates.Where(c => ComponentInspectHelpers.NormalizeGuid(c.Form.FormId) == normalizedId).ToList() + : candidates.Where(c => + string.Equals(ComponentInspectHelpers.PickLabel(c.Form.DisplayName), Id, StringComparison.OrdinalIgnoreCase) + || string.Equals(c.UniqueName, Id, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (matches.Count == 0) + { + Logger.LogError("Form '{Id}' not found in workspace. Available forms: {Forms}.", + Id, Summarize(candidates.Select(DescribeForm))); + return ExitValidationError; + } + + if (matches.Count > 1) + { + Logger.LogError("Form name '{Id}' is ambiguous ({Count} matches): {Forms}. Use the form GUID or narrow with --entity.", + Id, matches.Count, Summarize(matches.Select(DescribeForm))); + return ExitValidationError; + } + + var result = ComponentInspectHelpers.BuildFormResult(matches[0].Form, Depth, matches[0].UniqueName); + OutputFormatter.WriteData(result, ComponentInspectHelpers.RenderFormText); + return ExitSuccess; + } + + private int InspectView(MetadataWorkspace workspace) + { + var normalizedId = ComponentInspectHelpers.NormalizeGuid(Id); + + var candidates = Entity is null + ? workspace.Views + : workspace.Views.Where(v => string.Equals(v.EntityLogicalName, Entity, StringComparison.OrdinalIgnoreCase)).ToList(); + + var matches = normalizedId is not null + ? candidates.Where(v => ComponentInspectHelpers.NormalizeGuid(v.SavedQueryId) == normalizedId).ToList() + : candidates.Where(v => + string.Equals(ComponentInspectHelpers.PickLabel(v.DisplayName), Id, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (matches.Count == 0) + { + Logger.LogError("View '{Id}' not found in workspace. Available views: {Views}.", + Id, Summarize(candidates.Select(DescribeView))); + return ExitValidationError; + } + + if (matches.Count > 1) + { + Logger.LogError("View name '{Id}' is ambiguous ({Count} matches): {Views}. Use the view GUID or narrow with --entity.", + Id, matches.Count, Summarize(matches.Select(DescribeView))); + return ExitValidationError; + } + + var result = ComponentInspectHelpers.BuildViewResult(matches[0]); + OutputFormatter.WriteData(result, ComponentInspectHelpers.RenderViewText); + return ExitSuccess; + } + + private static string DescribeForm(DialogFormInfo candidate) + { + var origin = candidate.Form.EntityLogicalName ?? candidate.UniqueName ?? "dialog"; + return $"{candidate.Form.FormId} ({ComponentInspectHelpers.PickLabel(candidate.Form.DisplayName) ?? "unnamed"}, {origin})"; + } + + private static string DescribeView(SavedQueryMetadata view) + => $"{view.SavedQueryId} ({ComponentInspectHelpers.PickLabel(view.DisplayName) ?? "unnamed"}, {view.EntityLogicalName})"; + + private static string Summarize(IEnumerable items) + { + var list = items.ToList(); + var shown = string.Join(", ", list.Take(MaxSuggestions)); + return list.Count > MaxSuggestions ? $"{shown} ... and {list.Count - MaxSuggestions} more" : shown; + } +} diff --git a/src/TALXIS.CLI.Features.Workspace/ComponentInspectHelpers.cs b/src/TALXIS.CLI.Features.Workspace/ComponentInspectHelpers.cs new file mode 100644 index 00000000..f89b2dd6 --- /dev/null +++ b/src/TALXIS.CLI.Features.Workspace/ComponentInspectHelpers.cs @@ -0,0 +1,442 @@ +using System.Xml.Linq; +using TALXIS.CLI.Core; +using TALXIS.Platform.Metadata; +using TALXIS.Platform.Metadata.Components; +using TALXIS.Platform.Metadata.Merging; +using TALXIS.Platform.Metadata.Serialization.Xml; + +namespace TALXIS.CLI.Features.Workspace; + +/// +/// Pure projection logic for - +/// turns metadata objects into serializable inspection results. +/// +internal static class ComponentInspectHelpers +{ + private const int EnglishLanguageCode = 1033; + + public static FormInspectionResult BuildFormResult(FormMetadata form, int? depth, string? uniqueName = null) + { + var tabs = new List(); + foreach (var tab in FindDescendants(form.Body, "tab")) + { + List? sections = null; + if (depth is null || depth >= 2) + { + sections = new List(); + foreach (var section in FindDescendants(tab, "section")) + { + List? controls = null; + if (depth is null || depth >= 3) + { + controls = new List(); + foreach (var cell in FindDescendants(section, "cell")) + { + var cellLabel = PickNodeLabel(cell); + foreach (var control in FindDescendants(cell, "control")) + { + controls.Add(new FormControlNode( + control.GetAttribute("id"), + control.GetAttribute("datafieldname"), + control.GetAttribute("classid"), + cellLabel)); + } + } + } + + sections.Add(new FormSectionNode( + section.GetAttribute("id"), + section.GetAttribute("name"), + PickNodeLabel(section), + controls)); + } + } + + tabs.Add(new FormTabNode( + tab.GetAttribute("id"), + tab.GetAttribute("name"), + PickNodeLabel(tab), + sections)); + } + + return new FormInspectionResult( + form.FormId, + form.FormType, + form.EntityLogicalName, + PickLabel(form.DisplayName), + PickLabel(form.Description), + uniqueName, + tabs); + } + + /// + /// Parses a dialog component (Dialogs/{guid}.xml stored as a generic component) into + /// form metadata so dialogs can be inspected the same way as entity forms. + /// Returns null when the content is missing or not a dialog document. + /// + public static DialogFormInfo? ParseDialogForm(GenericComponentMetadata component) + { + if (string.IsNullOrWhiteSpace(component.SerializedContent)) + return null; + + XElement root; + try + { + root = XElement.Parse(component.SerializedContent); + } + catch (System.Xml.XmlException) + { + return null; + } + + if (!string.Equals(root.Name.LocalName, "Dialog", StringComparison.OrdinalIgnoreCase)) + return null; + + var formsElement = root.Element("FormXml")?.Element("forms"); + var formElement = formsElement?.Element("form"); + + var form = new FormMetadata + { + FormId = root.Element("FormId")?.Value ?? component.Id, + FormType = formsElement?.Attribute("type")?.Value ?? "dialog", + DisplayName = ParseLocalizedLabel(root.Element("LocalizedNames"), "LocalizedName"), + Description = ParseLocalizedLabel(root.Element("Descriptions"), "Description"), + Body = formElement is null ? null : MergeableNodeXmlConverter.FromXElement(formElement), + }; + + return new DialogFormInfo(form, root.Element("UniqueName")?.Value ?? component.Name); + } + + public static ViewInspectionResult BuildViewResult(SavedQueryMetadata view) + { + // The metadata library exposes layoutxml/fetchxml as element text (empty for + // nested XML), so read the grid and fetch definitions from the source file. + var savedQuery = TryLoadSavedQueryElement(view); + var layout = savedQuery?.Element("layoutxml")?.Elements().FirstOrDefault(); + var fetch = savedQuery?.Element("fetchxml")?.Elements().FirstOrDefault(); + var isQuickFind = view.IsQuickFindQuery || savedQuery?.Element("isquickfindquery")?.Value == "1"; + + return new ViewInspectionResult( + view.SavedQueryId, + PickLabel(view.DisplayName), + view.EntityLogicalName, + view.QueryType, + view.IsDefault, + isQuickFind, + ParseLayoutColumns(layout), + ParseFetchOrder(fetch), + fetch?.ToString()); + } + + private static XElement? TryLoadSavedQueryElement(SavedQueryMetadata view) + { + var path = view.Source?.FilePath; + if (path is null || !File.Exists(path)) + return null; + + try + { + var queries = XDocument.Load(path).Root?.Elements("savedquery").ToList(); + if (queries is null || queries.Count == 0) + return null; + + var normalizedId = NormalizeGuid(view.SavedQueryId); + return queries.FirstOrDefault(q => NormalizeGuid(q.Element("savedqueryid")?.Value) == normalizedId) + ?? (queries.Count == 1 ? queries[0] : null); + } + catch (System.Xml.XmlException) + { + return null; + } + } + + private static Label? ParseLocalizedLabel(XElement? container, string elementName) + { + if (container is null) + return null; + + var labels = container.Elements(elementName) + .Select(e => (Text: e.Attribute("description")?.Value, Code: (int?)e.Attribute("languagecode"))) + .Where(l => l.Text is not null) + .ToList(); + if (labels.Count == 0) + return null; + + var picked = labels.FirstOrDefault(l => l.Code == EnglishLanguageCode); + if (picked.Text is null) + picked = labels[0]; + return new Label(picked.Text!, picked.Code ?? EnglishLanguageCode); + } + + private static IReadOnlyList ParseLayoutColumns(XElement? grid) + => grid is null + ? [] + : grid.Descendants("cell") + .Select(c => new ViewColumnNode(c.Attribute("name")?.Value, (int?)c.Attribute("width"))) + .ToList(); + + private static IReadOnlyList ParseFetchOrder(XElement? fetch) + => fetch is null + ? [] + : fetch.Descendants("order") + .Select(o => + { + var attribute = o.Attribute("attribute")?.Value ?? "?"; + return (bool?)o.Attribute("descending") == true ? $"{attribute} (desc)" : attribute; + }) + .ToList(); + + public static EntityInspectionResult BuildEntityResult(EntityMetadata entity) + { + var attributes = entity.Attributes + .Select(a => new EntityAttributeNode( + a.LogicalName, + a.AttributeType.ToString(), + PickLabel(a.DisplayName), + a.RequiredLevel.ToString(), + a.RequiredLevel is RequiredLevel.Required or RequiredLevel.ApplicationRequired or RequiredLevel.SystemRequired, + a.IsCustomAttribute)) + .ToList(); + + return new EntityInspectionResult( + entity.LogicalName, + entity.SchemaName, + PickLabel(entity.DisplayName), + entity.PrimaryIdAttribute, + entity.PrimaryNameAttribute, + entity.Ownership.ToString(), + entity.IsCustomEntity, + attributes); + } + + /// + /// Recursively finds all descendants with the given element name. + /// Does not descend into a match - nested same-name elements are not expected in FormXml. + /// + public static IEnumerable FindDescendants(MergeableNode? node, string name) + { + if (node is null) + yield break; + + foreach (var child in node.Children) + { + if (string.Equals(child.Name, name, StringComparison.OrdinalIgnoreCase)) + { + yield return child; + } + else + { + foreach (var match in FindDescendants(child, name)) + yield return match; + } + } + } + + /// + /// Reads a node's display label from its labels/label@description children, + /// preferring English (1033) and falling back to the first label found. + /// + public static string? PickNodeLabel(MergeableNode node) + { + var labels = node.Children.FirstOrDefault(c => string.Equals(c.Name, "labels", StringComparison.OrdinalIgnoreCase)); + if (labels is null) + return null; + + string? first = null; + foreach (var label in labels.Children.Where(c => string.Equals(c.Name, "label", StringComparison.OrdinalIgnoreCase))) + { + var description = label.GetAttribute("description"); + if (description is null) + continue; + if (label.GetAttribute("languagecode") == EnglishLanguageCode.ToString()) + return description; + first ??= description; + } + return first; + } + + public static string? PickLabel(Label? label) + { + if (label is null) + return null; + if (label.LocalizedLabels.TryGetValue(EnglishLanguageCode, out var english)) + return english; + return label.Default; + } + + /// Normalizes a GUID string (with or without braces) for comparison; null if not a GUID. + public static string? NormalizeGuid(string? value) + => Guid.TryParse(value?.Trim('{', '}'), out var guid) ? guid.ToString("D") : null; + + public static void RenderEntityText(EntityInspectionResult entity) + { + OutputWriter.WriteLine($"Entity: {entity.LogicalName} ({entity.DisplayName ?? "no display name"})"); + OutputWriter.WriteLine($"Schema: {entity.SchemaName ?? "?"} | Ownership: {entity.Ownership} | Custom: {(entity.IsCustomEntity ? "yes" : "no")}"); + OutputWriter.WriteLine($"Primary id: {entity.PrimaryIdAttribute} | Primary name: {entity.PrimaryNameAttribute}"); + OutputWriter.WriteLine(string.Empty); + + if (entity.Attributes.Count == 0) + { + OutputWriter.WriteLine("No attributes found."); + return; + } + + int nameWidth = Math.Clamp(entity.Attributes.Max(a => a.LogicalName?.Length ?? 0), 12, 40); + int typeWidth = Math.Clamp(entity.Attributes.Max(a => a.Type.Length), 4, 20); + int requiredWidth = Math.Clamp(entity.Attributes.Max(a => RequiredDisplay(a).Length), 8, 20); + + string header = $"{"Logical Name".PadRight(nameWidth)} | {"Type".PadRight(typeWidth)} | {"Required".PadRight(requiredWidth)} | {"Custom",-6} | Display Name"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + foreach (var attribute in entity.Attributes) + { + OutputWriter.WriteLine( + $"{(attribute.LogicalName ?? "?").PadRight(nameWidth)} | " + + $"{attribute.Type.PadRight(typeWidth)} | " + + $"{RequiredDisplay(attribute).PadRight(requiredWidth)} | " + + $"{(attribute.IsCustomAttribute ? "yes" : ""),-6} | " + + $"{attribute.DisplayName ?? ""}"); + } + OutputWriter.WriteLine($"\n{entity.Attributes.Count} attribute(s)."); + } + + private static string RequiredDisplay(EntityAttributeNode attribute) + => attribute.RequiredLevel == nameof(RequiredLevel.None) ? string.Empty : attribute.RequiredLevel; + + public static void RenderViewText(ViewInspectionResult view) + { + OutputWriter.WriteLine($"View: {view.DisplayName ?? "unnamed"} {view.ViewId}"); + var flags = (view.IsDefault ? " | default" : "") + (view.IsQuickFindQuery ? " | quick find" : ""); + OutputWriter.WriteLine($"Entity: {view.EntityLogicalName ?? "?"} | Query type: {view.QueryType?.ToString() ?? "?"}{flags}"); + if (view.OrderBy.Count > 0) + OutputWriter.WriteLine($"Order by: {string.Join(", ", view.OrderBy)}"); + OutputWriter.WriteLine(string.Empty); + + if (view.Columns.Count == 0) + { + OutputWriter.WriteLine("No columns found in layout."); + return; + } + + int nameWidth = Math.Clamp(view.Columns.Max(c => c.Name?.Length ?? 0), 6, 40); + string header = $"{"Column".PadRight(nameWidth)} | Width"; + OutputWriter.WriteLine(header); + OutputWriter.WriteLine(new string('-', header.Length)); + foreach (var column in view.Columns) + OutputWriter.WriteLine($"{(column.Name ?? "?").PadRight(nameWidth)} | {column.Width?.ToString() ?? ""}"); + OutputWriter.WriteLine($"\n{view.Columns.Count} column(s)."); + } + + public static void RenderFormText(FormInspectionResult form) + { + OutputWriter.WriteLine($"Form: {form.DisplayName ?? "unnamed"} {form.FormId}"); + var origin = form.EntityLogicalName is not null + ? $"Entity: {form.EntityLogicalName}" + : $"Unique name: {form.UniqueName ?? "?"}"; + OutputWriter.WriteLine($"Type: {form.FormType ?? "?"} | {origin}"); + OutputWriter.WriteLine(string.Empty); + + for (int t = 0; t < form.Tabs.Count; t++) + { + var tab = form.Tabs[t]; + bool lastTab = t == form.Tabs.Count - 1; + OutputWriter.WriteLine($"{Branch(lastTab)}Tab: {FirstNonEmpty(tab.Label, tab.Name, tab.Id)}"); + + var sections = tab.Sections; + if (sections is null) + continue; + + var tabIndent = Indent(lastTab); + for (int s = 0; s < sections.Count; s++) + { + var section = sections[s]; + bool lastSection = s == sections.Count - 1; + OutputWriter.WriteLine($"{tabIndent}{Branch(lastSection)}Section: {FirstNonEmpty(section.Label, section.Name, section.Id)}"); + + var controls = section.Controls; + if (controls is null) + continue; + + var sectionIndent = tabIndent + Indent(lastSection); + for (int c = 0; c < controls.Count; c++) + { + var control = controls[c]; + bool lastControl = c == controls.Count - 1; + var label = !string.IsNullOrWhiteSpace(control.Label) ? $" ({control.Label})" : string.Empty; + OutputWriter.WriteLine($"{sectionIndent}{Branch(lastControl)}{FirstNonEmpty(control.DataFieldName, control.Id)}{label}"); + } + } + } + + static string Branch(bool last) => last ? "└─ " : "├─ "; + static string Indent(bool last) => last ? " " : "│ "; + } + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)) ?? "?"; +} + +internal sealed record FormInspectionResult( + string? FormId, + string? FormType, + string? EntityLogicalName, + string? DisplayName, + string? Description, + string? UniqueName, + IReadOnlyList Tabs); + +/// A dialog parsed into form metadata plus its unique name (dialogs are not entity-bound). +internal sealed record DialogFormInfo( + TALXIS.Platform.Metadata.Components.FormMetadata Form, + string? UniqueName); + +internal sealed record ViewInspectionResult( + string? ViewId, + string? DisplayName, + string? EntityLogicalName, + int? QueryType, + bool IsDefault, + bool IsQuickFindQuery, + IReadOnlyList Columns, + IReadOnlyList OrderBy, + string? FetchXml); + +internal sealed record ViewColumnNode( + string? Name, + int? Width); + +internal sealed record FormTabNode( + string? Id, + string? Name, + string? Label, + IReadOnlyList? Sections); + +internal sealed record FormSectionNode( + string? Id, + string? Name, + string? Label, + IReadOnlyList? Controls); + +internal sealed record FormControlNode( + string? Id, + string? DataFieldName, + string? ClassId, + string? Label); + +internal sealed record EntityInspectionResult( + string? LogicalName, + string? SchemaName, + string? DisplayName, + string? PrimaryIdAttribute, + string? PrimaryNameAttribute, + string Ownership, + bool IsCustomEntity, + IReadOnlyList Attributes); + +internal sealed record EntityAttributeNode( + string? LogicalName, + string Type, + string? DisplayName, + string RequiredLevel, + bool IsRequired, + bool IsCustomAttribute); diff --git a/tests/TALXIS.CLI.Tests/Workspace/ComponentInspectHelpersTests.cs b/tests/TALXIS.CLI.Tests/Workspace/ComponentInspectHelpersTests.cs new file mode 100644 index 00000000..67df16b9 --- /dev/null +++ b/tests/TALXIS.CLI.Tests/Workspace/ComponentInspectHelpersTests.cs @@ -0,0 +1,323 @@ +using System.Xml.Linq; +using TALXIS.CLI.Features.Workspace; +using TALXIS.Platform.Metadata; +using TALXIS.Platform.Metadata.Components; +using TALXIS.Platform.Metadata.Components.Attributes; +using TALXIS.Platform.Metadata.Serialization.Xml; +using Xunit; + +namespace TALXIS.CLI.Tests.Workspace; + +/// +/// Unit tests for - form tree projection +/// (tab/section/control with labels), depth pruning, and entity attribute projection. +/// +public class ComponentInspectHelpersTests +{ + private const string FormBodyXml = """ +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+ """; + + private static FormMetadata CreateForm() => new() + { + FormId = "{af7d924d-aeef-4023-b307-5939da109f64}", + FormType = "main", + EntityLogicalName = "ppf_bankaccount", + DisplayName = new Label("Information", 1033), + Body = MergeableNodeXmlConverter.FromXElement(XElement.Parse(FormBodyXml)), + }; + + [Fact] + public void BuildFormResult_FullDepth_ProjectsTabsSectionsControls() + { + var result = ComponentInspectHelpers.BuildFormResult(CreateForm(), depth: null); + + Assert.Equal("{af7d924d-aeef-4023-b307-5939da109f64}", result.FormId); + Assert.Equal("main", result.FormType); + Assert.Equal("ppf_bankaccount", result.EntityLogicalName); + Assert.Equal("Information", result.DisplayName); + + var tab = Assert.Single(result.Tabs); + Assert.Equal("general_tab", tab.Name); + Assert.Equal("General", tab.Label); + + var section = Assert.Single(tab.Sections!); + Assert.Equal("Details", section.Label); + + Assert.Equal(2, section.Controls!.Count); + var control = section.Controls[0]; + Assert.Equal("ppf_name", control.DataFieldName); + Assert.Equal("{4273EDBD-AC1D-40d3-9FB2-095C621B552D}", control.ClassId); + Assert.Equal("Name", control.Label); + } + + [Fact] + public void BuildFormResult_ControlLabel_FallsBackToNonEnglishCellLabel() + { + var result = ComponentInspectHelpers.BuildFormResult(CreateForm(), depth: null); + + var controls = result.Tabs[0].Sections![0].Controls!; + Assert.Equal("Číslo účtu", controls[1].Label); + } + + [Fact] + public void BuildFormResult_Depth1_OmitsSections() + { + var result = ComponentInspectHelpers.BuildFormResult(CreateForm(), depth: 1); + + var tab = Assert.Single(result.Tabs); + Assert.Equal("General", tab.Label); + Assert.Null(tab.Sections); + } + + [Fact] + public void BuildFormResult_Depth2_OmitsControls() + { + var result = ComponentInspectHelpers.BuildFormResult(CreateForm(), depth: 2); + + var section = Assert.Single(result.Tabs[0].Sections!); + Assert.Equal("Details", section.Label); + Assert.Null(section.Controls); + } + + [Fact] + public void BuildEntityResult_ProjectsAttributesWithRequiredLevels() + { + var entity = new EntityMetadata + { + LogicalName = "ppf_bankaccount", + SchemaName = "ppf_BankAccount", + DisplayName = new Label("Bank Account", 1033), + PrimaryIdAttribute = "ppf_bankaccountid", + PrimaryNameAttribute = "ppf_name", + Ownership = OwnershipType.UserOwned, + IsCustomEntity = true, + }; + entity.AddAttribute(new StringAttributeMetadata + { + LogicalName = "ppf_name", + DisplayName = new Label("Name", 1033), + RequiredLevel = RequiredLevel.ApplicationRequired, + IsCustomAttribute = true, + }); + entity.AddAttribute(new LookupAttributeMetadata + { + LogicalName = "ppf_ownerid", + RequiredLevel = RequiredLevel.None, + }); + + var result = ComponentInspectHelpers.BuildEntityResult(entity); + + Assert.Equal("ppf_bankaccount", result.LogicalName); + Assert.Equal("Bank Account", result.DisplayName); + Assert.Equal("ppf_name", result.PrimaryNameAttribute); + Assert.Equal("UserOwned", result.Ownership); + + Assert.Equal(2, result.Attributes.Count); + var name = result.Attributes[0]; + Assert.Equal("String", name.Type); + Assert.Equal("ApplicationRequired", name.RequiredLevel); + Assert.True(name.IsRequired); + + var owner = result.Attributes[1]; + Assert.Equal("Lookup", owner.Type); + Assert.False(owner.IsRequired); + } + + [Theory] + [InlineData("{AF7D924D-AEEF-4023-B307-5939DA109F64}", "af7d924d-aeef-4023-b307-5939da109f64")] + [InlineData("af7d924d-aeef-4023-b307-5939da109f64", "af7d924d-aeef-4023-b307-5939da109f64")] + [InlineData("not-a-guid", null)] + [InlineData(null, null)] + public void NormalizeGuid_HandlesBracesCaseAndGarbage(string? input, string? expected) + { + Assert.Equal(expected, ComponentInspectHelpers.NormalizeGuid(input)); + } + + private const string DialogXml = """ + + + + + + {1b68b00c-554d-4247-9665-5f9da90eca79} + ntg_orderdelivereddialog + + +
+ + + + +
+ + + + + + + +
+
+
+
+
+
+
+
+ """; + + [Fact] + public void ParseDialogForm_ProjectsDialogAsForm() + { + var component = new GenericComponentMetadata + { + ComponentTypeName = "Dialog", + Id = "{1b68b00c-554d-4247-9665-5f9da90eca79}", + Name = "ntg_orderdelivereddialog", + SerializedContent = DialogXml, + }; + + var dialog = ComponentInspectHelpers.ParseDialogForm(component); + + Assert.NotNull(dialog); + Assert.Equal("ntg_orderdelivereddialog", dialog!.UniqueName); + Assert.Equal("{1b68b00c-554d-4247-9665-5f9da90eca79}", dialog.Form.FormId); + Assert.Equal("dialog", dialog.Form.FormType); + + var result = ComponentInspectHelpers.BuildFormResult(dialog.Form, depth: null, dialog.UniqueName); + Assert.Equal("Order Delivered", result.DisplayName); + Assert.Equal("ntg_orderdelivereddialog", result.UniqueName); + Assert.Null(result.EntityLogicalName); + + var tab = Assert.Single(result.Tabs); + Assert.Equal("Overview", tab.Label); + var control = Assert.Single(Assert.Single(tab.Sections!).Controls!); + Assert.Equal("ntg_deliveredondate", control.DataFieldName); + } + + [Fact] + public void ParseDialogForm_ReturnsNullForNonDialogContent() + { + var component = new GenericComponentMetadata + { + ComponentTypeName = "Dialog", + SerializedContent = "", + }; + + Assert.Null(ComponentInspectHelpers.ParseDialogForm(component)); + } + + [Fact] + public void BuildViewResult_ParsesColumnsOrderAndQuickFindFromSourceFile() + { + var path = Path.Combine(Path.GetTempPath(), "txc-tests", $"view-{Guid.NewGuid():N}.xml"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, """ + + + 1 + 1 + {1b800408-e8a6-4f13-9e35-a7b3b50f7921} + + + + + + + + + 4 + + + + + + + + + + + """); + + try + { + var view = new SavedQueryMetadata + { + SavedQueryId = "{1B800408-E8A6-4F13-9E35-A7B3B50F7921}", + DisplayName = new Label("Quick Find Active ABF Products", 1033), + EntityLogicalName = "ntg_abfproduct", + QueryType = 4, + IsDefault = true, + Source = new SourceLocation(path, 0, 0), + }; + + var result = ComponentInspectHelpers.BuildViewResult(view); + + Assert.True(result.IsQuickFindQuery); + Assert.Equal(2, result.Columns.Count); + Assert.Equal("ntg_name", result.Columns[0].Name); + Assert.Equal(300, result.Columns[0].Width); + Assert.Equal("ntg_name (desc)", Assert.Single(result.OrderBy)); + Assert.Contains("ntg_abfproduct", result.FetchXml); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void BuildViewResult_WithoutSourceFile_ReturnsEmptyColumns() + { + var view = new SavedQueryMetadata + { + SavedQueryId = "{1b800408-e8a6-4f13-9e35-a7b3b50f7921}", + EntityLogicalName = "ntg_abfproduct", + }; + + var result = ComponentInspectHelpers.BuildViewResult(view); + + Assert.Empty(result.Columns); + Assert.Empty(result.OrderBy); + Assert.Null(result.FetchXml); + } +}