diff --git a/README.md b/README.md index 7f25d58653a..d3a6400078d 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,19 @@ git tag xrmquery-v0.1.0 && git push origin xrmquery-v0.1.0 # -> npm ## Installation +The generated declarations build on the community-maintained standard Xrm declarations and the +modern XrmQuery package instead of shipping a private copy of the full client API surface: + +```bash +npm install --save-dev @types/xrm +npm install @delegateas/xrmquery +``` + +When form generation is enabled, XDT emits `_internal/XrmDefinitelyTyped.d.ts`. This is a small, +generator-owned augmentation layer over `@types/xrm` that carries the additional precision XDT can +provide: lookup target unions, typed form collections, concrete quick-view forms, and typed option +set controls. It does not redeclare the complete `Xrm` namespace. + ## Configuration ## Usage @@ -48,8 +61,47 @@ xdt -o typings --generate web # web entity types for @delegateas/xrmquery ``` Web entity types land in `typings/Web/.d.ts` (or a single `typings/Web/WebEntities.d.ts` with -`--single-file`) inside the `XDT` namespace, overridable with `--web-namespace`. Option-set types are -written to `typings/_internal/Enum/` by whichever generator needs them. +`--single-file`) inside the `XDT` namespace, overridable with `--web-namespace`. Option-set numeric +union declarations are written to `typings/_internal/Enum/` by whichever generator needs them. + +Named option-set values are emitted as modern `as const` runtime objects in `typings/OptionSets.ts`: + +```typescript +import { ctx_subscription_statuscode } from "./typings/OptionSets"; + +const status: ctx_subscription_statuscode = ctx_subscription_statuscode.Active; +``` + +Importing the object provides discoverable named values while its same-named type remains the exact +union of the Dataverse numeric values. Unlike an ambient `const enum`, this works with +`isolatedModules` and module-based transpilers. Because it is a runtime value, the generated module +must be imported by application code that uses its members. + +### Shared entity interfaces + +Use `--intersect` (`-i`) to name the common contract of existing entities: + +```bash +xdt -o typings --intersect "ICustomer:account;contact, IActivity:phonecall;email;task" +``` + +`ICustomer` represents what accounts and contacts have in common. With both generators enabled, +this produces shared form types such as `Form.ICustomer.Main.Information` from the common +controls, attributes, tabs and sections of forms with matching names and form types on every +member entity, plus `XDT.ICustomer_Select`, +`XDT.ICustomer_Filter`, `XDT.ICustomer_Result` and the other shared Web API interfaces. +The ordinary entity and form declarations are still generated. + +Shared attribute metadata must have matching TypeScript and attribute types; incompatible fields +are omitted. Read/create/update permissions are retained only when supported by every entity. +A shared query contract has no entity set: query the actual `accounts` or `contacts` endpoint, +not an invented `ICustomer` endpoint. Different primary ID fields are not part of the common contract. + +Include the named entities in the metadata selection (`--entities` / `--solutions`) if you filter it. +Missing entity metadata causes an error rather than silently using only some member entities. +Unmatched forms and matched forms with no compatible common controls or tabs are omitted. +An entity without forms does not prevent generation of its shared XrmQuery interfaces. +Intersection mappings accept entity logical names, not form GUIDs. ## Configuration Options diff --git a/docs/content/form-intersection.md b/docs/content/form-intersection.md index 2d04cd5c134..3ffae926513 100644 --- a/docs/content/form-intersection.md +++ b/docs/content/form-intersection.md @@ -1,34 +1,37 @@ -Form intersection -================= +Entity and form intersection +============================ Got client-side logic that needs to run on multiple forms which have similar attributes, control, tabs, etc., -that you need to use in your code? +that you need to use in your code? Make a declaration file using form intersection! How it works ------------ -Form intersection evaluates the forms specified, and combines all common attributes and controls, -tabs and sections into a single form interface. +Entity intersection names a shared contract, such as `ICustomer` for accounts and contacts or +`IActivity` for phone calls, emails and tasks. It matches fetched supported forms by name and form type across every named entity, +then emits their common attributes, controls, tabs and sections as shared form interfaces. +Unmatched forms and matched forms with no compatible common controls or tabs are omitted. +Entities without forms still participate in the shared XrmQuery contract. +The web generator also emits shared XrmQuery interfaces from compatible common entity attributes. This makes it possible to create client-side code that can be used and shared safely across multiple forms.

-You can generate as many form intersection files as you want, and they are defined by passing in the GUIDs -of the forms you want to intersect with the `formIntersect`-argument. See [usage for more details](tool-usage.html). +Define intersections using entity logical names with `--intersect` (`-i`): -The intersection form interfaces can be found at `Form._special.`. +```bash +xdt -o typings --intersect "ICustomer:account;contact, IActivity:phonecall;email;task" +``` +Include these entities in your metadata selection. All ordinary forms and entity types are still +emitted. Shared attributes must have compatible types; permissions must be supported by every +member entity. An intersection defines interfaces, not a new Dataverse table or entity set. +Query the real entity endpoints using the shared XrmQuery select/filter/result contracts. -How to find the GUID of a form ------------------------------- +Intersection mappings take entity logical names, not form GUIDs. -My recommended way is to open the desired form, and execute `Xrm.Page.ui.formSelector.getCurrentItem().getId()` -in a developer console. - -Alternatively, you can find it by opening the form in the form editor through your CRM solution, -and examining the `formId` query-parameter in the URL: - -
+Shared form interfaces can be found at `Form...`, +for example `Form.ICustomer.Main.Information`. diff --git a/docs/content/tool-usage.fsx b/docs/content/tool-usage.fsx index bf1c9422dcd..c367f8d489c 100644 --- a/docs/content/tool-usage.fsx +++ b/docs/content/tool-usage.fsx @@ -37,7 +37,7 @@ Here is the full list of arguments for configuring the tool: | oneFile | of | Set to true if all the dynamic parts of the generated declarations should be put into a single file. | skipForms | sf | Set to true to skip generation of form declaration files. | skipInactiveForms | sif | Set to true to avoid generating types for inactive forms. -| formIntersect | fi | Comma-separated list of named semicolon-separated lists of form GUIDs that should be intersected. Example:
MyAccountIntersect: 284FF02B-BDD1-4BB0-9BCF-6CFDBDA130D4;16068A3B-D428-4430-AEF6-397CE2AEFE07, MyContactIntersect: F4B3397C-C1A5-40BE-89DD-CEA5F7064D1D;824CFA3C-3EB4-4746-BA3C-7F1DFCA114C0 +| intersect | i | Comma-separated list of named semicolon-separated lists of entity logical names to intersect into shared form and XrmQuery interfaces. Example: ICustomer:account;contact, IActivity:phonecall;email;task. Only matching forms with compatible overlap are emitted; mappings accept entity logical names, not form GUIDs. | useconfig | uc | Also applies the arguments found in the `.config` file. You can also view this list of arguments using the "***/help***" argument. diff --git a/src/XrmDefinitelyTyped.Core/Domain/ControlModel.cs b/src/XrmDefinitelyTyped.Core/Domain/ControlModel.cs index 3800d743a03..a6dea0eda9a 100644 --- a/src/XrmDefinitelyTyped.Core/Domain/ControlModel.cs +++ b/src/XrmDefinitelyTyped.Core/Domain/ControlModel.cs @@ -1,3 +1,11 @@ namespace XrmDefinitelyTyped.Core.Domain; -public sealed record ControlModel(string Id, string? DataFieldName, string ClassId); +public sealed record ControlModel( + string Id, + string? DataFieldName, + string ClassId, + IReadOnlyList? TargetEntityTypes = null, + bool CanBeNull = false, + bool IsBusinessProcessFlow = false, + Guid? QuickViewFormId = null, + string? QuickViewEntityLogicalName = null); diff --git a/src/XrmDefinitelyTyped.Core/Domain/FormModel.cs b/src/XrmDefinitelyTyped.Core/Domain/FormModel.cs index a64b7781844..31df6ceab3c 100644 --- a/src/XrmDefinitelyTyped.Core/Domain/FormModel.cs +++ b/src/XrmDefinitelyTyped.Core/Domain/FormModel.cs @@ -1,3 +1,11 @@ namespace XrmDefinitelyTyped.Core.Domain; -public sealed record FormModel(Guid Id, string EntityLogicalName, string Name, FormType FormType, IReadOnlyList Tabs); +public sealed record FormModel( + Guid Id, + string EntityLogicalName, + string Name, + FormType FormType, + IReadOnlyList Tabs, + IReadOnlyList? AdditionalControls = null, + bool IsIntersection = false, + string? MetadataEntityLogicalName = null); diff --git a/src/XrmDefinitelyTyped.Core/Domain/FormType.cs b/src/XrmDefinitelyTyped.Core/Domain/FormType.cs index c8bb65bc6e3..0be2e7cf6a1 100644 --- a/src/XrmDefinitelyTyped.Core/Domain/FormType.cs +++ b/src/XrmDefinitelyTyped.Core/Domain/FormType.cs @@ -7,14 +7,14 @@ public enum FormType Main = 2, MiniCampaignBO = 3, Preview = 4, - MobileExpress = 5, - QuickView = 6, + Mobile = 5, + Quick = 6, QuickCreate = 7, Dialog = 8, TaskFlowForm = 9, InteractionCentricDashboard = 10, Card = 11, - MainInteractiveExperience = 12, + MainInteractionCentric = 12, ContextualDashboard = 13, Other = 100, MainBackup = 101, diff --git a/src/XrmDefinitelyTyped.Core/Generation/ClassIdMap.cs b/src/XrmDefinitelyTyped.Core/Generation/ClassIdMap.cs index 62fa28164c7..898de1874af 100644 --- a/src/XrmDefinitelyTyped.Core/Generation/ClassIdMap.cs +++ b/src/XrmDefinitelyTyped.Core/Generation/ClassIdMap.cs @@ -2,41 +2,43 @@ namespace XrmDefinitelyTyped.Core.Generation; internal static class ClassIdMap { + internal const string WebResourceClassId = "WEBRESOURCE"; private static readonly IReadOnlyDictionary Map = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["B0C6723A-8503-4FD7-BB28-C8A06AC933C2"] = new("Xrm.OptionSetControl", "Xrm.OptionSetAttribute"), - ["5B773807-9FB2-42DB-97C3-7A91EFF8ADFF"] = new("Xrm.DateControl", "Xrm.DateAttribute"), - ["C3EFE0C3-0EC6-42BE-8349-CBD9079DFD8E"] = new("Xrm.NumberControl", "Xrm.NumberAttribute"), - ["AA987274-CE4E-4271-A803-66164311A958"] = new("Xrm.NumberControl", "Xrm.NumberAttribute"), - ["ADA2203E-B4CD-49BE-9DDF-234642B43B52"] = new("Xrm.StringControl", "Xrm.Attribute"), - ["6F3FB987-393B-4D2D-859F-9D0F0349B6AD"] = new("Xrm.StringControl", "Xrm.Attribute"), - ["0D2C745A-E5A8-4C8F-BA63-C6D3BB604660"] = new("Xrm.NumberControl", "Xrm.NumberAttribute"), - ["FD2A7985-3187-444E-908D-6624B21F69C0"] = new("Xrm.BaseControl", null), - ["C6D124CA-7EDA-4A60-AEA9-7FB8D318B68F"] = new("Xrm.NumberControl", "Xrm.NumberAttribute"), - ["671A9387-CA5A-4D1E-8AB7-06E39DDCF6B5"] = new("Xrm.NumberControl", "Xrm.NumberAttribute"), - ["270BD3DB-D9AF-4782-9025-509E298DEC0A"] = new("Xrm.LookupControl", "Xrm.LookupAttribute"), - ["533B9E00-756B-4312-95A0-DC888637AC78"] = new("Xrm.NumberControl", "Xrm.NumberAttribute"), - ["06375649-C143-495E-A496-C962E5B4488E"] = new("Xrm.StringControl", "Xrm.Attribute"), - ["CBFB742C-14E7-4A17-96BB-1A13F7F64AA2"] = new("Xrm.LookupControl", "Xrm.LookupAttribute"), - ["3EF39988-22BB-4F0B-BBBE-64B5A3748AEE"] = new("Xrm.OptionSetControl", "Xrm.OptionSetAttribute"), - ["67FAC785-CD58-4F9F-ABB3-4B7DDC6ED5ED"] = new("Xrm.OptionSetControl", "Xrm.OptionSetAttribute"), - ["F3015350-44A2-4AA0-97B5-00166532B5E9"] = new("Xrm.LookupControl", "Xrm.LookupAttribute"), - ["5D68B988-0661-4DB2-BC3E-17598AD3BE6C"] = new("Xrm.OptionSetControl", "Xrm.OptionSetAttribute"), - ["E0DECE4B-6FC8-4A8F-A065-082708572369"] = new("Xrm.StringControl", "Xrm.Attribute"), - ["4273EDBD-AC1D-40D3-9FB2-095C621B552D"] = new("Xrm.StringControl", "Xrm.Attribute"), - ["1E1FC551-F7A8-43AF-AC34-A8DC35C7B6D4"] = new("Xrm.StringControl", "Xrm.Attribute"), - ["7C624A0B-F59E-493D-9583-638D34759266"] = new("Xrm.OptionSetControl", "Xrm.OptionSetAttribute"), - ["71716B6C-711E-476C-8AB8-5D11542BFB47"] = new("Xrm.StringControl", "Xrm.Attribute"), - ["5C5600E0-1D6E-4205-A272-BE80DA87FD42"] = new("Xrm.BaseControl", null), - ["62B0DF79-0464-470F-8AF7-4483CFEA0C7D"] = new("Xrm.BaseControl", null), - ["E7A81278-8635-4D9E-8D4D-59480B391C5B"] = new("Xrm.SubGridControl", null), - ["9C5CA0A1-AB4D-4781-BE7E-8DFBE867B87E"] = new("Xrm.BaseControl", null), - ["4AA28AB7-9C13-4F57-A73D-AD894D048B5F"] = new("Xrm.MultiSelectOptionSetControl", "Xrm.OptionSetAttribute"), - ["E616A57F-20E0-4534-8662-A101B5DDF4E0"] = new("Xrm.BaseControl", null), + ["B0C6723A-8503-4FD7-BB28-C8A06AC933C2"] = new("XDTForm.BooleanControl", "Xrm.Attributes.BooleanAttribute"), + ["5B773807-9FB2-42DB-97C3-7A91EFF8ADFF"] = new("Xrm.Controls.DateControl", "Xrm.Attributes.DateAttribute"), + ["C3EFE0C3-0EC6-42BE-8349-CBD9079DFD8E"] = new("Xrm.Controls.NumberControl", "Xrm.Attributes.NumberAttribute"), + ["AA987274-CE4E-4271-A803-66164311A958"] = new("Xrm.Controls.NumberControl", "Xrm.Attributes.NumberAttribute"), + ["ADA2203E-B4CD-49BE-9DDF-234642B43B52"] = new("Xrm.Controls.StringControl", "Xrm.Attributes.Attribute"), + ["6F3FB987-393B-4D2D-859F-9D0F0349B6AD"] = new("Xrm.Controls.StringControl", "Xrm.Attributes.Attribute"), + ["0D2C745A-E5A8-4C8F-BA63-C6D3BB604660"] = new("Xrm.Controls.NumberControl", "Xrm.Attributes.NumberAttribute"), + ["FD2A7985-3187-444E-908D-6624B21F69C0"] = new("Xrm.Controls.IframeControl", null), + ["C6D124CA-7EDA-4A60-AEA9-7FB8D318B68F"] = new("Xrm.Controls.NumberControl", "Xrm.Attributes.NumberAttribute"), + ["671A9387-CA5A-4D1E-8AB7-06E39DDCF6B5"] = new("Xrm.Controls.NumberControl", "Xrm.Attributes.NumberAttribute"), + ["270BD3DB-D9AF-4782-9025-509E298DEC0A"] = new("XDTForm.LookupControl", "XDTForm.LookupAttribute"), + ["533B9E00-756B-4312-95A0-DC888637AC78"] = new("Xrm.Controls.NumberControl", "Xrm.Attributes.NumberAttribute"), + ["06375649-C143-495E-A496-C962E5B4488E"] = new("Xrm.Controls.StringControl", "Xrm.Attributes.Attribute"), + ["CBFB742C-14E7-4A17-96BB-1A13F7F64AA2"] = new("XDTForm.LookupControl", "XDTForm.LookupAttribute"), + ["3EF39988-22BB-4F0B-BBBE-64B5A3748AEE"] = new("XDTForm.OptionSetControl", "Xrm.Attributes.OptionSetAttribute"), + ["67FAC785-CD58-4F9F-ABB3-4B7DDC6ED5ED"] = new("XDTForm.BooleanControl", "Xrm.Attributes.BooleanAttribute"), + ["F3015350-44A2-4AA0-97B5-00166532B5E9"] = new("XDTForm.LookupControl", "XDTForm.LookupAttribute"), + ["5D68B988-0661-4DB2-BC3E-17598AD3BE6C"] = new("XDTForm.OptionSetControl", "Xrm.Attributes.OptionSetAttribute"), + ["E0DECE4B-6FC8-4A8F-A065-082708572369"] = new("Xrm.Controls.StringControl", "Xrm.Attributes.Attribute"), + ["4273EDBD-AC1D-40D3-9FB2-095C621B552D"] = new("Xrm.Controls.StringControl", "Xrm.Attributes.Attribute"), + ["1E1FC551-F7A8-43AF-AC34-A8DC35C7B6D4"] = new("Xrm.Controls.StringControl", "Xrm.Attributes.Attribute"), + ["7C624A0B-F59E-493D-9583-638D34759266"] = new("XDTForm.OptionSetControl", "Xrm.Attributes.OptionSetAttribute"), + ["71716B6C-711E-476C-8AB8-5D11542BFB47"] = new("Xrm.Controls.StringControl", "Xrm.Attributes.Attribute"), + ["5C5600E0-1D6E-4205-A272-BE80DA87FD42"] = new("Xrm.Controls.Control", null), + ["62B0DF79-0464-470F-8AF7-4483CFEA0C7D"] = new("Xrm.Controls.Control", null), + ["E7A81278-8635-4D9E-8D4D-59480B391C5B"] = new("XDTForm.SubGridControl", null), + ["9C5CA0A1-AB4D-4781-BE7E-8DFBE867B87E"] = new("Xrm.Controls.Control", null), + ["4AA28AB7-9C13-4F57-A73D-AD894D048B5F"] = new("XDTForm.MultiSelectOptionSetControl", "Xrm.Attributes.MultiSelectOptionSetAttribute"), + ["E616A57F-20E0-4534-8662-A101B5DDF4E0"] = new("Xrm.Controls.KbSearchControl", null), + [WebResourceClassId] = new("XDTForm.WebResourceControl", null), }; - private static readonly ControlTypeInfo Fallback = new("Xrm.BaseControl", "Xrm.Attribute"); + private static readonly ControlTypeInfo Fallback = new("Xrm.Controls.Control", "Xrm.Attributes.Attribute"); public static ControlTypeInfo Resolve(string classId) => Map.TryGetValue(classId.Trim('{', '}'), out var info) ? info : Fallback; diff --git a/src/XrmDefinitelyTyped.Core/Generation/CodeGenerator.cs b/src/XrmDefinitelyTyped.Core/Generation/CodeGenerator.cs index 8b523de76fc..c23cdf58a5b 100644 --- a/src/XrmDefinitelyTyped.Core/Generation/CodeGenerator.cs +++ b/src/XrmDefinitelyTyped.Core/Generation/CodeGenerator.cs @@ -1,5 +1,6 @@ using XrmDefinitelyTyped.Core.Domain; using XrmDefinitelyTyped.Core.Generation.Generators; +using XrmTyped.Shared.Domain; using XrmTyped.Shared.Generation; namespace XrmDefinitelyTyped.Core.Generation; @@ -11,13 +12,24 @@ public sealed class CodeGenerator : ICodeGenerator public IEnumerable GenerateCode( IEnumerable forms, IEnumerable customApis, + XdtGenerationConfig config) => + GenerateCode(forms, [], customApis, config); + + public IEnumerable GenerateCode( + IEnumerable forms, + IEnumerable entities, + IEnumerable customApis, XdtGenerationConfig config) { ArgumentNullException.ThrowIfNull(forms); + ArgumentNullException.ThrowIfNull(entities); ArgumentNullException.ThrowIfNull(config); - var files = new List(); - files.AddRange(_formGenerator.Generate(forms.ToList())); + var files = new List + { + new FormSupportGenerator().Generate(), + }; + files.AddRange(_formGenerator.Generate(forms.ToList(), entities.ToList(), config)); // Custom API generation is not yet implemented. When added, guard with // config.GenerateCustomApis and consume the customApis argument here. diff --git a/src/XrmDefinitelyTyped.Core/Generation/Generators/FormGenerator.cs b/src/XrmDefinitelyTyped.Core/Generation/Generators/FormGenerator.cs index 730e6baa0cf..2f19bd9dfbd 100644 --- a/src/XrmDefinitelyTyped.Core/Generation/Generators/FormGenerator.cs +++ b/src/XrmDefinitelyTyped.Core/Generation/Generators/FormGenerator.cs @@ -1,9 +1,9 @@ using Scriban; using System.Reflection; using XrmDefinitelyTyped.Core.Domain; -using XrmDefinitelyTyped.Core.Generation; using XrmDefinitelyTyped.Core.Generation.Utilities; using XrmDefinitelyTyped.Core.Generation.ViewModels; +using XrmTyped.Shared.Domain; using XrmTyped.Shared.Generation; using XrmTyped.Shared.Generation.Utilities; @@ -11,66 +11,314 @@ namespace XrmDefinitelyTyped.Core.Generation.Generators; public sealed class FormGenerator { + private const string OutputDirectory = "Form"; + private const string QuickViewClassId = "5C5600E0-1D6E-4205-A272-BE80DA87FD42"; + private static readonly Template Template = TemplateRenderer.Load( Assembly.GetExecutingAssembly(), "XrmDefinitelyTyped.Core.Templates.form.sbn"); - public IReadOnlyList Generate(IReadOnlyList forms) + public IReadOnlyList Generate( + IReadOnlyList forms, + IReadOnlyList? entities = null, + XdtGenerationConfig? config = null) { ArgumentNullException.ThrowIfNull(forms); - return forms.Select(GenerateFormFile).ToList(); + + config ??= new XdtGenerationConfig(new Dictionary>()); + var supportedForms = forms + .Where(IsSupportedForm) + .ToList(); + var intersectionEntities = EntityIntersectionBuilder.Build(entities ?? [], config.IntersectMapping); + supportedForms.AddRange(BuildIntersections(supportedForms, config.IntersectMapping, intersectionEntities)); + var entitiesByLogicalName = (entities ?? []).Concat(intersectionEntities) + .ToDictionary(entity => entity.LogicalName, StringComparer.OrdinalIgnoreCase); + var formsById = supportedForms.Where(form => !form.IsIntersection).ToDictionary(form => form.Id); + + return RenameDuplicateForms(supportedForms) + .Select(namedForm => GenerateFormFile( + namedForm.Form, + namedForm.Name, + entitiesByLogicalName.GetValueOrDefault(namedForm.Form.MetadataEntityLogicalName ?? namedForm.Form.EntityLogicalName), + formsById, + config.GenerateMappings)) + .ToList(); + } + + private static bool IsSupportedForm(FormModel form) => + form.FormType is not FormType.Card + and not FormType.InteractionCentricDashboard + and not FormType.TaskFlowForm; + + private static IEnumerable BuildIntersections( + IReadOnlyList forms, + IReadOnlyDictionary> mappings, + IReadOnlyList intersectionEntities) + { + var metadataByName = intersectionEntities.ToDictionary(entity => entity.LogicalName, StringComparer.Ordinal); + foreach (var mapping in mappings.OrderBy(mapping => mapping.Key, StringComparer.Ordinal)) + { + var entityNames = mapping.Value.ToHashSet(StringComparer.OrdinalIgnoreCase); + var sharedAttributes = metadataByName[mapping.Key].Attributes + .Select(attribute => attribute.LogicalName).ToHashSet(StringComparer.OrdinalIgnoreCase); + var matchingForms = forms + .Where(form => entityNames.Contains(form.EntityLogicalName)) + .GroupBy(form => (form.FormType, Name: TypeScriptIdentifier.RemoveInvalidCharacters(form.Name))) + .OrderBy(group => group.Key.FormType) + .ThenBy(group => group.Key.Name, StringComparer.Ordinal); + + foreach (var group in matchingForms) + { + // A form shape must exist on every member entity. Other forms do not participate. + if (!entityNames.SetEquals(group.Select(form => form.EntityLogicalName))) + continue; + var selected = group.ToList(); + var commonControls = IntersectControls(selected) + .Where(control => control.DataFieldName is null || sharedAttributes.Contains(control.DataFieldName)) + .ToList(); + var commonTabs = IntersectTabs(selected); + if (commonControls.Count == 0 && commonTabs.Count == 0) + continue; + + yield return new FormModel( + Guid.Empty, + mapping.Key, + group.Key.Name, + group.Key.FormType, + commonTabs, + commonControls, + IsIntersection: true, + MetadataEntityLogicalName: mapping.Key); + } + } + } + + private static IReadOnlyList IntersectControls(IReadOnlyList forms) + { + static IEnumerable AllControls(FormModel form) => form.Tabs + .SelectMany(tab => tab.Sections) + .SelectMany(section => section.Controls) + .Concat(form.AdditionalControls ?? []); + static string Key(ControlModel control) => $"{control.Id}\u001f{control.DataFieldName}\u001f{control.ClassId}"; + + var first = AllControls(forms[0]).GroupBy(Key, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); + var common = first.Keys.ToHashSet(StringComparer.Ordinal); + foreach (var form in forms.Skip(1)) + common.IntersectWith(AllControls(form).Select(Key)); + return [.. common.Order(StringComparer.Ordinal).Select(key => first[key] with + { + CanBeNull = forms.SelectMany(AllControls).Any(control => + control.CanBeNull && (Key(control) == key || first[key].DataFieldName is not null && + string.Equals(control.DataFieldName, first[key].DataFieldName, StringComparison.OrdinalIgnoreCase))), + })]; + } + + private static IReadOnlyList IntersectTabs(IReadOnlyList forms) + { + var commonTabNames = forms[0].Tabs.Select(tab => tab.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)).ToHashSet(StringComparer.Ordinal); + foreach (var form in forms.Skip(1)) + commonTabNames.IntersectWith(form.Tabs.Select(tab => tab.Name)); + + return [.. commonTabNames.Order(StringComparer.Ordinal).Select(tabName => + { + var matchingTabs = forms.Select(form => form.Tabs.First(tab => tab.Name == tabName)).ToList(); + var commonSections = matchingTabs[0].Sections.Select(section => section.Name).ToHashSet(StringComparer.Ordinal); + foreach (var tab in matchingTabs.Skip(1)) + commonSections.IntersectWith(tab.Sections.Select(section => section.Name)); + return new TabModel( + tabName, + matchingTabs[0].Label, + [.. commonSections.Order(StringComparer.Ordinal).Select(name => new SectionModel(name, name, []))]); + })]; } - private static GeneratedFile GenerateFormFile(FormModel form) + private static string ResolveValueType(AttributeModel? attribute) => attribute switch { - var viewModel = BuildFormViewModel(form); + null => "any", + { SpecialType: SpecialAttributeType.MultiSelectOptionSet } => $"{attribute.TypeScriptType}[]", + { SpecialType: SpecialAttributeType.EntityReference } => $"XDTForm.LookupValue<{BuildLookupTargetType(attribute)}>[]", + _ => attribute.TypeScriptType, + }; + + private static IEnumerable<(FormModel Form, string Name)> RenameDuplicateForms(IReadOnlyList forms) => + forms + .GroupBy(form => (form.EntityLogicalName, form.FormType, Name: TypeScriptIdentifier.RemoveInvalidCharacters(form.Name))) + .SelectMany(group => group + .OrderBy(form => form.Id) + .Select((form, index) => (form, index == 0 ? group.Key.Name : $"{group.Key.Name}{index}"))); + + private static GeneratedFile GenerateFormFile( + FormModel form, + string generatedName, + EntityModel? entity, + IReadOnlyDictionary formsById, + bool generateMappings) + { + var viewModel = BuildFormViewModel(form, generatedName, entity, formsById, generateMappings); var content = TemplateRenderer.Render(Template, viewModel); - var filename = Path.Combine(form.EntityLogicalName, form.FormType.ToString(), $"{viewModel.FormName}.d.ts"); + var filename = Path.Combine(OutputDirectory, form.EntityLogicalName, form.FormType.ToString(), $"{viewModel.FormName}.d.ts"); return new GeneratedFile(filename, content); } - private static FormViewModel BuildFormViewModel(FormModel form) + private static FormViewModel BuildFormViewModel( + FormModel form, + string generatedName, + EntityModel? entity, + IReadOnlyDictionary formsById, + bool generateMappings) { - var tabs = form.Tabs.Select(BuildTabViewModel).ToList(); + var tabs = form.Tabs + .Where(tab => !string.IsNullOrWhiteSpace(tab.Name)) + .Select(BuildTabViewModel) + .OrderBy(tab => tab.Identifier, StringComparer.Ordinal) + .ToList(); + var metadataByLogicalName = (entity?.Attributes ?? []) + .ToDictionary(attribute => attribute.LogicalName, StringComparer.OrdinalIgnoreCase); var allControls = form.Tabs .SelectMany(t => t.Sections) .SelectMany(s => s.Controls) + .Concat(form.AdditionalControls ?? []) .ToList(); var attributes = allControls - .Where(c => c.DataFieldName is not null) - .GroupBy(c => c.DataFieldName!, StringComparer.OrdinalIgnoreCase) - .Select(g => + .Where(control => control.DataFieldName is not null && !IsQuickViewControl(control)) + .GroupBy(control => control.DataFieldName!, StringComparer.OrdinalIgnoreCase) + .Select(group => { - var typeInfo = ClassIdMap.Resolve(g.First().ClassId); - return typeInfo.AttributeType is not null - ? new AttributeViewModel(g.Key, typeInfo.AttributeType) - : null; + var control = group.First(); + metadataByLogicalName.TryGetValue(group.Key, out var metadata); + var attributeType = ResolveAttributeType(control, metadata); + return attributeType is null + ? null + : new AttributeViewModel( + group.Key, + Nullable(attributeType, group.Any(item => item.CanBeNull)), + Nullable(ResolveValueType(metadata), group.Any(item => item.CanBeNull))); }) .OfType() - .OrderBy(a => a.FieldName, StringComparer.OrdinalIgnoreCase) + .OrderBy(attribute => attribute.FieldName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var controls = RenameDuplicateControls(allControls.Where(control => !IsQuickViewControl(control))) + .Select(namedControl => + { + var metadata = namedControl.Control.DataFieldName is not null + ? metadataByLogicalName.GetValueOrDefault(namedControl.Control.DataFieldName) + : null; + var type = ResolveControlType(namedControl.Control, metadata); + return new ControlViewModel(namedControl.Name, Nullable(type, namedControl.Control.CanBeNull)); + }) + .OrderBy(control => control.ControlId, StringComparer.OrdinalIgnoreCase) .ToList(); - var controls = allControls - .Select(c => new ControlViewModel(c.Id, ClassIdMap.Resolve(c.ClassId).ControlType)) - .OrderBy(c => c.ControlId, StringComparer.OrdinalIgnoreCase) + var quickViewForms = allControls + .Where(IsQuickViewControl) + .Select(control => new QuickViewFormViewModel(control.Id, ResolveQuickViewFormType(control, formsById))) + .DistinctBy(quickView => quickView.ControlId, StringComparer.Ordinal) + .OrderBy(quickView => quickView.ControlId, StringComparer.Ordinal) .ToList(); return new FormViewModel( form.EntityLogicalName, form.FormType.ToString(), - TypeScriptIdentifier.ToPascalCase(form.Name), + generatedName, + form.FormType == FormType.Quick, + generateMappings, tabs, attributes, - controls); + controls, + quickViewForms); + } + + private static IEnumerable<(ControlModel Control, string Name)> RenameDuplicateControls(IEnumerable controls) => + controls + .GroupBy(control => control.Id, StringComparer.Ordinal) + .SelectMany(group => group.Select((control, index) => + (control, index == 0 + ? group.Key + : control.IsBusinessProcessFlow ? $"{group.Key}_{index}" : $"{group.Key}{index}"))); + + private static string ResolveQuickViewFormType(ControlModel control, IReadOnlyDictionary formsById) + { + if (control.QuickViewFormId is not Guid formId || !formsById.TryGetValue(formId, out var form)) + return "XDTForm.QuickViewFormBase"; + + return $"Form.{form.EntityLogicalName}.{form.FormType}.{TypeScriptIdentifier.RemoveInvalidCharacters(form.Name)}"; + } + + private static bool IsQuickViewControl(ControlModel control) => + string.Equals(control.ClassId.Trim('{', '}'), QuickViewClassId, StringComparison.OrdinalIgnoreCase) + || control.QuickViewFormId is not null; + + private static string? ResolveAttributeType(ControlModel control, AttributeModel? attribute) + { + if (attribute is null) + return ClassIdMap.Resolve(control.ClassId).AttributeType; + + return attribute.SpecialType switch + { + SpecialAttributeType.OptionSet => $"Xrm.Attributes.OptionSetAttribute<{attribute.TypeScriptType}>", + SpecialAttributeType.MultiSelectOptionSet => $"Xrm.Attributes.MultiSelectOptionSetAttribute<{attribute.TypeScriptType}>", + SpecialAttributeType.EntityReference => $"XDTForm.LookupAttribute<{BuildLookupTargetType(attribute)}>", + _ when attribute.TypeScriptType == "Date" => "Xrm.Attributes.DateAttribute", + _ when attribute.TypeScriptType == "number" => "Xrm.Attributes.NumberAttribute", + _ when attribute.TypeScriptType == "boolean" && IsOptionSetControl(control) => "Xrm.Attributes.BooleanAttribute", + _ => $"Xrm.Attributes.Attribute<{attribute.TypeScriptType}>", + }; + } + + private static string ResolveControlType(ControlModel control, AttributeModel? attribute) + { + var classType = ClassIdMap.Resolve(control.ClassId).ControlType; + if (classType.StartsWith("XDTForm.SubGridControl<", StringComparison.Ordinal)) + return $"XDTForm.SubGridControl<{BuildTargetType(control.TargetEntityTypes)}>"; + + if (attribute is null) + return classType; + + return attribute.SpecialType switch + { + SpecialAttributeType.OptionSet => $"XDTForm.OptionSetControl<{attribute.TypeScriptType}>", + SpecialAttributeType.MultiSelectOptionSet => $"XDTForm.MultiSelectOptionSetControl<{attribute.TypeScriptType}>", + SpecialAttributeType.EntityReference => $"XDTForm.LookupControl<{BuildLookupTargetType(attribute)}>", + _ when attribute.TypeScriptType == "Date" => "Xrm.Controls.DateControl", + _ when attribute.TypeScriptType == "number" => "Xrm.Controls.NumberControl", + _ when attribute.TypeScriptType == "string" => "Xrm.Controls.StringControl", + _ when attribute.TypeScriptType == "boolean" && IsOptionSetControl(control) => "XDTForm.BooleanControl", + _ => "Xrm.Controls.StandardControl", + }; + } + + private static string Nullable(string type, bool canBeNull) => canBeNull ? $"{type} | null" : type; + + private static bool IsOptionSetControl(ControlModel control) => + ClassIdMap.Resolve(control.ClassId).ControlType.StartsWith("XDTForm.OptionSetControl<", StringComparison.Ordinal); + + private static string BuildLookupTargetType(AttributeModel attribute) => + BuildTargetType(attribute.Targets.Select(target => target.LogicalName)); + + private static string BuildTargetType(IEnumerable? logicalNames) + { + var targets = (logicalNames ?? []) + .Where(target => !string.IsNullOrWhiteSpace(target)) + .Select(target => $"\"{target}\"") + .Distinct(StringComparer.Ordinal) + .OrderBy(target => target, StringComparer.Ordinal) + .ToArray(); + + return targets.Length == 0 ? "string" : string.Join(" | ", targets); } private static TabViewModel BuildTabViewModel(TabModel tab) { var sections = tab.Sections - .Select(s => new SectionViewModel(s.Name)) + .Where(section => !string.IsNullOrWhiteSpace(section.Name)) + .Select(section => new SectionViewModel(section.Name)) + .OrderBy(section => section.Name, StringComparer.Ordinal) .ToList(); - return new TabViewModel(tab.Name, sections); + return new TabViewModel(TypeScriptIdentifier.RemoveInvalidCharacters(tab.Name), tab.Name, sections); } } diff --git a/src/XrmDefinitelyTyped.Core/Generation/Generators/FormSupportGenerator.cs b/src/XrmDefinitelyTyped.Core/Generation/Generators/FormSupportGenerator.cs new file mode 100644 index 00000000000..64f3d1f6622 --- /dev/null +++ b/src/XrmDefinitelyTyped.Core/Generation/Generators/FormSupportGenerator.cs @@ -0,0 +1,16 @@ +using Scriban; +using System.Reflection; +using XrmTyped.Shared.Generation; +using XrmTyped.Shared.Generation.Utilities; + +namespace XrmDefinitelyTyped.Core.Generation.Generators; + +public sealed class FormSupportGenerator +{ + private static readonly Template Template = TemplateRenderer.Load( + Assembly.GetExecutingAssembly(), + "XrmDefinitelyTyped.Core.Templates.form-support.sbn"); + + public GeneratedFile Generate() => + new(Path.Combine("_internal", "XrmDefinitelyTyped.d.ts"), TemplateRenderer.Render(Template, new { })); +} diff --git a/src/XrmDefinitelyTyped.Core/Generation/Utilities/TypeScriptIdentifier.cs b/src/XrmDefinitelyTyped.Core/Generation/Utilities/TypeScriptIdentifier.cs index 35428d1df94..c45bce23111 100644 --- a/src/XrmDefinitelyTyped.Core/Generation/Utilities/TypeScriptIdentifier.cs +++ b/src/XrmDefinitelyTyped.Core/Generation/Utilities/TypeScriptIdentifier.cs @@ -13,6 +13,15 @@ public static string Sanitize(string name) return result.Length > 0 && !char.IsLetter(result[0]) ? "_" + result : result; } + public static string RemoveInvalidCharacters(string name) + { + var result = InvalidCharsRegex().Replace(name, string.Empty); + if (result.Length == 0) + return "_EmptyString"; + + return char.IsAsciiDigit(result[0]) ? $"_{result}" : result; + } + public static string ToPascalCase(string name) { var words = name.Split([' ', '_', '-', '.'], StringSplitOptions.RemoveEmptyEntries); diff --git a/src/XrmDefinitelyTyped.Core/Generation/ViewModels/AttributeViewModel.cs b/src/XrmDefinitelyTyped.Core/Generation/ViewModels/AttributeViewModel.cs index b1f00687953..680e7ecc551 100644 --- a/src/XrmDefinitelyTyped.Core/Generation/ViewModels/AttributeViewModel.cs +++ b/src/XrmDefinitelyTyped.Core/Generation/ViewModels/AttributeViewModel.cs @@ -1,3 +1,3 @@ namespace XrmDefinitelyTyped.Core.Generation.ViewModels; -internal sealed record AttributeViewModel(string FieldName, string AttributeType); +internal sealed record AttributeViewModel(string FieldName, string AttributeType, string ValueType); diff --git a/src/XrmDefinitelyTyped.Core/Generation/ViewModels/FormViewModel.cs b/src/XrmDefinitelyTyped.Core/Generation/ViewModels/FormViewModel.cs index 93b3ccd7e1c..05e365a0ea7 100644 --- a/src/XrmDefinitelyTyped.Core/Generation/ViewModels/FormViewModel.cs +++ b/src/XrmDefinitelyTyped.Core/Generation/ViewModels/FormViewModel.cs @@ -4,6 +4,9 @@ internal sealed record FormViewModel( string EntityLogicalName, string FormTypeName, string FormName, + bool IsQuickView, + bool GenerateMappings, IReadOnlyList Tabs, IReadOnlyList Attributes, - IReadOnlyList Controls); + IReadOnlyList Controls, + IReadOnlyList QuickViewForms); diff --git a/src/XrmDefinitelyTyped.Core/Generation/ViewModels/QuickViewFormViewModel.cs b/src/XrmDefinitelyTyped.Core/Generation/ViewModels/QuickViewFormViewModel.cs new file mode 100644 index 00000000000..9918a451d77 --- /dev/null +++ b/src/XrmDefinitelyTyped.Core/Generation/ViewModels/QuickViewFormViewModel.cs @@ -0,0 +1,3 @@ +namespace XrmDefinitelyTyped.Core.Generation.ViewModels; + +internal sealed record QuickViewFormViewModel(string ControlId, string FormType); diff --git a/src/XrmDefinitelyTyped.Core/Generation/ViewModels/TabViewModel.cs b/src/XrmDefinitelyTyped.Core/Generation/ViewModels/TabViewModel.cs index 70b8cdd1040..5bfc542db6a 100644 --- a/src/XrmDefinitelyTyped.Core/Generation/ViewModels/TabViewModel.cs +++ b/src/XrmDefinitelyTyped.Core/Generation/ViewModels/TabViewModel.cs @@ -1,5 +1,6 @@ namespace XrmDefinitelyTyped.Core.Generation.ViewModels; internal sealed record TabViewModel( + string Identifier, string Name, IReadOnlyList Sections); diff --git a/src/XrmDefinitelyTyped.Core/Generation/XdtGenerationConfig.cs b/src/XrmDefinitelyTyped.Core/Generation/XdtGenerationConfig.cs index ec2a9925737..0c3dedd52c0 100644 --- a/src/XrmDefinitelyTyped.Core/Generation/XdtGenerationConfig.cs +++ b/src/XrmDefinitelyTyped.Core/Generation/XdtGenerationConfig.cs @@ -3,4 +3,5 @@ namespace XrmDefinitelyTyped.Core.Generation; public record XdtGenerationConfig( IReadOnlyDictionary> IntersectMapping, bool SingleFile = false, - bool GenerateCustomApis = true); \ No newline at end of file + bool GenerateCustomApis = true, + bool GenerateMappings = false); diff --git a/src/XrmDefinitelyTyped.Core/Metadata/DataverseMetadataFetcher.cs b/src/XrmDefinitelyTyped.Core/Metadata/DataverseMetadataFetcher.cs index 96d0f54c5cb..d78e5295f2c 100644 --- a/src/XrmDefinitelyTyped.Core/Metadata/DataverseMetadataFetcher.cs +++ b/src/XrmDefinitelyTyped.Core/Metadata/DataverseMetadataFetcher.cs @@ -1,28 +1,43 @@ -using XrmDefinitelyTyped.Core.Domain; using Microsoft.PowerPlatform.Dataverse.Client; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; +using System.Text.Json; +using System.Text.RegularExpressions; using System.Xml.Linq; +using XrmDefinitelyTyped.Core.Domain; +using XrmDefinitelyTyped.Core.Generation; using XrmTyped.Shared.Metadata; namespace XrmDefinitelyTyped.Core.Metadata; -public sealed class DataverseMetadataFetcher(ServiceClient serviceClient, XrmFetchConfig config) : IDataverseMetadataFetcher +public sealed partial class DataverseMetadataFetcher(ServiceClient serviceClient, XrmFetchConfig config) : IDataverseMetadataFetcher { private const int MaxParallelism = 8; + private const string CustomControlClassId = "F9A8A302-114E-466A-B582-6771B2AE0D92"; + private const string TextBoxClassId = "4273EDBD-AC1D-40D3-9FB2-095C621B552D"; + + private static readonly string[] AddressCompositeSuffixes = + [ + "line1", "line2", "line3", "city", "stateorprovince", "postalcode", "country", + ]; private readonly EntitySelectionResolver entitySelectionResolver = new(serviceClient, config); + [GeneratedRegex("^address(\\d)_composite$", RegexOptions.IgnoreCase)] + private static partial Regex CompositeAddressRegex(); + public async Task> FetchMetadataAsync() { var entityNames = await entitySelectionResolver.ResolveEntityNamesAsync(); - var query = BuildFormQuery(entityNames); - var entities = await RetrieveAllPagesAsync(query); + var formsTask = RetrieveAllPagesAsync(BuildFormQuery(entityNames)); + var bpfTask = FetchBusinessProcessFlowControlsAsync(entityNames); + await Task.WhenAll(formsTask, bpfTask); - return entities + var bpfControls = await bpfTask; + return (await formsTask) .AsParallel() .WithDegreeOfParallelism(MaxParallelism) - .Select(ParseForm) + .Select(entity => ParseForm(entity, bpfControls)) .ToList(); } @@ -37,16 +52,116 @@ private QueryExpression BuildFormQuery(IReadOnlyList entityNames) if (config.SkipInactiveForms) query.Criteria.AddCondition("formactivationstate", ConditionOperator.Equal, 1); - if (entityNames.Count > 0) + AddEntityNameCondition(query, "objecttypecode", entityNames); + return query; + } + + private async Task>> FetchBusinessProcessFlowControlsAsync( + IReadOnlyList entityNames) + { + var query = new QueryExpression("workflow") { - var condition = new ConditionExpression("objecttypecode", ConditionOperator.In); - foreach (var name in entityNames) - condition.Values.Add(name); + ColumnSet = new ColumnSet("clientdata"), + Criteria = new FilterExpression(LogicalOperator.And), + }; + query.Criteria.AddCondition("category", ConditionOperator.Equal, 4); + query.Criteria.AddCondition("clientdata", ConditionOperator.NotNull); + + var selectedNames = entityNames.ToHashSet(StringComparer.OrdinalIgnoreCase); + var controls = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var workflow in await RetrieveAllPagesAsync(query)) + { + var clientData = workflow.GetAttributeValue("clientdata"); + if (string.IsNullOrWhiteSpace(clientData)) + continue; - query.Criteria.Conditions.Add(condition); + try + { + using var document = JsonDocument.Parse(clientData); + CollectBusinessProcessFlowControls(document.RootElement, null, selectedNames, controls); + } + catch (JsonException ex) + { + Console.Error.WriteLine($"Warning: unable to parse BPF workflow {workflow.Id}: {ex.Message}"); + } } - return query; + return controls.ToDictionary( + entry => entry.Key, + entry => (IReadOnlyList)entry.Value, + StringComparer.OrdinalIgnoreCase); + } + + private static void CollectBusinessProcessFlowControls( + JsonElement element, + string? currentEntity, + IReadOnlySet selectedNames, + IDictionary> controls) + { + if (element.ValueKind == JsonValueKind.Array) + { + foreach (var child in element.EnumerateArray()) + CollectBusinessProcessFlowControls(child, currentEntity, selectedNames, controls); + return; + } + + if (element.ValueKind != JsonValueKind.Object) + return; + + var className = GetJsonString(element, "__class") ?? string.Empty; + if (className.StartsWith("EntityStep", StringComparison.Ordinal)) + currentEntity = GetJsonString(element, "description") ?? currentEntity; + + if (className.StartsWith("ControlStep", StringComparison.Ordinal) + && !string.IsNullOrWhiteSpace(currentEntity) + && (selectedNames.Count == 0 || selectedNames.Contains(currentEntity))) + { + var attributeName = GetJsonString(element, "dataFieldName"); + if (!string.IsNullOrWhiteSpace(attributeName)) + { + var target = element.TryGetProperty("entity", out var relatedEntity) + ? GetJsonString(relatedEntity, "entityName") + : null; + var control = new ControlModel( + $"header_process_{attributeName}", + attributeName, + GetJsonString(element, "classId") ?? string.Empty, + string.IsNullOrWhiteSpace(target) ? [] : [target], + CanBeNull: true, + IsBusinessProcessFlow: true); + + if (!controls.TryGetValue(currentEntity, out var entityControls)) + { + entityControls = []; + controls[currentEntity] = entityControls; + } + entityControls.Add(control); + } + } + + foreach (var property in element.EnumerateObject()) + { + if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) + CollectBusinessProcessFlowControls(property.Value, currentEntity, selectedNames, controls); + } + } + + private static string? GetJsonString(JsonElement element, string propertyName) => + element.ValueKind == JsonValueKind.Object + && element.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private static void AddEntityNameCondition(QueryExpression query, string attributeName, IReadOnlyList entityNames) + { + if (entityNames.Count == 0) + return; + + var condition = new ConditionExpression(attributeName, ConditionOperator.In); + foreach (var name in entityNames) + condition.Values.Add(name); + query.Criteria.Conditions.Add(condition); } private async Task> RetrieveAllPagesAsync(QueryExpression query) @@ -69,7 +184,9 @@ private async Task> RetrieveAllPagesAsync(QueryExpression return results; } - private FormModel ParseForm(Entity entity) + private FormModel ParseForm( + Entity entity, + IReadOnlyDictionary> bpfControls) { var id = entity.Id; var entityLogicalName = entity.GetAttributeValue("objecttypecode") ?? string.Empty; @@ -79,12 +196,27 @@ private FormModel ParseForm(Entity entity) var formXml = entity.GetAttributeValue("formxml") ?? "
"; var formElement = XDocument.Parse(formXml).Root ?? new XElement("form"); - var tabs = ParseTabs(formElement, config.LabelMappings); + var controlClassIds = ParseCustomControlClassIds(formElement); + var tabs = ParseTabs(formElement, config.LabelMappings, controlClassIds); + bpfControls.TryGetValue(entityLogicalName, out var additionalControls); - return new FormModel(id, entityLogicalName, name, formType, tabs); + return new FormModel(id, entityLogicalName, name, formType, tabs, additionalControls); } - private static IReadOnlyList ParseTabs(XElement formElement, IReadOnlyDictionary labelMappings) + private static IReadOnlyDictionary ParseCustomControlClassIds(XElement formElement) => + formElement.Descendants("controlDescription") + .Select(description => new + { + ForControl = description.Attribute("forControl")?.Value, + ClassId = description.Elements("customControl").Select(control => control.Attribute("id")?.Value).FirstOrDefault(value => value is not null), + }) + .Where(item => !string.IsNullOrWhiteSpace(item.ForControl) && !string.IsNullOrWhiteSpace(item.ClassId)) + .ToDictionary(item => item.ForControl!, item => item.ClassId!, StringComparer.OrdinalIgnoreCase); + + private static IReadOnlyList ParseTabs( + XElement formElement, + IReadOnlyDictionary labelMappings, + IReadOnlyDictionary customControlClassIds) { return formElement .Element("tabs")? @@ -92,54 +224,126 @@ private static IReadOnlyList ParseTabs(XElement formElement, IReadOnly .Select(tab => new TabModel( tab.Attribute("name")?.Value ?? string.Empty, GetLabel(tab, labelMappings), - ParseSections(tab, labelMappings))) + ParseSections(tab, labelMappings, customControlClassIds))) .ToList() ?? []; } - private static IReadOnlyList ParseSections(XElement tabElement, IReadOnlyDictionary labelMappings) + private static IReadOnlyList ParseSections( + XElement tabElement, + IReadOnlyDictionary labelMappings, + IReadOnlyDictionary customControlClassIds) { return tabElement .Element("columns")? .Elements("column") - .SelectMany(col => col.Element("sections")?.Elements("section") ?? []) + .SelectMany(column => column.Element("sections")?.Elements("section") ?? []) .Select(section => new SectionModel( section.Attribute("name")?.Value ?? string.Empty, GetLabel(section, labelMappings), - ParseControls(section))) + ParseControls(section, customControlClassIds))) .ToList() ?? []; } - private static IReadOnlyList ParseControls(XElement sectionElement) + private static IReadOnlyList ParseControls( + XElement sectionElement, + IReadOnlyDictionary customControlClassIds) { return sectionElement .Element("rows")? .Elements("row") .SelectMany(row => row.Elements("cell")) .SelectMany(cell => cell.Elements("control")) - .Select(control => new ControlModel( - control.Attribute("id")?.Value ?? string.Empty, - control.Attribute("datafieldname")?.Value, - control.Attribute("classid")?.Value ?? string.Empty)) + .SelectMany(control => ExpandCompositeControl(ParseControl(control, customControlClassIds))) .ToList() ?? []; } - private static string GetLabel(XElement element, IReadOnlyDictionary labelMappings) + private static ControlModel ParseControl( + XElement control, + IReadOnlyDictionary customControlClassIds) { - var labels = element.Element("labels")?.Elements("label").ToList() ?? []; - if (labels.Count == 0) - return string.Empty; + var id = control.Attribute("id")?.Value ?? string.Empty; + var classId = NormalizeClassId(control.Attribute("classid")?.Value); + var uniqueId = control.Attribute("uniqueid")?.Value ?? string.Empty; + if (string.Equals(classId, CustomControlClassId, StringComparison.OrdinalIgnoreCase) + && customControlClassIds.TryGetValue(uniqueId, out var customClassId)) + { + classId = NormalizeClassId(customClassId); + } + + if (id.StartsWith("WebResource_", StringComparison.Ordinal)) + classId = ClassIdMap.WebResourceClassId; + + var dataFieldName = control.Attribute("datafieldname")?.Value; + var targets = control.Descendants("TargetEntityType") + .Select(target => target.Value) + .Where(target => !string.IsNullOrWhiteSpace(target)) + .Distinct(StringComparer.Ordinal) + .ToList(); + var (quickViewFormId, quickViewEntityName) = ParseQuickViewReference(control); + var nullable = string.Equals(dataFieldName, "fullname", StringComparison.OrdinalIgnoreCase) + || (dataFieldName is not null && CompositeAddressRegex().IsMatch(dataFieldName)); + + return new ControlModel( + id, + dataFieldName, + classId, + targets, + nullable, + IsBusinessProcessFlow: false, + quickViewFormId, + quickViewEntityName); + } + + private static IEnumerable ExpandCompositeControl(ControlModel control) + { + yield return control; + if (control.DataFieldName is null) + yield break; - foreach (var langCode in labelMappings.Keys) + var match = CompositeAddressRegex().Match(control.DataFieldName); + if (!match.Success) + yield break; + + var prefix = $"address{match.Groups[1].Value}_"; + foreach (var suffix in AddressCompositeSuffixes) + { + var attributeName = prefix + suffix; + yield return new ControlModel( + $"{control.Id}_compositionLinkControl_{attributeName}", + attributeName, + TextBoxClassId, + CanBeNull: true); + } + } + + private static (Guid? FormId, string? EntityName) ParseQuickViewReference(XElement control) + { + var xml = control.Descendants("QuickForms").Select(element => element.Value).FirstOrDefault(); + if (string.IsNullOrWhiteSpace(xml)) + return (null, null); + + try { - var match = labels.FirstOrDefault(l => l.Attribute("languagecode")?.Value == langCode); - if (match is not null) - return match.Attribute("description")?.Value ?? string.Empty; + var quickForm = XDocument.Parse(xml).Descendants("QuickFormId").FirstOrDefault(); + return ( + Guid.TryParse(quickForm?.Value, out var formId) ? formId : null, + quickForm?.Attribute("entityname")?.Value); } + catch (System.Xml.XmlException) + { + return (null, null); + } + } + + private static string NormalizeClassId(string? classId) => (classId ?? string.Empty).Trim('{', '}').ToUpperInvariant(); - var english = labels.FirstOrDefault(l => l.Attribute("languagecode")?.Value == "1033"); - if (english is not null) - return english.Attribute("description")?.Value ?? string.Empty; + private static string GetLabel(XElement element, IReadOnlyDictionary labelMappings) + { + var labels = element.Element("labels")?.Elements("label").ToList() ?? []; + var text = labels.FirstOrDefault(label => label.Attribute("languagecode")?.Value == "1033")?.Attribute("description")?.Value + ?? labels.FirstOrDefault()?.Attribute("description")?.Value + ?? string.Empty; - return labels[0].Attribute("description")?.Value ?? string.Empty; + return labelMappings.Aggregate(text, (current, mapping) => current.Replace(mapping.Key, mapping.Value, StringComparison.Ordinal)); } } diff --git a/src/XrmDefinitelyTyped.Core/Templates/form-support.sbn b/src/XrmDefinitelyTyped.Core/Templates/form-support.sbn new file mode 100644 index 00000000000..d8b28764ce0 --- /dev/null +++ b/src/XrmDefinitelyTyped.Core/Templates/form-support.sbn @@ -0,0 +1,94 @@ +/// +import "@delegateas/xrmquery"; + +export {}; + +declare global { + namespace XDTForm { + interface LookupValue extends Xrm.LookupValue { + entityType: TTarget; + } + + interface LookupAttribute extends Xrm.Attributes.LookupAttribute { + getValue(): LookupValue[] | null; + setValue(value: LookupValue[] | null): void; + } + + interface LookupControl extends Xrm.Controls.LookupControl { + getAttribute(): LookupAttribute; + } + + interface OptionSetControl extends Xrm.Controls.OptionSetControl { + getAttribute(): Xrm.Attributes.OptionSetAttribute; + } + + interface BooleanControl extends Xrm.Controls.BooleanControl { + getAttribute(): Xrm.Attributes.BooleanAttribute; + } + + interface MultiSelectOptionSetControl + extends Xrm.Controls.MultiSelectOptionSetControl { + getAttribute(): Xrm.Attributes.MultiSelectOptionSetAttribute; + } + + interface SubGridControl extends Xrm.Controls.GridControl {} + + interface AttributeCollectionBase + extends Xrm.Collection.ItemCollection {} + + interface ControlCollectionBase + extends Xrm.Collection.ItemCollection {} + + interface SectionCollectionBase + extends Xrm.Collection.ItemCollection {} + + interface TabCollectionBase + extends Xrm.Collection.ItemCollection {} + + interface QuickViewFormCollectionBase + extends Xrm.Collection.ItemCollection {} + + interface PageTab + extends Xrm.Controls.Tab { + sections: TSections; + } + + interface TypedEntity extends Xrm.Entity { + attributes: TAttributes; + } + + interface TypedData extends Xrm.Data { + entity: TypedEntity; + } + + interface TypedUi< + TTabs extends TabCollectionBase, + TControls extends ControlCollectionBase, + TQuickViews extends QuickViewFormCollectionBase, + > extends Xrm.Ui { + tabs: TTabs; + controls: TControls; + quickForms: TQuickViews; + } + + interface FormContextBase< + TAttributes extends AttributeCollectionBase, + TTabs extends TabCollectionBase, + TControls extends ControlCollectionBase, + TQuickViews extends QuickViewFormCollectionBase = QuickViewFormCollectionBase, + > extends Xrm.FormContext { + data: TypedData; + ui: TypedUi; + } + + type QuickViewControlBase = Omit; + + interface QuickViewForm< + TTabs extends TabCollectionBase, + TControls extends ControlCollectionBase, + > extends QuickViewControlBase {} + + type QuickViewFormBase = Xrm.Controls.QuickFormControl; + type WebResourceControl = Xrm.Controls.FramedControl; + } +} diff --git a/src/XrmDefinitelyTyped.Core/Templates/form.sbn b/src/XrmDefinitelyTyped.Core/Templates/form.sbn index 95a83e10b36..ba9a4977842 100644 --- a/src/XrmDefinitelyTyped.Core/Templates/form.sbn +++ b/src/XrmDefinitelyTyped.Core/Templates/form.sbn @@ -1,15 +1,15 @@ -declare namespace Form.{{ EntityLogicalName }}.{{ FormTypeName }} { +declare namespace Form.{{ EntityLogicalName }}{{ if FormTypeName != "" }}.{{ FormTypeName }}{{ end }} { namespace {{ FormName }} { namespace Tabs { {{~ for tab in Tabs ~}} - interface {{ tab.Name }} extends Xrm.SectionCollectionBase { + interface {{ tab.Identifier }} extends XDTForm.SectionCollectionBase { {{~ for section in tab.Sections ~}} - get(name: "{{ section.Name }}"): Xrm.PageSection; + get(name: "{{ section.Name }}"): Xrm.Controls.Section; {{~ end ~}} - get(name: string): undefined; - get(): Xrm.PageSection[]; - get(index: number): Xrm.PageSection; - get(chooser: (item: Xrm.PageSection, index: number) => boolean): Xrm.PageSection[]; + get(name: string): null; + get(): Xrm.Controls.Section[]; + get(index: number): Xrm.Controls.Section; + get(chooser: (item: Xrm.Controls.Section, index: number) => boolean): Xrm.Controls.Section[]; } {{~ if !for.last ~}} @@ -17,54 +17,79 @@ declare namespace Form.{{ EntityLogicalName }}.{{ FormTypeName }} { {{~ end ~}} } - interface Attributes extends Xrm.AttributeCollectionBase { +{{~ if !IsQuickView ~}} + interface Attributes extends XDTForm.AttributeCollectionBase { {{~ for attr in Attributes ~}} get(name: "{{ attr.FieldName }}"): {{ attr.AttributeType }}; {{~ end ~}} - get(name: string): undefined; - get(): Xrm.Attribute[]; - get(index: number): Xrm.Attribute; - get(chooser: (item: Xrm.Attribute, index: number) => boolean): Xrm.Attribute[]; + get(name: string): null; + get(): Xrm.Attributes.Attribute[]; + get(index: number): Xrm.Attributes.Attribute; + get(chooser: (item: Xrm.Attributes.Attribute, index: number) => boolean): Xrm.Attributes.Attribute[]; } - interface Controls extends Xrm.ControlCollectionBase { +{{~ end ~}} + +{{~ if GenerateMappings ~}} + interface AttributeValueMap { +{{~ for attr in Attributes ~}} + "{{ attr.FieldName }}": {{ attr.ValueType }}; +{{~ end ~}} + } + +{{~ end ~}} + interface Controls extends XDTForm.ControlCollectionBase { {{~ for ctrl in Controls ~}} get(name: "{{ ctrl.ControlId }}"): {{ ctrl.ControlType }}; {{~ end ~}} - get(name: string): undefined; - get(): Xrm.BaseControl[]; - get(index: number): Xrm.BaseControl; - get(chooser: (item: Xrm.BaseControl, index: number) => boolean): Xrm.BaseControl[]; + get(name: string): null; + get(): Xrm.Controls.Control[]; + get(index: number): Xrm.Controls.Control; + get(chooser: (item: Xrm.Controls.Control, index: number) => boolean): Xrm.Controls.Control[]; } - interface QuickViewForms extends Xrm.QuickViewFormCollectionBase { - get(name: string): undefined; - get(): Xrm.QuickViewFormBase[]; - get(index: number): Xrm.QuickViewFormBase; - get(chooser: (item: Xrm.QuickViewFormBase, index: number) => boolean): Xrm.QuickViewFormBase[]; +{{~ if GenerateMappings ~}} + interface ControlMap { +{{~ for ctrl in Controls ~}} + "{{ ctrl.ControlId }}": {{ ctrl.ControlType }}; +{{~ end ~}} } - interface Tabs extends Xrm.TabCollectionBase { +{{~ end ~}} +{{~ if !IsQuickView ~}} + interface QuickViewForms extends XDTForm.QuickViewFormCollectionBase { +{{~ for quick_view in QuickViewForms ~}} + get(name: "{{ quick_view.ControlId }}"): {{ quick_view.FormType }}; +{{~ end ~}} + get(name: string): null; + get(): XDTForm.QuickViewFormBase[]; + get(index: number): XDTForm.QuickViewFormBase; + get(chooser: (item: XDTForm.QuickViewFormBase, index: number) => boolean): XDTForm.QuickViewFormBase[]; + } + +{{~ end ~}} + + interface Tabs extends XDTForm.TabCollectionBase { {{~ for tab in Tabs ~}} - get(name: "{{ tab.Name }}"): Xrm.PageTab; + get(name: "{{ tab.Name }}"): XDTForm.PageTab; {{~ end ~}} - get(name: string): undefined; - get(): Xrm.PageTab>[]; - get(index: number): Xrm.PageTab>; - get(chooser: (item: Xrm.PageTab>, index: number) => boolean): Xrm.PageTab>[]; + get(name: string): null; + get(): Xrm.Controls.Tab[]; + get(index: number): Xrm.Controls.Tab; + get(chooser: (item: Xrm.Controls.Tab, index: number) => boolean): Xrm.Controls.Tab[]; } } - interface {{ FormName }} extends Xrm.PageBase<{{ FormName }}.Attributes,{{ FormName }}.Tabs,{{ FormName }}.Controls,{{ FormName }}.QuickViewForms> { + interface {{ FormName }} extends {{ if IsQuickView }}XDTForm.QuickViewForm<{{ FormName }}.Tabs,{{ FormName }}.Controls>{{ else }}XDTForm.FormContextBase<{{ FormName }}.Attributes,{{ FormName }}.Tabs,{{ FormName }}.Controls,{{ FormName }}.QuickViewForms>{{ end }} { {{~ for attr in Attributes ~}} getAttribute(attributeName: "{{ attr.FieldName }}"): {{ attr.AttributeType }}; {{~ end ~}} - getAttribute(attributeName: string): undefined; - getAttribute(delegateFunction: Xrm.Collection.MatchingDelegate>): Xrm.Attribute[]; + getAttribute(attributeName: string): null; + getAttribute(delegateFunction: Xrm.Collection.MatchingDelegate): Xrm.Attributes.Attribute[]; {{~ for ctrl in Controls ~}} getControl(controlName: "{{ ctrl.ControlId }}"): {{ ctrl.ControlType }}; {{~ end ~}} - getControl(controlName: string): undefined; - getControl(delegateFunction: Xrm.Collection.MatchingDelegate>): Xrm.Control[]; + getControl(controlName: string): null; + getControl(delegateFunction: Xrm.Collection.MatchingDelegate): Xrm.Controls.Control[]; } } diff --git a/src/XrmDefinitelyTyped.Tool/CommandLineParser.cs b/src/XrmDefinitelyTyped.Tool/CommandLineParser.cs index 9959fd0e259..9ec95a95c9a 100644 --- a/src/XrmDefinitelyTyped.Tool/CommandLineParser.cs +++ b/src/XrmDefinitelyTyped.Tool/CommandLineParser.cs @@ -53,11 +53,11 @@ public static PartialConfig Parse(string[] args) var intersectOption = new Option>>("--intersect", "-i") { - Description = "Comma-separated list of named semicolon-separated lists of entity logical names to intersect. Example: ICustomer:account;contact, IActivity:phonecall;email;task", + Description = "Comma-separated list of named semicolon-separated lists of entity logical names to intersect into shared interfaces for forms and XrmQuery. Example: ICustomer:account;contact, IActivity:phonecall;email;task", AllowMultipleArgumentsPerToken = true, CustomParser = result => { - var value = result.Tokens.Select(t => t.Value).ToArray(); + var value = result.Tokens.SelectMany(t => t.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).ToArray(); var dict = new Dictionary>(StringComparer.InvariantCulture); foreach (var entry in value) { @@ -90,6 +90,11 @@ public static PartialConfig Parse(string[] args) Description = "Generate Custom API types (not yet implemented).", }; + var mappingsOption = new Option("--generate-mappings", "-gm") + { + Description = "Generate AttributeValueMap and ControlMap interfaces for forms.", + }; + var generateOption = new Option("--generate", "-g") { Description = $"What to generate: '{GeneratorKinds.FormsValue}', '{GeneratorKinds.WebValue}' or both (default).", @@ -112,6 +117,7 @@ public static PartialConfig Parse(string[] args) labelMappingsOption, singleFileOption, customApisOption, + mappingsOption, generateOption, webNamespaceOption, }; @@ -130,6 +136,7 @@ public static PartialConfig Parse(string[] args) result.GetValue(skipInactiveOption), result.GetValue(singleFileOption), result.GetValue(customApisOption), + result.GetValue(mappingsOption), AsList(result.GetValue(generateOption)), result.GetValue(webNamespaceOption)); } diff --git a/src/XrmDefinitelyTyped.Tool/Configuration/AppSettingsConfigBuilder.cs b/src/XrmDefinitelyTyped.Tool/Configuration/AppSettingsConfigBuilder.cs index 48fbab2a64e..5457127e397 100644 --- a/src/XrmDefinitelyTyped.Tool/Configuration/AppSettingsConfigBuilder.cs +++ b/src/XrmDefinitelyTyped.Tool/Configuration/AppSettingsConfigBuilder.cs @@ -25,6 +25,7 @@ public static PartialConfig BuildFromConfiguration(IConfiguration configuration) configSection.GetValue("SkipInactiveForms"), configSection.GetValue("SingleFile"), configSection.GetValue("GenerateCustomApis"), + configSection.GetValue("GenerateMappings"), configSection.GetSection("Generate").Get>(), configSection.GetValue("WebNamespace")); } diff --git a/src/XrmDefinitelyTyped.Tool/Configuration/PartialConfig.cs b/src/XrmDefinitelyTyped.Tool/Configuration/PartialConfig.cs index 65c1a9c057e..7eab2c902d4 100644 --- a/src/XrmDefinitelyTyped.Tool/Configuration/PartialConfig.cs +++ b/src/XrmDefinitelyTyped.Tool/Configuration/PartialConfig.cs @@ -9,5 +9,6 @@ public record PartialConfig( bool? SkipInactiveForms, bool? SingleFile, bool? GenerateCustomApis, + bool? GenerateMappings, List? Generate, string? WebNamespace); diff --git a/src/XrmDefinitelyTyped.Tool/Program.cs b/src/XrmDefinitelyTyped.Tool/Program.cs index 3d5fdc76ac8..76667f8ff6c 100644 --- a/src/XrmDefinitelyTyped.Tool/Program.cs +++ b/src/XrmDefinitelyTyped.Tool/Program.cs @@ -31,11 +31,13 @@ new XdtGenerationConfig( paramsConfig.IntersectMapping ?? appSettingsConfig.IntersectMapping ?? new Dictionary>(), SingleFile: paramsConfig.SingleFile ?? appSettingsConfig.SingleFile ?? false, - GenerateCustomApis: paramsConfig.GenerateCustomApis ?? appSettingsConfig.GenerateCustomApis ?? false), + GenerateCustomApis: paramsConfig.GenerateCustomApis ?? appSettingsConfig.GenerateCustomApis ?? false, + GenerateMappings: paramsConfig.GenerateMappings ?? appSettingsConfig.GenerateMappings ?? false), GeneratorKinds.Parse(paramsConfig.Generate ?? appSettingsConfig.Generate ?? []), new XrmQueryGenerationConfig( paramsConfig.WebNamespace ?? appSettingsConfig.WebNamespace ?? "XDT", - SingleFile: paramsConfig.SingleFile ?? appSettingsConfig.SingleFile ?? false)); + SingleFile: paramsConfig.SingleFile ?? appSettingsConfig.SingleFile ?? false, + IntersectMapping: paramsConfig.IntersectMapping ?? appSettingsConfig.IntersectMapping)); ConfigValidator.Validate(config); @@ -52,7 +54,14 @@ var formSourceFactory = new DataverseMetadataSourceFactory(serviceClient); var entitySourceFactory = new DataverseEntityMetadataSourceFactory(serviceClient); var files = new List(); - var optionSets = new List(); + + // Entity metadata is shared by both generators. Forms need it for precise option-set, + // lookup, date and numeric attribute/control types; web generation uses the same model. + Console.WriteLine($"Fetching entity metadata from {configuration["DATAVERSE_URL"]} ..."); + var entities = await entitySourceFactory + .CreateEntityMetadataFetcher(MetadataSourceType.Dataverse, config.Fetch) + .FetchEntityMetadataAsync(); + Console.WriteLine($"Fetched {entities.Count} entity/entities."); if (config.Generators.Contains(GeneratorKind.Forms)) { @@ -62,24 +71,26 @@ .FetchMetadataAsync(); Console.WriteLine($"Fetched {forms.Count} form(s)."); - files.AddRange(new CodeGenerator().GenerateCode(forms, [], config.Generation)); + files.AddRange(new CodeGenerator().GenerateCode(forms, entities, [], config.Generation)); } if (config.Generators.Contains(GeneratorKind.Web)) - { - Console.WriteLine($"Fetching entity metadata from {configuration["DATAVERSE_URL"]} ..."); - var entities = await entitySourceFactory - .CreateEntityMetadataFetcher(MetadataSourceType.Dataverse, config.Fetch) - .FetchEntityMetadataAsync(); - Console.WriteLine($"Fetched {entities.Count} entity/entities."); - files.AddRange(new EntityTypesGenerator().Generate(entities, config.XrmQuery)); - optionSets.AddRange(entities.SelectMany(entity => entity.OptionSets)); - } - files.AddRange(new OptionSetGenerator().Generate(optionSets)); + files.AddRange(new OptionSetGenerator().Generate(entities.SelectMany(entity => entity.OptionSets).ToList())); + + if (config.Generation.SingleFile) + { + var runtimeFiles = files.Where(file => file.Filename.EndsWith(".ts", StringComparison.Ordinal) && !file.Filename.EndsWith(".d.ts", StringComparison.Ordinal)).ToList(); + var declarations = files.Where(file => file.Filename.EndsWith(".d.ts", StringComparison.Ordinal)).ToList(); + files = + [ + .. runtimeFiles, + new GeneratedFile("context.d.ts", string.Join(Environment.NewLine, declarations.Select(file => file.Content))), + ]; + } - // A single write, since the output writer clears the output directory before writing. + // A single write, since the output writer removes only files from its previous manifest. var outputFiles = files.DistinctBy(file => file.Filename, StringComparer.Ordinal).ToList(); Console.WriteLine($"Generated {outputFiles.Count} file(s)."); diff --git a/src/XrmQuery/src/query/base.ts b/src/XrmQuery/src/query/base.ts index 5185d4281a0..094d7c53053 100644 --- a/src/XrmQuery/src/query/base.ts +++ b/src/XrmQuery/src/query/base.ts @@ -97,3 +97,13 @@ export type ResultOf = UnionToIntersection< export type FormattedOf = UnionToIntersection< AttributeFormatted >; + +/** Allows a shared selector only when the queried entity satisfies its attribute contract. */ +export type CompatibleSelection = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [K in keyof T]: T[K] extends WebAttribute + ? S extends A + ? T[K] + : never + : never; +}; diff --git a/src/XrmQuery/src/query/retrieve.ts b/src/XrmQuery/src/query/retrieve.ts index 428eeb1c907..065de526b65 100644 --- a/src/XrmQuery/src/query/retrieve.ts +++ b/src/XrmQuery/src/query/retrieve.ts @@ -1,3 +1,4 @@ +import type { CompatibleSelection } from "./base.js"; import { FORMATTED_VALUES_HEADER, INCLUDE_ANNOTATIONS_HEADER } from "../headers.js"; import { idToString, type RecordId } from "../id.js"; import type { RawResponse } from "../http.js"; @@ -64,8 +65,8 @@ export class RetrieveRecord< /** Selects the attributes to retrieve, replacing any previous selection. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - select[]>( - varFunc: (x: ISelect) => T, + select[]>( + varFunc: (x: ISelect) => T & CompatibleSelection, ): RetrieveRecord, IFixed & ResultOf> { this.selects = parseSelects(varFunc as (x: never) => readonly unknown[]); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -74,8 +75,8 @@ export class RetrieveRecord< /** Adds more attributes to the current selection. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - selectMore[]>( - varFunc: (x: ISelect) => T, + selectMore[]>( + varFunc: (x: ISelect) => T & CompatibleSelection, ): RetrieveRecord< ISelect, IExpand, diff --git a/src/XrmQuery/src/query/retrieveMultiple.ts b/src/XrmQuery/src/query/retrieveMultiple.ts index b63c21cb97f..6ae6ff33e63 100644 --- a/src/XrmQuery/src/query/retrieveMultiple.ts +++ b/src/XrmQuery/src/query/retrieveMultiple.ts @@ -1,3 +1,4 @@ +import type { CompatibleSelection } from "./base.js"; import { Filter } from "../filter.js"; import { FORMATTED_VALUES_HEADER, @@ -144,8 +145,8 @@ export class RetrieveMultipleRecords< /** Selects the attributes to retrieve, replacing any previous selection. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - select[]>( - vars: (x: ISelect) => T, + select[]>( + vars: (x: ISelect) => T & CompatibleSelection, ): RetrieveMultipleRecords< ISelect, IExpand, @@ -161,8 +162,8 @@ export class RetrieveMultipleRecords< /** Adds more attributes to the current selection. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - selectMore[]>( - vars: (x: ISelect) => T, + selectMore[]>( + vars: (x: ISelect) => T & CompatibleSelection, ): RetrieveMultipleRecords< ISelect, IExpand, diff --git a/src/XrmQuery/tests/generated-compat/README.md b/src/XrmQuery/tests/generated-compat/README.md index d8bc0745a98..84f6e312168 100644 --- a/src/XrmQuery/tests/generated-compat/README.md +++ b/src/XrmQuery/tests/generated-compat/README.md @@ -1,8 +1,9 @@ # XrmQueryTyped compatibility project `typings/account.d.ts` and `typings/contact.d.ts` are `XrmQueryTyped.Core` output, copied verbatim -from `test/XrmQueryTyped.Core.Tests/Fixtures/`. `typings/enums.d.ts` is what the option-set generator -emits for `account_accountcategorycode`. `typings/stubs.d.ts` stands in for the two entities that +from `test/XrmQueryTyped.Core.Tests/Fixtures/`. `typings/enums.d.ts` and `typings/OptionSets.ts` are what the option-set generator +emits for `account_accountcategorycode`: an ambient numeric union for generated declarations and an +importable `as const` value object for named values at runtime. `typings/stubs.d.ts` stands in for the two entities that `account.d.ts` reaches through its polymorphic `ownerid` lookup — a real run generates those the same way. @@ -27,9 +28,5 @@ which collides with `tests/fixtures/entities.d.ts` in the root project — hence generator has the same limitation. - **Expanded children are non-optional.** `_Result` members are `T | null` rather than `T?: T | null`, so accessing a member of an expanded child no longer includes `undefined`. -- **Option sets are union types, not `const enum`s.** `account_accountcategorycode` is `1 | 2` with - the labels in a JSDoc block; named member access (`account_accountcategorycode.Standard`) is gone, - because an ambient `const enum` is unusable under `isolatedModules` and a `.d.ts` cannot provide a - runtime value. - **No `_Base`, `_Relationships` or `"@odata.etag"`.** Base members are inlined into `_Result`; the runtime never referenced the other two. diff --git a/src/XrmQuery/tests/generated-compat/consumer.ts b/src/XrmQuery/tests/generated-compat/consumer.ts index 34e7c586930..cef82492f84 100644 --- a/src/XrmQuery/tests/generated-compat/consumer.ts +++ b/src/XrmQuery/tests/generated-compat/consumer.ts @@ -11,6 +11,7 @@ */ import { Filter, SortOrder, XrmQuery } from "@delegateas/xrmquery"; +import { account_accountcategorycode } from "./typings/OptionSets"; /** Compile-time assertion that `Actual` is exactly `Expected`. */ type Equals = @@ -58,7 +59,7 @@ async function filtering() { Filter.ands([ Filter.startsWith(x.name, "Contoso"), Filter.greaterThan(x.revenue, 1000), - Filter.notEquals(x.accountcategorycode, 1 as account_accountcategorycode), + Filter.notEquals(x.accountcategorycode, account_accountcategorycode.PreferredCustomer), Filter.equals(x.parentaccountid_guid, Filter.makeGuid(guid)), Filter.equals(x.merged, false), ]), diff --git a/src/XrmQuery/tests/generated-compat/typings/OptionSets.ts b/src/XrmQuery/tests/generated-compat/typings/OptionSets.ts new file mode 100644 index 00000000000..4401235b784 --- /dev/null +++ b/src/XrmQuery/tests/generated-compat/typings/OptionSets.ts @@ -0,0 +1,7 @@ +export const account_accountcategorycode = { + PreferredCustomer: 1, + Standard: 2, +} as const; + +export type account_accountcategorycode = + (typeof account_accountcategorycode)[keyof typeof account_accountcategorycode]; diff --git a/src/XrmQuery/tests/types.test.ts b/src/XrmQuery/tests/types.test.ts index b30fa81f736..121cfb95493 100644 --- a/src/XrmQuery/tests/types.test.ts +++ b/src/XrmQuery/tests/types.test.ts @@ -30,6 +30,39 @@ describe("inference", () => { }>(); }); + it("accepts shared selectors only for compatible entity contracts", () => { + interface SharedSelect { + emailaddress1: WebAttribute; + telephone1: WebAttribute; + } + const shared = (x: SharedSelect) => [x.emailaddress1, x.telephone1] as const; + const query = XrmQuery.retrieveMultiple((x) => x.accounts).select(shared); + expectTypeOf>().toExtend< + { + accountid: string; + emailaddress1: string | null; + telephone1: string | null; + }[] + >(); + const record = XrmQuery.retrieve((x) => x.accounts, "ID") + .select(shared) + .selectMore((x) => [x.revenue]); + expectTypeOf>().toExtend<{ + accountid: string; + emailaddress1: string | null; + telephone1: string | null; + revenue: number | null; + }>(); + interface UnrelatedSelect { + missing: WebAttribute; + } + const unrelated = (x: UnrelatedSelect) => [x.missing] as const; + // @ts-expect-error The account entity does not satisfy this shared contract. + query.select(unrelated); + // @ts-expect-error selectMore must also reject incompatible contracts. + record.selectMore(unrelated); + }); + it("keeps inferring past the legacy 15-attribute overload cap", () => { const query = XrmQuery.retrieveMultiple((x) => x.accounts).select((x) => [ x.accountid, diff --git a/src/XrmQueryTyped.Core/Generation/EntityInterfaceBuilder.cs b/src/XrmQueryTyped.Core/Generation/EntityInterfaceBuilder.cs index cc4fe562de4..1b08d25cb65 100644 --- a/src/XrmQueryTyped.Core/Generation/EntityInterfaceBuilder.cs +++ b/src/XrmQueryTyped.Core/Generation/EntityInterfaceBuilder.cs @@ -29,7 +29,7 @@ public static IReadOnlyList Build(EntityModel entity) return [ - new(InterfaceNames.Fixed(name), string.Empty, [new(entity.PrimaryIdAttribute, string.Empty, "string")]), + new(InterfaceNames.Fixed(name), string.Empty, string.IsNullOrEmpty(entity.PrimaryIdAttribute) ? [] : [new(entity.PrimaryIdAttribute, string.Empty, "string")]), new(InterfaceNames.Result(name), $" extends {InterfaceNames.Fixed(name)}", BuildResultMembers(entity, resultRelationships)), new(InterfaceNames.FormattedResult(name), string.Empty, BuildFormattedResultMembers(entity)), new(InterfaceNames.Select(name), string.Empty, BuildSelectMembers(entity)), diff --git a/src/XrmQueryTyped.Core/Generation/EntityTypesGenerator.cs b/src/XrmQueryTyped.Core/Generation/EntityTypesGenerator.cs index 023f5eca062..a927a4ab50e 100644 --- a/src/XrmQueryTyped.Core/Generation/EntityTypesGenerator.cs +++ b/src/XrmQueryTyped.Core/Generation/EntityTypesGenerator.cs @@ -18,7 +18,8 @@ public sealed class EntityTypesGenerator public IReadOnlyList Generate(IReadOnlyList entities, XrmQueryGenerationConfig config) { - var rendered = entities + var intersections = EntityIntersectionBuilder.Build(entities, config.IntersectMapping ?? new Dictionary>()); + var rendered = entities.Concat(intersections) .OrderBy(entity => entity.LogicalName, StringComparer.Ordinal) .Select(entity => (entity.LogicalName, Content: TemplateRenderer.Render(Template, BuildViewModel(entity, config)))) .ToArray(); diff --git a/src/XrmQueryTyped.Core/Generation/XrmQueryGenerationConfig.cs b/src/XrmQueryTyped.Core/Generation/XrmQueryGenerationConfig.cs index bd8dc81ef4e..5299a6d94b7 100644 --- a/src/XrmQueryTyped.Core/Generation/XrmQueryGenerationConfig.cs +++ b/src/XrmQueryTyped.Core/Generation/XrmQueryGenerationConfig.cs @@ -2,4 +2,5 @@ namespace XrmQueryTyped.Core.Generation; public sealed record XrmQueryGenerationConfig( string Namespace = "XDT", - bool SingleFile = false); + bool SingleFile = false, + IReadOnlyDictionary>? IntersectMapping = null); diff --git a/src/XrmTyped.Shared/Generation/EntityIntersectionBuilder.cs b/src/XrmTyped.Shared/Generation/EntityIntersectionBuilder.cs new file mode 100644 index 00000000000..4a87cbafe18 --- /dev/null +++ b/src/XrmTyped.Shared/Generation/EntityIntersectionBuilder.cs @@ -0,0 +1,51 @@ +using XrmTyped.Shared.Domain; +using System.Text.RegularExpressions; + +namespace XrmTyped.Shared.Generation; + +/// Builds shared contracts, not new Dataverse tables or entity sets. +public static class EntityIntersectionBuilder +{ + public static IReadOnlyList Build( + IReadOnlyList entities, + IReadOnlyDictionary> mappings) + { + var byName = entities.ToDictionary(entity => entity.LogicalName, StringComparer.OrdinalIgnoreCase); + var intersections = new List(); + foreach (var mapping in mappings.OrderBy(mapping => mapping.Key, StringComparer.Ordinal)) + { + var name = mapping.Key; + if (!Regex.IsMatch(name, @"^[a-zA-Z_$][a-zA-Z0-9_$]*$")) + throw new ArgumentException($"Invalid intersection interface name '{mapping.Key}'."); + if (mapping.Value.Count == 0) + throw new ArgumentException($"Intersection '{name}' must specify entity logical names."); + var selected = mapping.Value.Distinct(StringComparer.OrdinalIgnoreCase).Select(value => + byName.TryGetValue(value, out var entity) ? entity : throw new ArgumentException( + $"Entity '{value}' for intersection '{name}' was not found in the fetched metadata.")).ToArray(); + if (entities.Any(entity => entity.SchemaName == name || entity.LogicalName == name)) + throw new ArgumentException($"Intersection '{name}' conflicts with an existing entity name."); + + var attributes = selected[0].Attributes.Select(attribute => + { + var matches = selected.Select(entity => entity.Attributes.FirstOrDefault(candidate => + string.Equals(candidate.LogicalName, attribute.LogicalName, StringComparison.OrdinalIgnoreCase))).ToArray(); + if (matches.Any(match => match is null || match.SpecialType != attribute.SpecialType || match.TypeScriptType != attribute.TypeScriptType)) + return null; + return attribute with + { + Readable = matches.All(match => match!.Readable), + Createable = matches.All(match => match!.Createable), + Updateable = matches.All(match => match!.Updateable), + Targets = matches.SelectMany(match => (IEnumerable)match!.Targets).Distinct().ToArray(), + }; + }).OfType().ToArray(); + var relationships = selected[0].Relationships.Where(relationship => + selected.Skip(1).All(entity => entity.Relationships.Contains(relationship))).ToArray(); + var primaryIds = selected.Select(entity => entity.PrimaryIdAttribute).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + intersections.Add(new EntityModel( + 0, name, name, null, primaryIds.Length == 1 ? primaryIds[0] : string.Empty, + attributes, relationships, [])); + } + return intersections; + } +} diff --git a/src/XrmTyped.Shared/Generation/Generators/OptionSetGenerator.cs b/src/XrmTyped.Shared/Generation/Generators/OptionSetGenerator.cs index c4b502d9041..76891807b56 100644 --- a/src/XrmTyped.Shared/Generation/Generators/OptionSetGenerator.cs +++ b/src/XrmTyped.Shared/Generation/Generators/OptionSetGenerator.cs @@ -8,27 +8,41 @@ namespace XrmTyped.Shared.Generation.Generators; public sealed class OptionSetGenerator { - private static readonly Template Template = TemplateRenderer.Load( + private const string ModuleFilename = "OptionSets.ts"; + + private static readonly Template DeclarationTemplate = TemplateRenderer.Load( Assembly.GetExecutingAssembly(), "XrmTyped.Shared.Templates.optionset.sbn"); + private static readonly Template ModuleTemplate = TemplateRenderer.Load( + Assembly.GetExecutingAssembly(), + "XrmTyped.Shared.Templates.optionsets-module.sbn"); + public IReadOnlyList Generate(IReadOnlyList optionSets) { - return optionSets + var viewModels = optionSets .DistinctBy(optionSet => optionSet.Name, StringComparer.Ordinal) .OrderBy(optionSet => optionSet.Name, StringComparer.Ordinal) - .Select(GenerateOptionSetFile) + .Select(BuildViewModel) .ToList(); + + var declarations = viewModels.Select(GenerateOptionSetDeclaration); + var module = new GeneratedFile( + ModuleFilename, + TemplateRenderer.Render(ModuleTemplate, new OptionSetsModuleViewModel(viewModels))); + + return [.. declarations, module]; } - private static GeneratedFile GenerateOptionSetFile(OptionSetModel optionSet) - { - var viewModel = new OptionSetViewModel( + private static OptionSetViewModel BuildViewModel(OptionSetModel optionSet) => + new( optionSet.Name, BuildUnion(optionSet), [.. optionSet.Options.Select(option => new OptionViewModel(option.Label, option.Value))]); - var content = TemplateRenderer.Render(Template, viewModel); + private static GeneratedFile GenerateOptionSetDeclaration(OptionSetViewModel optionSet) + { + var content = TemplateRenderer.Render(DeclarationTemplate, optionSet); var filename = Path.Combine("_internal", "Enum", $"{optionSet.Name}.d.ts"); return new GeneratedFile(filename, content); } diff --git a/src/XrmTyped.Shared/Generation/ViewModels/OptionSetsModuleViewModel.cs b/src/XrmTyped.Shared/Generation/ViewModels/OptionSetsModuleViewModel.cs new file mode 100644 index 00000000000..467234300b4 --- /dev/null +++ b/src/XrmTyped.Shared/Generation/ViewModels/OptionSetsModuleViewModel.cs @@ -0,0 +1,4 @@ +namespace XrmTyped.Shared.Generation.ViewModels; + +internal sealed record OptionSetsModuleViewModel( + IReadOnlyList OptionSets); diff --git a/src/XrmTyped.Shared/Metadata/DataverseEntityMetadataFetcher.cs b/src/XrmTyped.Shared/Metadata/DataverseEntityMetadataFetcher.cs index ea48ffea1ac..09f417cf971 100644 --- a/src/XrmTyped.Shared/Metadata/DataverseEntityMetadataFetcher.cs +++ b/src/XrmTyped.Shared/Metadata/DataverseEntityMetadataFetcher.cs @@ -18,12 +18,42 @@ public async Task> FetchEntityMetadataAsync() var metadata = entityNames.Count == 0 ? await FetchAllEntityMetadataAsync() - : await FetchEntityMetadataAsync(entityNames); + : await FetchEntityMetadataWithDependenciesAsync(entityNames); var ordered = metadata.OrderBy(entity => entity.LogicalName, StringComparer.Ordinal).ToList(); return EntityMetadataInterpreter.Interpret(ordered, nameMap, config.LabelMappings); } + + private async Task> FetchEntityMetadataWithDependenciesAsync(IReadOnlyList entityNames) + { + var selected = await FetchEntityMetadataAsync(entityNames); + var selectedNames = entityNames.ToHashSet(StringComparer.OrdinalIgnoreCase); + var dependencyNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (!selectedNames.Contains("activityparty") && selected.Any(entity => + (entity.Attributes ?? []).Any(attribute => attribute.AttributeType == AttributeTypeCode.PartyList))) + { + dependencyNames.Add("activityparty"); + } + + foreach (var relationship in selected.SelectMany(entity => entity.ManyToManyRelationships ?? [])) + { + if (selectedNames.Contains(relationship.Entity1LogicalName) + && selectedNames.Contains(relationship.Entity2LogicalName) + && !selectedNames.Contains(relationship.IntersectEntityName)) + { + dependencyNames.Add(relationship.IntersectEntityName); + } + } + + if (dependencyNames.Count == 0) + return selected; + + var dependencies = await FetchEntityMetadataAsync([.. dependencyNames]); + return [.. selected.Concat(dependencies).DistinctBy(entity => entity.LogicalName, StringComparer.OrdinalIgnoreCase)]; + } + private async Task> FetchAllEntityMetadataAsync() { var request = new RetrieveAllEntitiesRequest diff --git a/src/XrmTyped.Shared/Output/FileSystemOutputWriter.cs b/src/XrmTyped.Shared/Output/FileSystemOutputWriter.cs index bd819302b5e..460e30f7a6f 100644 --- a/src/XrmTyped.Shared/Output/FileSystemOutputWriter.cs +++ b/src/XrmTyped.Shared/Output/FileSystemOutputWriter.cs @@ -1,36 +1,96 @@ +using System.Text.Json; using XrmTyped.Shared.Generation; namespace XrmTyped.Shared.Output; public class FileSystemOutputWriter : IOutputWriter { + private const string ManifestFilename = ".xdt-manifest.json"; + + private static readonly string[] LegacyManagedDirectories = + [ + "Form", + "Web", + ]; + + private static readonly string[] LegacyManagedFiles = + [ + "OptionSets.ts", + "xrm.d.ts", + "dg.xrmquery.web.d.ts", + Path.Combine("Web", "WebEntities.d.ts"), + Path.Combine("_internal", "Enum", "LCID.d.ts"), + Path.Combine("_internal", "sdk.d.ts"), + Path.Combine("_internal", "web-entities.d.ts"), + Path.Combine("_internal", "WebResources.d.ts"), + ]; + public void WriteFiles(IEnumerable files, string outputDirectory) { ArgumentNullException.ThrowIfNull(files); - if (Directory.Exists(outputDirectory)) + var generatedFiles = files.ToList(); + Directory.CreateDirectory(outputDirectory); + RemovePreviouslyGeneratedFiles(outputDirectory); + + foreach (var file in generatedFiles) + { + var filePath = Path.Combine(outputDirectory, file.Filename); + var directoryPath = Path.GetDirectoryName(filePath) ?? throw new InvalidOperationException("Unable to determine directory path for file creation."); + Directory.CreateDirectory(directoryPath); + File.WriteAllText(filePath, file.Content); + } + + var manifestPath = Path.Combine(outputDirectory, ManifestFilename); + var manifest = generatedFiles.Select(file => NormalizePath(file.Filename)).Order(StringComparer.Ordinal).ToArray(); + File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true })); + } + + private static void RemovePreviouslyGeneratedFiles(string outputDirectory) + { + var manifestPath = Path.Combine(outputDirectory, ManifestFilename); + var hadManifest = File.Exists(manifestPath); + if (hadManifest) { - foreach (var file in Directory.GetFiles(outputDirectory)) + var paths = JsonSerializer.Deserialize(File.ReadAllText(manifestPath)) ?? []; + foreach (var relativePath in paths) { - File.Delete(file); + var fullPath = Path.GetFullPath(Path.Combine(outputDirectory, relativePath)); + if (!fullPath.StartsWith(Path.GetFullPath(outputDirectory) + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + continue; + + if (File.Exists(fullPath)) + File.Delete(fullPath); } - foreach (var dir in Directory.GetDirectories(outputDirectory)) + File.Delete(manifestPath); + } + + // Migration from the legacy generator: these directories were generator-owned. + if (!hadManifest) + { + foreach (var relativeDirectory in LegacyManagedDirectories) { - Directory.Delete(dir, true); + var directory = Path.Combine(outputDirectory, relativeDirectory); + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); } } - else + + var legacyEnumDirectory = Path.Combine(outputDirectory, "_internal", "Enum"); + if (!hadManifest && Directory.Exists(legacyEnumDirectory)) { - Directory.CreateDirectory(outputDirectory); + foreach (var file in Directory.GetFiles(legacyEnumDirectory, "*.d.ts")) + File.Delete(file); } - foreach (var file in files) + foreach (var relativeFile in LegacyManagedFiles) { - var filePath = Path.Combine(outputDirectory, file.Filename); - var directoryPath = Path.GetDirectoryName(filePath) ?? throw new InvalidOperationException("Unable to determine directory path for file creation."); - Directory.CreateDirectory(directoryPath); - File.WriteAllText(filePath, file.Content); + var file = Path.Combine(outputDirectory, relativeFile); + if (File.Exists(file)) + File.Delete(file); } } -} \ No newline at end of file + + private static string NormalizePath(string path) => path.Replace('\\', '/'); +} diff --git a/src/XrmTyped.Shared/Templates/optionsets-module.sbn b/src/XrmTyped.Shared/Templates/optionsets-module.sbn new file mode 100644 index 00000000000..81d471f6d3c --- /dev/null +++ b/src/XrmTyped.Shared/Templates/optionsets-module.sbn @@ -0,0 +1,21 @@ +/** + * Runtime values and exact numeric types for generated Dataverse option sets. + * + * Import the value whose members you need, for example: + * `import { {{ if OptionSets.size > 0 }}{{ OptionSets[0].Name }}{{ else }}MyOptionSet{{ end }} } from "./OptionSets";` + */ +{{~ for option_set in OptionSets ~}} + +export const {{ option_set.Name }} = { +{{~ for option in option_set.Options ~}} + {{ option.Label }}: {{ option.Value }}, +{{~ end ~}} +} as const; + +export type {{ option_set.Name }} = +{{~ if option_set.Options.size == 0 ~}} + number; +{{~ else ~}} + (typeof {{ option_set.Name }})[keyof typeof {{ option_set.Name }}]; +{{~ end ~}} +{{~ end ~}} diff --git a/test/XrmQueryTyped.Core.Tests/EntityTypesGeneratorTests.cs b/test/XrmQueryTyped.Core.Tests/EntityTypesGeneratorTests.cs index 6847028b38b..025456183e8 100644 --- a/test/XrmQueryTyped.Core.Tests/EntityTypesGeneratorTests.cs +++ b/test/XrmQueryTyped.Core.Tests/EntityTypesGeneratorTests.cs @@ -71,6 +71,41 @@ public void Generate_WithOutputLargerThanOneMebibyte_IsNotTruncated() Assert.Contains("field8000: string;", content, StringComparison.Ordinal); } + + [Fact] + public void Generate_EntityIntersectionEmitsSharedQueryContractWithoutEntitySet() + { + var attribute = new XrmTyped.Shared.Domain.AttributeModel("Name", "name", "string", + XrmTyped.Shared.Domain.SpecialAttributeType.Default, [], true, true, true); + var account = TestEntityModels.Account() with { Attributes = [attribute], Relationships = [] }; + var contact = TestEntityModels.Contact() with { Attributes = [attribute with { Updateable = false }], Relationships = [] }; + var config = DefaultConfig with + { + IntersectMapping = new Dictionary> { ["ICustomer"] = ["account", "contact"] }, + }; + + var files = new EntityTypesGenerator().Generate([account, contact], config); + var intersection = Assert.Single(files, file => file.Filename == Path.Combine("Web", "ICustomer.d.ts")); + Assert.Contains("interface ICustomer_Select", intersection.Content, StringComparison.Ordinal); + Assert.Contains("WebAttribute", intersection.Content, StringComparison.Ordinal); + Assert.Contains("interface ICustomer_Filter", intersection.Content, StringComparison.Ordinal); + Assert.Contains("interface ICustomer_Update {\n }", Normalize(intersection.Content), StringComparison.Ordinal); + Assert.DoesNotContain("accountid", intersection.Content, StringComparison.Ordinal); + Assert.DoesNotContain("contactid", intersection.Content, StringComparison.Ordinal); + Assert.DoesNotContain("WebEntitiesRetrieve", intersection.Content, StringComparison.Ordinal); + Assert.Contains("interface ICustomer_Select", new EntityTypesGenerator().Generate([account, contact], config with { SingleFile = true })[0].Content, StringComparison.Ordinal); + } + + [Fact] + public void Generate_IntersectionRejectsMissingEntitiesRatherThanUsingPartialContract() + { + var config = DefaultConfig with + { + IntersectMapping = new Dictionary> { ["ICustomer"] = ["account", "missing"] }, + }; + Assert.Throws(() => new EntityTypesGenerator().Generate([TestEntityModels.Account()], config)); + } + private static IReadOnlyList Generate(XrmQueryGenerationConfig config) => new EntityTypesGenerator().Generate([TestEntityModels.Account(), TestEntityModels.Contact()], config); diff --git a/test/XrmTyped.Shared.Tests/EntityIntersectionBuilderTests.cs b/test/XrmTyped.Shared.Tests/EntityIntersectionBuilderTests.cs new file mode 100644 index 00000000000..8aebae28914 --- /dev/null +++ b/test/XrmTyped.Shared.Tests/EntityIntersectionBuilderTests.cs @@ -0,0 +1,43 @@ +using XrmTyped.Shared.Domain; +using XrmTyped.Shared.Generation; + +namespace XrmTyped.Shared.Tests; + +public sealed class EntityIntersectionBuilderTests +{ + [Fact] + public void Build_OnlyIncludesCompatibleSharedAttributesAndCommonPermissions() + { + var name = new AttributeModel("Name", "name", "string", SpecialAttributeType.Default, [], true, true, true); + var incompatible = name with { LogicalName = "conflicting" }; + var account = new EntityModel(1, "Account", "account", "accounts", "accountid", [name, incompatible], [], []); + var contact = account with + { + SchemaName = "Contact", LogicalName = "contact", EntitySetName = "contacts", PrimaryIdAttribute = "contactid", + Attributes = [name with { Updateable = false }, incompatible with { TypeScriptType = "number" }], + }; + var mappings = new Dictionary> { ["ICustomer"] = ["ACCOUNT", "contact"] }; + + var intersection = Assert.Single(EntityIntersectionBuilder.Build([account, contact], mappings)); + Assert.Equal("ICustomer", intersection.SchemaName); + Assert.Null(intersection.EntitySetName); + Assert.Empty(intersection.PrimaryIdAttribute); + var attribute = Assert.Single(intersection.Attributes); + Assert.Equal("name", attribute.LogicalName); + Assert.True(attribute.Readable); + Assert.True(attribute.Createable); + Assert.False(attribute.Updateable); + } + + [Theory] + [InlineData("", "account")] + [InlineData("../BadName", "account")] + [InlineData("ICustomer", "missing")] + [InlineData("ICustomer", "284FF02B-BDD1-4BB0-9BCF-6CFDBDA130D4")] + public void Build_RejectsInvalidMappings(string name, string member) + { + var account = new EntityModel(1, "Account", "account", "accounts", "accountid", [], [], []); + var mappings = new Dictionary> { [name] = [member] }; + Assert.Throws(() => EntityIntersectionBuilder.Build([account], mappings)); + } +} diff --git a/test/XrmTyped.Shared.Tests/FileSystemOutputWriterTests.cs b/test/XrmTyped.Shared.Tests/FileSystemOutputWriterTests.cs new file mode 100644 index 00000000000..3bc3e67f8ce --- /dev/null +++ b/test/XrmTyped.Shared.Tests/FileSystemOutputWriterTests.cs @@ -0,0 +1,39 @@ +using XrmTyped.Shared.Generation; +using XrmTyped.Shared.Output; + +namespace XrmTyped.Shared.Tests; + +public sealed class FileSystemOutputWriterTests +{ + [Fact] + public void WriteFiles_RemovesLegacySupportFilesAndGeneratedDirectories() + { + var output = Path.Combine(Path.GetTempPath(), $"xdt-{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(Path.Combine(output, "Web")); + Directory.CreateDirectory(Path.Combine(output, "_internal", "Enum")); + File.WriteAllText(Path.Combine(output, "xrm.d.ts"), "legacy xrm"); + File.WriteAllText(Path.Combine(output, "dg.xrmquery.web.d.ts"), "legacy xrmquery"); + File.WriteAllText(Path.Combine(output, "_internal", "Enum", "LCID.d.ts"), "lcid"); + File.WriteAllText(Path.Combine(output, "_internal", "Enum", "stale.d.ts"), "stale enum"); + File.WriteAllText(Path.Combine(output, "Web", "stale.d.ts"), "stale"); + + new FileSystemOutputWriter().WriteFiles( + [new GeneratedFile(Path.Combine("Web", "account.d.ts"), "generated")], output); + + Assert.False(File.Exists(Path.Combine(output, "xrm.d.ts"))); + Assert.False(File.Exists(Path.Combine(output, "dg.xrmquery.web.d.ts"))); + Assert.False(File.Exists(Path.Combine(output, "Web", "stale.d.ts"))); + Assert.False(File.Exists(Path.Combine(output, "_internal", "Enum", "stale.d.ts"))); + Assert.False(File.Exists(Path.Combine(output, "_internal", "Enum", "LCID.d.ts"))); + Assert.Equal("generated", File.ReadAllText(Path.Combine(output, "Web", "account.d.ts"))); + Assert.True(File.Exists(Path.Combine(output, ".xdt-manifest.json"))); + } + finally + { + if (Directory.Exists(output)) + Directory.Delete(output, recursive: true); + } + } +} diff --git a/test/XrmTyped.Shared.Tests/FormGeneratorTests.cs b/test/XrmTyped.Shared.Tests/FormGeneratorTests.cs new file mode 100644 index 00000000000..a78c00607d6 --- /dev/null +++ b/test/XrmTyped.Shared.Tests/FormGeneratorTests.cs @@ -0,0 +1,175 @@ +using XrmDefinitelyTyped.Core.Domain; +using XrmDefinitelyTyped.Core.Generation; +using XrmDefinitelyTyped.Core.Generation.Generators; +using XrmTyped.Shared.Domain; + +namespace XrmTyped.Shared.Tests; + +public sealed class FormGeneratorTests +{ + [Fact] + public void Generate_UsesMetadataForNamedOptionSetsAndLookupTargets() + { + var form = new FormModel( + Guid.NewGuid(), + "ctx_sample", + "Information", + FormType.Main, + [new TabModel("general", "General", [new SectionModel("details", "Details", [ + new ControlModel("ctx_status", "ctx_status", "3EF39988-22BB-4F0B-BBBE-64B5A3748AEE"), + new ControlModel("ownerid", "ownerid", "270BD3DB-D9AF-4782-9025-509E298DEC0A"), + ])])]); + + var entity = new EntityModel( + 10000, + "ctx_Sample", + "ctx_sample", + "ctx_samples", + "ctx_sampleid", + [ + new AttributeModel("ctx_Status", "ctx_status", "ctx_sample_ctx_status", SpecialAttributeType.OptionSet, [], true, true, true), + new AttributeModel("OwnerId", "ownerid", "string", SpecialAttributeType.EntityReference, + [new TargetEntitySet("systemuser", "systemusers"), new TargetEntitySet("team", "teams")], true, true, true), + ], + [], + [new OptionSetModel("ctx_sample_ctx_status", [new OptionModel("Active", 1)])]); + + var file = Assert.Single(new FormGenerator().Generate([form], [entity])); + + Assert.Equal(Path.Combine("Form", "ctx_sample", "Main", "Information.d.ts"), file.Filename); + Assert.Contains("Xrm.Attributes.OptionSetAttribute", file.Content, StringComparison.Ordinal); + Assert.Contains("XDTForm.OptionSetControl", file.Content, StringComparison.Ordinal); + Assert.Contains("XDTForm.LookupAttribute<\"systemuser\" | \"team\">", file.Content, StringComparison.Ordinal); + Assert.Contains("XDTForm.LookupControl<\"systemuser\" | \"team\">", file.Content, StringComparison.Ordinal); + Assert.Contains("interface general extends", file.Content, StringComparison.Ordinal); + } + + [Fact] + public void Generate_UsesMultiSelectOptionSetType() + { + var form = new FormModel( + Guid.NewGuid(), + "ctx_sample", + "Information", + FormType.Main, + [new TabModel("general", "General", [new SectionModel("details", "Details", [ + new ControlModel("ctx_tags", "ctx_tags", "4AA28AB7-9C13-4F57-A73D-AD894D048B5F"), + ])])]); + var entity = new EntityModel( + 10000, "ctx_Sample", "ctx_sample", "ctx_samples", "ctx_sampleid", + [new AttributeModel("ctx_Tags", "ctx_tags", "ctx_sample_ctx_tags", SpecialAttributeType.MultiSelectOptionSet, [], true, true, true)], + [], + [new OptionSetModel("ctx_sample_ctx_tags", [new OptionModel("One", 1)])]); + + var file = Assert.Single(new FormGenerator().Generate([form], [entity])); + + Assert.Contains("Xrm.Attributes.MultiSelectOptionSetAttribute", file.Content, StringComparison.Ordinal); + Assert.Contains("XDTForm.MultiSelectOptionSetControl", file.Content, StringComparison.Ordinal); + } + [Fact] + public void Generate_SkipsUnsupportedFormsAndEmptyTabNames() + { + var main = new FormModel( + Guid.NewGuid(), "ctx_sample", "Profile Web Form (Enhanced)", FormType.Main, + [new TabModel(string.Empty, string.Empty, [new SectionModel(string.Empty, string.Empty, [])])]); + var card = main with { Id = Guid.NewGuid(), Name = "Card", FormType = FormType.Card }; + + var file = Assert.Single(new FormGenerator().Generate([main, card])); + + Assert.Equal(Path.Combine("Form", "ctx_sample", "Main", "ProfileWebFormEnhanced.d.ts"), file.Filename); + Assert.DoesNotContain("interface extends", file.Content, StringComparison.Ordinal); + Assert.DoesNotContain("Tabs.>", file.Content, StringComparison.Ordinal); + } + [Fact] + public void Generate_QuickViewUsesQuickViewFormShape() + { + var form = new FormModel( + Guid.NewGuid(), "ctx_sample", "Information", FormType.Quick, + [new TabModel("general", "General", [new SectionModel("details", "Details", [])])]); + + var file = Assert.Single(new FormGenerator().Generate([form])); + + Assert.Contains("extends XDTForm.QuickViewForm", file.Content, StringComparison.Ordinal); + Assert.DoesNotContain("interface Attributes extends", file.Content, StringComparison.Ordinal); + Assert.DoesNotContain("interface QuickViewForms extends", file.Content, StringComparison.Ordinal); + } + [Fact] + public void Generate_RenamesDuplicateControlsAndPreservesNullability() + { + var duplicate = new ControlModel( + "header_process_name", "name", "4273EDBD-AC1D-40D3-9FB2-095C621B552D", + CanBeNull: true, IsBusinessProcessFlow: true); + var form = new FormModel( + Guid.NewGuid(), "account", "Information", FormType.Main, + [new TabModel("general", "General", [new SectionModel("details", "Details", [duplicate, duplicate])])]); + + var file = Assert.Single(new FormGenerator().Generate([form])); + + Assert.Contains("get(name: \"header_process_name\"): Xrm.Controls.StringControl | null", file.Content, StringComparison.Ordinal); + Assert.Contains("get(name: \"header_process_name_1\"): Xrm.Controls.StringControl | null", file.Content, StringComparison.Ordinal); + } + + [Fact] + public void Generate_EntityIntersectionsMatchFormsAndIgnoreUnmatchedForms() + { + var common = new ControlModel("name", "name", "4273EDBD-AC1D-40D3-9FB2-095C621B552D"); + var accountOnly = common with { Id = "accountonly", DataFieldName = "accountonly" }; + var forms = new[] + { + new FormModel(Guid.NewGuid(), "account", "Information", FormType.Main, [], [common, accountOnly]), + new FormModel(Guid.NewGuid(), "account", "Unmatched", FormType.Main, [], [accountOnly]), + new FormModel(Guid.NewGuid(), "contact", "Information", FormType.Main, [], + [common, common with { Id = "header_process_name", CanBeNull = true, IsBusinessProcessFlow = true }]), + new FormModel(Guid.NewGuid(), "account", "Quick", FormType.QuickCreate, [], [common]), + new FormModel(Guid.NewGuid(), "contact", "Quick", FormType.QuickCreate, [], [common]), + }; + var (account, contact) = CustomerEntities(); + var files = new FormGenerator().Generate(forms, [account, contact], CustomerConfig()); + var intersection = Assert.Single(files, file => file.Filename == Path.Combine("Form", "ICustomer", "Main", "Information.d.ts")); + Assert.Contains("declare namespace Form.ICustomer.Main", intersection.Content, StringComparison.Ordinal); + Assert.Contains("\"name\": string | null;", intersection.Content, StringComparison.Ordinal); + Assert.Contains("interface ControlMap", intersection.Content, StringComparison.Ordinal); + Assert.DoesNotContain("accountonly", intersection.Content, StringComparison.Ordinal); + Assert.DoesNotContain("any", intersection.Content, StringComparison.Ordinal); + Assert.Contains(files, file => file.Filename == Path.Combine("Form", "ICustomer", "QuickCreate", "Quick.d.ts")); + Assert.DoesNotContain(files, file => file.Filename == Path.Combine("Form", "ICustomer", "Main", "Unmatched.d.ts")); + Assert.Equal(7, files.Count); + } + + [Theory] + [InlineData("missing")] + [InlineData("different-name")] + [InlineData("different-type")] + [InlineData("no-overlap")] + [InlineData("incompatible-metadata")] + public void Generate_SkipsFormIntersectionsWithoutCommonShape(string scenario) + { + var common = new ControlModel("name", "name", "4273EDBD-AC1D-40D3-9FB2-095C621B552D"); + var first = new FormModel(Guid.NewGuid(), "account", "Information", FormType.Main, [], [common]); + var second = first with { Id = Guid.NewGuid(), EntityLogicalName = "contact" }; + second = scenario switch + { + "different-name" => second with { Name = "Other" }, + "different-type" => second with { FormType = FormType.QuickCreate }, + "no-overlap" => second with { AdditionalControls = [common with { Id = "other", DataFieldName = "other" }] }, + _ => second, + }; + var (account, contact) = CustomerEntities(); + if (scenario == "incompatible-metadata") + contact = contact with { Attributes = [contact.Attributes[0] with { TypeScriptType = "number" }] }; + FormModel[] forms = scenario == "missing" ? [first] : [first, second]; + var files = new FormGenerator().Generate(forms, [account, contact], CustomerConfig()); + Assert.Equal(forms.Length, files.Count); + Assert.DoesNotContain(files, file => file.Filename.StartsWith(Path.Combine("Form", "ICustomer"), StringComparison.Ordinal)); + } + + private static (EntityModel Account, EntityModel Contact) CustomerEntities() + { + var attribute = new AttributeModel("Name", "name", "string", SpecialAttributeType.Default, [], true, true, true); + var account = new EntityModel(1, "Account", "account", "accounts", "accountid", [attribute], [], []); + return (account, account with { SchemaName = "Contact", LogicalName = "contact", EntitySetName = "contacts", PrimaryIdAttribute = "contactid" }); + } + + private static XdtGenerationConfig CustomerConfig() => new( + new Dictionary> { ["ICustomer"] = ["account", "contact"] }, GenerateMappings: true); +} diff --git a/test/XrmTyped.Shared.Tests/FormSupportGeneratorTests.cs b/test/XrmTyped.Shared.Tests/FormSupportGeneratorTests.cs new file mode 100644 index 00000000000..92cb7756b4b --- /dev/null +++ b/test/XrmTyped.Shared.Tests/FormSupportGeneratorTests.cs @@ -0,0 +1,19 @@ +using XrmDefinitelyTyped.Core.Generation.Generators; + +namespace XrmTyped.Shared.Tests; + +public sealed class FormSupportGeneratorTests +{ + [Fact] + public void Generate_EmitsThinAugmentationOverTypesXrm() + { + var file = new FormSupportGenerator().Generate(); + + Assert.Equal(Path.Combine("_internal", "XrmDefinitelyTyped.d.ts"), file.Filename); + Assert.Contains("/// ", file.Content, StringComparison.Ordinal); + Assert.Contains("import \"@delegateas/xrmquery\";", file.Content, StringComparison.Ordinal); + Assert.Contains("extends Xrm.FormContext", file.Content, StringComparison.Ordinal); + Assert.Contains("extends Xrm.Attributes.LookupAttribute", file.Content, StringComparison.Ordinal); + Assert.DoesNotContain("declare namespace Xrm", file.Content, StringComparison.Ordinal); + } +} diff --git a/test/XrmTyped.Shared.Tests/OptionSetGeneratorTests.cs b/test/XrmTyped.Shared.Tests/OptionSetGeneratorTests.cs index e52b3ddccb4..c1b74b057d6 100644 --- a/test/XrmTyped.Shared.Tests/OptionSetGeneratorTests.cs +++ b/test/XrmTyped.Shared.Tests/OptionSetGeneratorTests.cs @@ -6,24 +6,33 @@ namespace XrmTyped.Shared.Tests; public sealed class OptionSetGeneratorTests { [Fact] - public void Generate_EmitsUnionTypeWithLabelDocumentation() + public void Generate_EmitsUnionDeclarationAndNamedConstObject() { var optionSet = new OptionSetModel("account_category", [new OptionModel("Preferred", 1), new OptionModel("Standard", 2)]); - var file = Assert.Single(new OptionSetGenerator().Generate([optionSet])); + var files = new OptionSetGenerator().Generate([optionSet]); + var declaration = Assert.Single(files, file => file.Filename == Path.Combine("_internal", "Enum", "account_category.d.ts")); + var module = Assert.Single(files, file => file.Filename == "OptionSets.ts"); - Assert.Equal(Path.Combine("_internal", "Enum", "account_category.d.ts"), file.Filename); - Assert.Contains("declare type account_category = 1 | 2;", file.Content, StringComparison.Ordinal); - Assert.Contains("`1`: Preferred", file.Content, StringComparison.Ordinal); - Assert.Contains("`2`: Standard", file.Content, StringComparison.Ordinal); + Assert.Contains("declare type account_category = 1 | 2;", declaration.Content, StringComparison.Ordinal); + Assert.Contains("export const account_category", module.Content, StringComparison.Ordinal); + Assert.Contains("Preferred: 1,", module.Content, StringComparison.Ordinal); + Assert.Contains("Standard: 2,", module.Content, StringComparison.Ordinal); + Assert.Contains("export type account_category", module.Content, StringComparison.Ordinal); + Assert.Contains("(typeof account_category)[keyof typeof account_category]", module.Content, StringComparison.Ordinal); } [Fact] public void Generate_WithoutOptions_FallsBackToNumber() { - var file = Assert.Single(new OptionSetGenerator().Generate([new OptionSetModel("empty_set", [])])); - - Assert.Contains("declare type empty_set = number;", file.Content, StringComparison.Ordinal); + var files = new OptionSetGenerator().Generate([new OptionSetModel("empty_set", [])]); + var declaration = Assert.Single(files, file => file.Filename.EndsWith("empty_set.d.ts", StringComparison.Ordinal)); + var module = Assert.Single(files, file => file.Filename == "OptionSets.ts"); + + Assert.Contains("declare type empty_set = number;", declaration.Content, StringComparison.Ordinal); + Assert.Contains("export const empty_set = {", module.Content, StringComparison.Ordinal); + Assert.Contains("export type empty_set =", module.Content, StringComparison.Ordinal); + Assert.Contains("number;", module.Content, StringComparison.Ordinal); } [Fact] @@ -33,9 +42,11 @@ public void Generate_DeduplicatesOptionSetsByNameAndOrdersThem() var second = new OptionSetModel("a_set", [new OptionModel("One", 1)]); var files = new OptionSetGenerator().Generate([first, second, first]); + var declarationFiles = files.Where(file => file.Filename.EndsWith(".d.ts", StringComparison.Ordinal)); Assert.Equal( [Path.Combine("_internal", "Enum", "a_set.d.ts"), Path.Combine("_internal", "Enum", "b_set.d.ts")], - files.Select(file => file.Filename)); + declarationFiles.Select(file => file.Filename)); + Assert.Single(files, file => file.Filename == "OptionSets.ts"); } } diff --git a/test/XrmTyped.Shared.Tests/XrmTyped.Shared.Tests.csproj b/test/XrmTyped.Shared.Tests/XrmTyped.Shared.Tests.csproj index fdc847b9ab9..656670fe9f7 100644 --- a/test/XrmTyped.Shared.Tests/XrmTyped.Shared.Tests.csproj +++ b/test/XrmTyped.Shared.Tests/XrmTyped.Shared.Tests.csproj @@ -12,6 +12,7 @@ +