diff --git a/DataModelViewer.sln b/DataModelViewer.sln index 385f248..eb7ea56 100644 --- a/DataModelViewer.sln +++ b/DataModelViewer.sln @@ -1,19 +1,16 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36429.23 d17.14 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11222.16 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Generator", "Generator\Generator.csproj", "{164968FD-4D5C-4C5F-BAE2-EBC071F2AB7D}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Generator.Tests", "Generator.Tests\Generator.Tests.csproj", "{DD894991-9A5E-4201-9651-C7367BE8FA34}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scripts", "Scripts", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{E2A0671D-5354-45C7-8D86-287DBCA099EC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AddWebResourceDescription", "Tools\Scripts\AddWebResourceDescription.csproj", "{379C35FE-EAD1-E476-6E2C-58C14BFEF2B2}" -EndProject -Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "SharedTools", "SharedTools\SharedTools.shproj", "{668737CF-1205-43E7-9CE2-1E451FD8C8A7}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{9EFE4F19-B072-4920-985D-1489373F5E1B}" + ProjectSection(SolutionItems) = preProject + Tools\Scripts\addWebResourceDescription.cs = Tools\Scripts\addWebResourceDescription.cs + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -29,23 +26,11 @@ Global {DD894991-9A5E-4201-9651-C7367BE8FA34}.Debug|Any CPU.Build.0 = Debug|Any CPU {DD894991-9A5E-4201-9651-C7367BE8FA34}.Release|Any CPU.ActiveCfg = Release|Any CPU {DD894991-9A5E-4201-9651-C7367BE8FA34}.Release|Any CPU.Build.0 = Release|Any CPU - {379C35FE-EAD1-E476-6E2C-58C14BFEF2B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {379C35FE-EAD1-E476-6E2C-58C14BFEF2B2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {379C35FE-EAD1-E476-6E2C-58C14BFEF2B2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {379C35FE-EAD1-E476-6E2C-58C14BFEF2B2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {E2A0671D-5354-45C7-8D86-287DBCA099EC} - {379C35FE-EAD1-E476-6E2C-58C14BFEF2B2} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} - {668737CF-1205-43E7-9CE2-1E451FD8C8A7} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} - EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {53B88BBA-AEED-4925-9F3A-E96F7B0E00C5} EndGlobalSection - GlobalSection(SharedMSBuildProjectFiles) = preSolution - SharedTools\SharedTools.projitems*{668737cf-1205-43e7-9ce2-1e451fd8c8a7}*SharedItemsImports = 13 - EndGlobalSection EndGlobal diff --git a/Generator/DTO/AttributeUsage.cs b/Generator/DTO/AttributeUsage.cs index 60aff26..5d2dba8 100644 --- a/Generator/DTO/AttributeUsage.cs +++ b/Generator/DTO/AttributeUsage.cs @@ -6,7 +6,9 @@ public enum ComponentType Plugin, WebResource, WorkflowActivity, - CustomApi + CustomApi, + BusinessRule, + ClassicWorkflow } public enum OperationType @@ -23,5 +25,6 @@ public record AttributeUsage( string Name, string Usage, OperationType OperationType, - ComponentType ComponentType + ComponentType ComponentType, + bool IsFromDependencyAnalysis ); diff --git a/Generator/DTO/Attributes/Attribute.cs b/Generator/DTO/Attributes/Attribute.cs index 221ce63..eea31ff 100644 --- a/Generator/DTO/Attributes/Attribute.cs +++ b/Generator/DTO/Attributes/Attribute.cs @@ -1,4 +1,5 @@ -using Microsoft.Xrm.Sdk.Metadata; +using Generator.Extensions; +using Microsoft.Xrm.Sdk.Metadata; namespace Generator.DTO.Attributes; @@ -9,6 +10,10 @@ public abstract class Attribute public bool IsPrimaryId { get; set; } public bool IsPrimaryName { get; set; } public List AttributeUsages { get; set; } = new List(); + public bool IsExplicit { get; set; } + public string PublisherName { get; set; } + public string PublisherPrefix { get; set; } + public List Solutions { get; set; } = new List(); public string DisplayName { get; } public string SchemaName { get; } public string Description { get; } @@ -23,9 +28,9 @@ protected Attribute(AttributeMetadata metadata) IsPrimaryId = metadata.IsPrimaryId ?? false; IsPrimaryName = metadata.IsPrimaryName ?? false; IsCustomAttribute = metadata.IsCustomAttribute ?? false; - DisplayName = metadata.DisplayName.UserLocalizedLabel?.Label ?? string.Empty; + DisplayName = metadata.DisplayName.ToLabelString(); SchemaName = metadata.SchemaName; - Description = metadata.Description.UserLocalizedLabel?.Label.PrettyDescription() ?? string.Empty; + Description = metadata.Description.ToLabelString().PrettyDescription() ?? string.Empty; RequiredLevel = metadata.RequiredLevel.Value; IsAuditEnabled = metadata.IsAuditEnabled.Value; IsColumnSecured = metadata.IsSecured ?? false; diff --git a/Generator/DTO/Attributes/BooleanAttribute.cs b/Generator/DTO/Attributes/BooleanAttribute.cs index 2e66f45..2481946 100644 --- a/Generator/DTO/Attributes/BooleanAttribute.cs +++ b/Generator/DTO/Attributes/BooleanAttribute.cs @@ -1,4 +1,5 @@ -using Microsoft.Xrm.Sdk.Metadata; +using Generator.Extensions; +using Microsoft.Xrm.Sdk.Metadata; namespace Generator.DTO.Attributes; @@ -11,8 +12,8 @@ internal class BooleanAttribute : Attribute public BooleanAttribute(BooleanAttributeMetadata metadata) : base(metadata) { - TrueLabel = metadata.OptionSet.TrueOption.Label.UserLocalizedLabel?.Label ?? string.Empty; - FalseLabel = metadata.OptionSet.FalseOption.Label.UserLocalizedLabel?.Label ?? string.Empty; + TrueLabel = metadata.OptionSet.TrueOption.Label.ToLabelString(); + FalseLabel = metadata.OptionSet.FalseOption.Label.ToLabelString(); DefaultValue = metadata.DefaultValue; } } diff --git a/Generator/DTO/Attributes/ChoiceAttribute.cs b/Generator/DTO/Attributes/ChoiceAttribute.cs index 313095b..f3d2ed8 100644 --- a/Generator/DTO/Attributes/ChoiceAttribute.cs +++ b/Generator/DTO/Attributes/ChoiceAttribute.cs @@ -1,4 +1,5 @@ -using Microsoft.Xrm.Sdk.Metadata; +using Generator.Extensions; +using Microsoft.Xrm.Sdk.Metadata; namespace Generator.DTO.Attributes; @@ -13,10 +14,10 @@ public class ChoiceAttribute : Attribute public ChoiceAttribute(PicklistAttributeMetadata metadata) : base(metadata) { Options = metadata.OptionSet.Options.Select(x => new Option( - x.Label.UserLocalizedLabel?.Label ?? string.Empty, + x.Label.ToLabelString(), x.Value, x.Color, - x.Description.UserLocalizedLabel?.Label.PrettyDescription() ?? string.Empty)); + x.Description.ToLabelString().PrettyDescription())); Type = "Single"; DefaultValue = metadata.DefaultFormValue; } @@ -24,10 +25,10 @@ public ChoiceAttribute(PicklistAttributeMetadata metadata) : base(metadata) public ChoiceAttribute(StateAttributeMetadata metadata) : base(metadata) { Options = metadata.OptionSet.Options.Select(x => new Option( - x.Label.UserLocalizedLabel?.Label ?? string.Empty, + x.Label.ToLabelString(), x.Value, x.Color, - x.Description.UserLocalizedLabel?.Label.PrettyDescription() ?? string.Empty)); + x.Description.ToLabelString().PrettyDescription())); Type = "Single"; DefaultValue = metadata.DefaultFormValue; } @@ -35,10 +36,10 @@ public ChoiceAttribute(StateAttributeMetadata metadata) : base(metadata) public ChoiceAttribute(MultiSelectPicklistAttributeMetadata metadata) : base(metadata) { Options = metadata.OptionSet.Options.Select(x => new Option( - x.Label.UserLocalizedLabel?.Label ?? string.Empty, + x.Label.ToLabelString(), x.Value, x.Color, - x.Description.UserLocalizedLabel?.Label.PrettyDescription() ?? string.Empty)); + x.Description.ToLabelString().PrettyDescription())); Type = "Multi"; DefaultValue = metadata.DefaultFormValue; } diff --git a/Generator/DTO/Attributes/LookupAttribute.cs b/Generator/DTO/Attributes/LookupAttribute.cs index 198fae7..5f6a4a6 100644 --- a/Generator/DTO/Attributes/LookupAttribute.cs +++ b/Generator/DTO/Attributes/LookupAttribute.cs @@ -13,10 +13,17 @@ internal class LookupAttribute : Attribute { public IEnumerable Targets { get; } - public LookupAttribute(LookupAttributeMetadata metadata, Dictionary logicalToSchema, ILogger logger) + public LookupAttribute(LookupAttributeMetadata metadata, Dictionary logicalToSchema, ILogger? logger = null) : base(metadata) { - foreach (var target in metadata.Targets) { if (!logicalToSchema.ContainsKey(target)) logger.LogError($"Missing logicalname in logicalToSchema {target}, on entity {metadata.EntityLogicalName}."); } + if (logger != null) + { + foreach (var target in metadata.Targets) + { + if (!logicalToSchema.ContainsKey(target)) + logger.LogError($"Missing logicalname in logicalToSchema {target}, on entity {metadata.EntityLogicalName}."); + } + } Targets = metadata.Targets diff --git a/Generator/DTO/Attributes/StatusAttribute.cs b/Generator/DTO/Attributes/StatusAttribute.cs index 2b1229a..a5b7a15 100644 --- a/Generator/DTO/Attributes/StatusAttribute.cs +++ b/Generator/DTO/Attributes/StatusAttribute.cs @@ -1,5 +1,5 @@ -using Microsoft.Xrm.Sdk.Metadata; -using Newtonsoft.Json; +using Generator.Extensions; +using Microsoft.Xrm.Sdk.Metadata; namespace Generator.DTO.Attributes; @@ -13,12 +13,12 @@ public StatusAttribute(StatusAttributeMetadata metadata, StateAttributeMetadata var stateToName = stateAttribute.OptionSet.Options .Where(x => x.Value != null) - .ToDictionary(x => x.Value!.Value, x => x.Label.UserLocalizedLabel?.Label ?? string.Empty); + .ToDictionary(x => x.Value!.Value, x => x.Label.ToLabelString()); Options = metadata.OptionSet.Options .Select(x => (StatusOptionMetadata)x) .Select(x => new StatusOption( - x.Label.UserLocalizedLabel?.Label ?? string.Empty, + x.Label.ToLabelString(), x.Value, x.State == null ? "Unknown State" : stateToName[x.State.Value])); } diff --git a/Generator/DTO/Record.cs b/Generator/DTO/Record.cs index c6386c6..0b69d2c 100644 --- a/Generator/DTO/Record.cs +++ b/Generator/DTO/Record.cs @@ -11,11 +11,14 @@ internal record Record( bool IsAuditEnabled, bool IsActivity, bool IsCustom, + string PublisherName, + string PublisherPrefix, OwnershipTypes Ownership, bool IsNotesEnabled, List Attributes, List Relationships, List SecurityRoles, List Keys, - string? IconBase64); + string? IconBase64, + List Solutions); diff --git a/Generator/DTO/Relationship.cs b/Generator/DTO/Relationship.cs index c26986a..938b6e3 100644 --- a/Generator/DTO/Relationship.cs +++ b/Generator/DTO/Relationship.cs @@ -9,5 +9,9 @@ public record Relationship( string TableSchema, string LookupDisplayName, string RelationshipSchema, - bool IsManyToMany, - CascadeConfiguration? CascadeConfiguration); + string RelationshipType, + bool IsExplicit, + string PublisherName, + string PublisherPrefix, + CascadeConfiguration? CascadeConfiguration, + List Solutions); diff --git a/Generator/DTO/SolutionInfo.cs b/Generator/DTO/SolutionInfo.cs new file mode 100644 index 0000000..29a8a06 --- /dev/null +++ b/Generator/DTO/SolutionInfo.cs @@ -0,0 +1,8 @@ +namespace Generator.DTO; + +/// +/// Represents solution membership information for a component (entity, attribute, or relationship) +/// +public record SolutionInfo( + Guid Id, + string Name); diff --git a/Generator/DataverseService.cs b/Generator/DataverseService.cs index fe3975a..84b3e8c 100644 --- a/Generator/DataverseService.cs +++ b/Generator/DataverseService.cs @@ -1,52 +1,54 @@ -using Azure.Core; -using Azure.Identity; -using Generator.DTO; +using Generator.DTO; using Generator.DTO.Attributes; using Generator.DTO.Warnings; +using Generator.Extensions; using Generator.Queries; using Generator.Services; using Generator.Services.Plugins; using Generator.Services.PowerAutomate; using Generator.Services.WebResources; -using Microsoft.Crm.Sdk.Messages; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.PowerPlatform.Dataverse.Client; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Metadata; -using Microsoft.Xrm.Sdk.Query; -using System.Collections.Concurrent; using System.Diagnostics; -using System.Reflection; -using Attribute = Generator.DTO.Attributes.Attribute; namespace Generator { internal class DataverseService { - private readonly ServiceClient client; - private readonly IConfiguration configuration; private readonly ILogger logger; + private readonly EntityMetadataService entityMetadataService; + private readonly SolutionService solutionService; + private readonly SecurityRoleService securityRoleService; + private readonly EntityIconService entityIconService; + private readonly RecordMappingService recordMappingService; + private readonly SolutionComponentService solutionComponentService; + private readonly WorkflowService workflowService; + private readonly RelationshipService relationshipService; private readonly List analyzerRegistrations; - public DataverseService(IConfiguration configuration, ILogger logger) + public DataverseService( + ServiceClient client, + ILogger logger, + EntityMetadataService entityMetadataService, + SolutionService solutionService, + SecurityRoleService securityRoleService, + EntityIconService entityIconService, + RecordMappingService recordMappingService, + SolutionComponentService solutionComponentService, + WorkflowService workflowService, + RelationshipService relationshipService) { - this.configuration = configuration; this.logger = logger; - - var cache = new MemoryCache(new MemoryCacheOptions()); - - var dataverseUrl = configuration["DataverseUrl"]; - if (dataverseUrl == null) - { - throw new Exception("DataverseUrl is required"); - } - - client = new ServiceClient( - instanceUrl: new Uri(dataverseUrl), - tokenProviderFunction: url => TokenProviderFunction(url, cache, logger)); + this.entityMetadataService = entityMetadataService; + this.solutionService = solutionService; + this.securityRoleService = securityRoleService; + this.entityIconService = entityIconService; + this.recordMappingService = recordMappingService; + this.workflowService = workflowService; + this.relationshipService = relationshipService; // Register all analyzers with their query functions analyzerRegistrations = new List @@ -64,773 +66,217 @@ public DataverseService(IConfiguration configuration, ILogger solutionIds => client.GetWebResourcesAsync(solutionIds), "WebResources") }; + this.solutionComponentService = solutionComponentService; } - public async Task<(IEnumerable, IEnumerable, IEnumerable)> GetFilteredMetadata() + public async Task<(IEnumerable, IEnumerable)> GetFilteredMetadata() { - var warnings = new List(); // used to collect warnings for the insights dashboard - var (solutionIds, solutionEntities) = await GetSolutionIds(); - var solutionComponents = await GetSolutionComponents(solutionIds); // (id, type, rootcomponentbehavior, solutionid) - - var entitiesInSolution = solutionComponents.Where(x => x.ComponentType == 1).Select(x => x.ObjectId).Distinct().ToList(); - var entityRootBehaviour = solutionComponents - .Where(x => x.ComponentType == 1) - .GroupBy(x => x.ObjectId) - .ToDictionary(g => g.Key, g => - { - // If any solution includes all attributes (0), use that, otherwise use the first occurrence - var behaviors = g.Select(x => x.RootComponentBehavior).ToList(); - return behaviors.Contains(0) ? 0 : behaviors.First(); - }); - var attributesInSolution = solutionComponents.Where(x => x.ComponentType == 2).Select(x => x.ObjectId).ToHashSet(); - var rolesInSolution = solutionComponents.Where(x => x.ComponentType == 20).Select(x => x.ObjectId).Distinct().ToList(); - - var entitiesInSolutionMetadata = await GetEntityMetadata(entitiesInSolution); - - var logicalNameToKeys = entitiesInSolutionMetadata.ToDictionary( - entity => entity.LogicalName, - entity => entity.Keys.Select(key => new Key( - key.DisplayName.UserLocalizedLabel?.Label ?? key.DisplayName.LocalizedLabels.First().Label, - key.LogicalName, - key.KeyAttributes) - ).ToList()); + // used to collect warnings for the insights dashboard + var warnings = new List(); + var (solutionIds, solutionEntities) = await solutionService.GetSolutionIds(); - var logicalNameToSecurityRoles = await GetSecurityRoles(rolesInSolution, entitiesInSolutionMetadata.ToDictionary(x => x.LogicalName, x => x.Privileges)); - var entityLogicalNamesInSolution = entitiesInSolutionMetadata.Select(e => e.LogicalName).ToHashSet(); - - logger.LogInformation("There are {Count} entities in the solution.", entityLogicalNamesInSolution.Count); - // Collect all referenced entities from attributes and add (needed for lookup attributes) - var relatedEntityLogicalNames = new HashSet(); - foreach (var entity in entitiesInSolutionMetadata) + /// SOLUTIONS + IEnumerable solutionComponents; + try { - var entityLogicalNamesOutsideSolution = entity.Attributes - .OfType() - .SelectMany(attr => attr.Targets) - .Distinct() - .Where(target => !entityLogicalNamesInSolution.Contains(target)); - foreach (var target in entityLogicalNamesOutsideSolution) relatedEntityLogicalNames.Add(target); + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Calling solutionComponentService.GetAllSolutionComponents()"); + solutionComponents = solutionComponentService.GetAllSolutionComponents(solutionIds); + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Retrieved {solutionComponents.Count()} solution components"); } - logger.LogInformation("There are {Count} entities referenced outside the solution.", relatedEntityLogicalNames.Count); - var referencedEntityMetadata = await GetEntityMetadataByLogicalName(relatedEntityLogicalNames.ToList()); - - var allEntityMetadata = entitiesInSolutionMetadata.Concat(referencedEntityMetadata).ToList(); - var logicalToSchema = allEntityMetadata.ToDictionary(x => x.LogicalName, x => new ExtendedEntityInformation { Name = x.SchemaName, IsInSolution = entitiesInSolutionMetadata.Any(e => e.LogicalName == x.LogicalName) }); - var attributeLogicalToSchema = allEntityMetadata.ToDictionary(x => x.LogicalName, x => x.Attributes?.ToDictionary(attr => attr.LogicalName, attr => attr.DisplayName.UserLocalizedLabel?.Label ?? attr.SchemaName) ?? []); - - var entityIconMap = await GetEntityIconMap(allEntityMetadata); - // Processes analysis - var attributeUsages = new Dictionary>>(); - - // Run all registered analyzers, passing entity metadata - foreach (var registration in analyzerRegistrations) + catch (Exception ex) { - await registration.RunAnalysisAsync(solutionIds, attributeUsages, warnings, logger, entitiesInSolutionMetadata.ToList()); + logger.LogError(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to get solution components"); + throw; } - var records = - entitiesInSolutionMetadata - .Select(x => new - { - EntityMetadata = x, - RelevantAttributes = - x.GetRelevantAttributes(attributesInSolution, entityRootBehaviour) - .Where(x => x.DisplayName.UserLocalizedLabel?.Label != null) - .ToList(), - RelevantManyToMany = - x.ManyToManyRelationships - .Where(r => entityLogicalNamesInSolution.Contains(r.Entity1LogicalName) && entityLogicalNamesInSolution.Contains(r.Entity2LogicalName)) - .ToList(), - }) - .Where(x => x.EntityMetadata.DisplayName.UserLocalizedLabel?.Label != null) - .ToList(); - - // Warn about attributes that were used in processes, but the entity could not be resolved from e.g. JavaScript file name or similar - var hash = entitiesInSolutionMetadata.SelectMany(r => [r.LogicalCollectionName?.ToLower() ?? "", r.LogicalName.ToLower()]).ToHashSet(); - warnings.AddRange(attributeUsages.Keys - .Where(k => !hash.Contains(k.ToLower())) - .SelectMany(entityKey => attributeUsages.GetValueOrDefault(entityKey)! - .SelectMany(attributeDict => attributeDict.Value - .Select(usage => - new AttributeWarning($"{attributeDict.Key} was used inside a {usage.ComponentType} component [{usage.Name}]. However, the entity {entityKey} could not be resolved in the provided solutions."))))); - - // Create solutions with their components - var solutions = await CreateSolutions(solutionEntities, solutionComponents, allEntityMetadata); - - return (records - .Select(x => - { - logicalNameToSecurityRoles.TryGetValue(x.EntityMetadata.LogicalName, out var securityRoles); - logicalNameToKeys.TryGetValue(x.EntityMetadata.LogicalName, out var keys); - - return MakeRecord( - logger, - x.EntityMetadata, - x.RelevantAttributes, - x.RelevantManyToMany, - logicalToSchema, - attributeLogicalToSchema, - securityRoles ?? [], - keys ?? [], - entityIconMap, - attributeUsages, - configuration); - }), - warnings, - solutions); - } - - private async Task> CreateSolutions( - List solutionEntities, - IEnumerable<(Guid ObjectId, int ComponentType, int RootComponentBehavior, EntityReference SolutionId)> solutionComponents, - List allEntityMetadata) - { - var solutions = new List(); - - // Create lookup dictionaries for faster access - var entityLookup = allEntityMetadata.ToDictionary(e => e.MetadataId ?? Guid.Empty, e => e); - - // Fetch all unique publishers for the solutions - var publisherIds = solutionEntities - .Select(s => s.GetAttributeValue("publisherid").Id) - .Distinct() - .ToList(); - - var publisherQuery = new QueryExpression("publisher") + // Build solution lookup: SolutionId -> SolutionInfo + var solutionLookup = solutionEntities.ToDictionary( + s => s.GetAttributeValue("solutionid"), + s => new DTO.SolutionInfo( + s.GetAttributeValue("solutionid"), + s.GetAttributeValue("friendlyname") ?? s.GetAttributeValue("uniquename") ?? "Unknown" + ) + ); + + // Build ObjectId -> List mapping BEFORE creating hashsets + // This preserves the many-to-many relationship between components and solutions + var componentSolutionMap = new Dictionary>(); + foreach (var component in solutionComponents) { - ColumnSet = new ColumnSet("publisherid", "friendlyname", "customizationprefix"), - Criteria = new FilterExpression(LogicalOperator.And) + if (!componentSolutionMap.ContainsKey(component.ObjectId)) { - Conditions = - { - new ConditionExpression("publisherid", ConditionOperator.In, publisherIds) - } + componentSolutionMap[component.ObjectId] = new List(); } - }; - - var publishers = await client.RetrieveMultipleAsync(publisherQuery); - var publisherLookup = publishers.Entities.ToDictionary( - p => p.GetAttributeValue("publisherid"), - p => ( - Name: p.GetAttributeValue("friendlyname") ?? "Unknown Publisher", - Prefix: p.GetAttributeValue("customizationprefix") ?? string.Empty - )); - - // Group components by solution - var componentsBySolution = solutionComponents.GroupBy(c => c.SolutionId).ToDictionary(g => g.Key.Id, g => g); - - // Process ALL solutions from configuration, not just those with components - foreach (var solutionEntity in solutionEntities) - { - var solutionId = solutionEntity.GetAttributeValue("solutionid"); - - var solutionName = solutionEntity.GetAttributeValue("friendlyname") ?? - solutionEntity.GetAttributeValue("uniquename") ?? - "Unknown Solution"; - var publisherId = solutionEntity.GetAttributeValue("publisherid").Id; - var publisher = publisherLookup.GetValueOrDefault(publisherId); - - var components = new List(); - - // Add components if this solution has any - if (componentsBySolution.TryGetValue(solutionId, out var solutionGroup)) + if (solutionLookup.TryGetValue(component.SolutionId, out var solutionInfo)) { - foreach (var component in solutionGroup) + // Only add if not already present (avoid duplicates) + if (!componentSolutionMap[component.ObjectId].Any(s => s.Id == solutionInfo.Id)) { - var solutionComponent = CreateSolutionComponent(component, entityLookup, allEntityMetadata, publisherLookup); - if (solutionComponent != null) - { - components.Add(solutionComponent); - } + componentSolutionMap[component.ObjectId].Add(solutionInfo); } } - - // Add solution even if components list is empty (e.g., flow-only solutions) - solutions.Add(new Solution( - solutionName, - publisher.Name, - publisher.Prefix, - components)); } + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Built solution mapping for {componentSolutionMap.Count} unique components"); - return solutions.AsEnumerable(); - } + var inclusionMap = solutionComponents.ToDictionary(s => s.ObjectId, s => s.IsExplicit); - private SolutionComponent? CreateSolutionComponent( - (Guid ObjectId, int ComponentType, int RootComponentBehavior, EntityReference SolutionId) component, - Dictionary entityLookup, - List allEntityMetadata, - Dictionary publisherLookup) - { + /// ENTITIES + var set = solutionComponents.Select(c => c.ObjectId).ToHashSet(); + IEnumerable entitiesInSolutionMetadata; + IEnumerable entitiesInSolution; try { - switch (component.ComponentType) - { - case 1: // Entity - // Try to find entity by MetadataId first, then by searching all entities - if (entityLookup.TryGetValue(component.ObjectId, out var entityMetadata)) - { - var (publisherName, publisherPrefix) = GetPublisherFromSchemaName(entityMetadata.SchemaName, publisherLookup); - return new SolutionComponent( - entityMetadata.DisplayName?.UserLocalizedLabel?.Label ?? entityMetadata.SchemaName, - entityMetadata.SchemaName, - entityMetadata.Description?.UserLocalizedLabel?.Label ?? string.Empty, - SolutionComponentType.Entity, - publisherName, - publisherPrefix); - } - - // Entity lookup by ObjectId is complex in Dataverse, so we'll skip the fallback for now - // The primary lookup by MetadataId should handle most cases - break; - - case 2: // Attribute - // Search for attribute across all entities - foreach (var entity in allEntityMetadata) - { - var attribute = entity.Attributes?.FirstOrDefault(a => a.MetadataId == component.ObjectId); - if (attribute != null) - { - var (publisherName, publisherPrefix) = GetPublisherFromSchemaName(attribute.SchemaName, publisherLookup); - return new SolutionComponent( - attribute.DisplayName?.UserLocalizedLabel?.Label ?? attribute.SchemaName, - attribute.SchemaName, - attribute.Description?.UserLocalizedLabel?.Label ?? string.Empty, - SolutionComponentType.Attribute, - publisherName, - publisherPrefix); - } - } - break; - - case 3: // Relationship (if you want to add this to the enum later) - // Search for relationships across all entities - foreach (var entity in allEntityMetadata) - { - // Check one-to-many relationships - var oneToMany = entity.OneToManyRelationships?.FirstOrDefault(r => r.MetadataId == component.ObjectId); - if (oneToMany != null) - { - var (publisherName, publisherPrefix) = GetPublisherFromSchemaName(oneToMany.SchemaName, publisherLookup); - return new SolutionComponent( - oneToMany.SchemaName, - oneToMany.SchemaName, - $"One-to-Many: {entity.SchemaName} -> {oneToMany.ReferencingEntity}", - SolutionComponentType.Relationship, - publisherName, - publisherPrefix); - } - - // Check many-to-one relationships - var manyToOne = entity.ManyToOneRelationships?.FirstOrDefault(r => r.MetadataId == component.ObjectId); - if (manyToOne != null) - { - var (publisherName, publisherPrefix) = GetPublisherFromSchemaName(manyToOne.SchemaName, publisherLookup); - return new SolutionComponent( - manyToOne.SchemaName, - manyToOne.SchemaName, - $"Many-to-One: {entity.SchemaName} -> {manyToOne.ReferencedEntity}", - SolutionComponentType.Relationship, - publisherName, - publisherPrefix); - } - - // Check many-to-many relationships - var manyToMany = entity.ManyToManyRelationships?.FirstOrDefault(r => r.MetadataId == component.ObjectId); - if (manyToMany != null) - { - var (publisherName, publisherPrefix) = GetPublisherFromSchemaName(manyToMany.SchemaName, publisherLookup); - return new SolutionComponent( - manyToMany.SchemaName, - manyToMany.SchemaName, - $"Many-to-Many: {manyToMany.Entity1LogicalName} <-> {manyToMany.Entity2LogicalName}", - SolutionComponentType.Relationship, - publisherName, - publisherPrefix); - } - } - break; - - case 20: // Security Role - skip for now as not in enum - case 92: // SDK Message Processing Step (Plugin) - skip for now as not in enum - break; - } + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Calling entityMetadataService.GetEntityMetadataByObjectIds()"); + entitiesInSolution = solutionComponents.Where(c => c.ComponentType is 1).DistinctBy(comp => comp.ObjectId); + entitiesInSolutionMetadata = (await entityMetadataService.GetEntityMetadataByObjectIds(entitiesInSolution.Select(e => e.ObjectId))) + .Where(ent => ent.IsIntersect is false); // IsIntersect is true for standard hidden M-M entities + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Retrieved {entitiesInSolutionMetadata.Count()} entity metadata"); } catch (Exception ex) { - logger.LogWarning($"Failed to create solution component for ObjectId {component.ObjectId}, ComponentType {component.ComponentType}: {ex.Message}"); + logger.LogError(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to get entity metadata"); + throw; } - - return null; - } - - private static (string PublisherName, string PublisherPrefix) GetPublisherFromSchemaName( - string schemaName, - Dictionary publisherLookup) - { - // Extract prefix from schema name (e.g., "contoso_entity" -> "contoso") - var parts = schemaName.Split('_', 2); - - if (parts.Length == 2) + var entityLogicalNamesInSolution = entitiesInSolutionMetadata.Select(e => e.LogicalName).ToHashSet(); + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {entityLogicalNamesInSolution.Count} unique entities"); + var entityIconMap = await entityIconService.GetEntityIconMap(entitiesInSolutionMetadata); + var relatedEntityLogicalNames = new HashSet(); + foreach (var entity in entitiesInSolutionMetadata) { - var prefix = parts[0]; - - // Find publisher by matching prefix - foreach (var publisher in publisherLookup.Values) - { - if (publisher.Prefix.Equals(prefix, StringComparison.OrdinalIgnoreCase)) - { - return (publisher.Name, publisher.Prefix); - } - } + var entityLogicalNamesOutsideSolution = entity.Attributes + .OfType() + .SelectMany(attr => attr.Targets) + .Distinct() + .Where(target => !entityLogicalNamesInSolution.Contains(target)); + foreach (var target in entityLogicalNamesOutsideSolution) relatedEntityLogicalNames.Add(target); } + logger.LogInformation("There are {Count} entities referenced outside the solution.", relatedEntityLogicalNames.Count); + var referencedEntityMetadata = await entityMetadataService.GetEntityMetadataByLogicalNames(relatedEntityLogicalNames.ToList()); + var allEntityMetadata = entitiesInSolutionMetadata.Concat(referencedEntityMetadata).ToList(); + var logicalToSchema = allEntityMetadata.ToDictionary(x => x.LogicalName, x => new ExtendedEntityInformation { Name = x.SchemaName, IsInSolution = entitiesInSolutionMetadata.Any(e => e.LogicalName == x.LogicalName) }); - // Default to Microsoft if no prefix or prefix not found - return ("Microsoft", ""); - } - - private static Record MakeRecord( - ILogger logger, - EntityMetadata entity, - List relevantAttributes, - List relevantManyToMany, - Dictionary logicalToSchema, - Dictionary> attributeLogicalToSchema, - List securityRoles, - List keys, - Dictionary entityIconMap, - Dictionary>> attributeUsages, - IConfiguration configuration) - { - var attributes = - relevantAttributes - .Select(metadata => - { - var attr = GetAttribute(metadata, entity, logicalToSchema, attributeUsages, logger); - attr.IsStandardFieldModified = MetadataExtensions.StandardFieldHasChanged(metadata, entity.DisplayName.UserLocalizedLabel?.Label ?? string.Empty, entity.IsCustomEntity ?? false); - return attr; - }) - .Where(x => !string.IsNullOrEmpty(x.DisplayName)) - .ToList(); - - var oneToMany = (entity.OneToManyRelationships ?? Enumerable.Empty()) - .Where(x => logicalToSchema.ContainsKey(x.ReferencingEntity) && logicalToSchema[x.ReferencingEntity].IsInSolution && attributeLogicalToSchema[x.ReferencingEntity].ContainsKey(x.ReferencingAttribute)) - .Select(x => new DTO.Relationship( - x.IsCustomRelationship ?? false, - x.ReferencingEntityNavigationPropertyName, - logicalToSchema[x.ReferencingEntity].Name, - attributeLogicalToSchema[x.ReferencingEntity][x.ReferencingAttribute], - x.SchemaName, - IsManyToMany: false, - x.CascadeConfiguration)) - .ToList(); + /// PUBLISHERS + var publisherMap = await solutionService.GetPublisherMapAsync(solutionEntities); - var manyToMany = relevantManyToMany - .Where(x => logicalToSchema.ContainsKey(x.Entity1LogicalName) && logicalToSchema[x.Entity1LogicalName].IsInSolution) - .Select(x => - { - var useEntity1 = x.Entity1LogicalName == entity.LogicalName; - - var label = !useEntity1 - ? x.Entity1AssociatedMenuConfiguration.Label.UserLocalizedLabel?.Label ?? x.Entity1NavigationPropertyName - : x.Entity2AssociatedMenuConfiguration.Label.UserLocalizedLabel?.Label ?? x.Entity2NavigationPropertyName; - - return new DTO.Relationship( - x.IsCustomRelationship ?? false, - label ?? x.SchemaName, // Fallback to schema name if no localized label is available, this is relevant for some Default/System Many to Many relationships. - logicalToSchema[!useEntity1 ? x.Entity1LogicalName : x.Entity2LogicalName].Name, - "-", - x.SchemaName, - IsManyToMany: true, - null - ); - }) - .ToList(); + /// SECURITY ROLES + var rolesInSolution = solutionComponents.Where(x => x.ComponentType == 20).Select(x => x.ObjectId).Distinct().ToList(); + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {rolesInSolution.Count} roles"); + var logicalNameToSecurityRoles = await securityRoleService.GetSecurityRoles(rolesInSolution, entitiesInSolutionMetadata.ToDictionary(x => x.LogicalName, x => x.Privileges)); - Dictionary tablegroups = []; // logicalname -> group - var tablegroupstring = configuration["TableGroups"]; - if (tablegroupstring?.Length > 0) + /// ATTRIBUTES + var attributesInSolution = solutionComponents.Where(x => x.ComponentType == 2).Select(x => x.ObjectId).ToHashSet(); + var rootBehaviourEntities = entitiesInSolution.Where(ent => ent.RootComponentBehaviour is 0).Select(e => e.ObjectId).ToHashSet(); + var attributesAllExplicitlyAdded = entitiesInSolutionMetadata.Where(e => rootBehaviourEntities.Contains(e.MetadataId!.Value)) + .SelectMany(e => e.Attributes + .Where(a => a.DisplayName.UserLocalizedLabel is not null)) // Sometimes Yomi columns and other hidden attributes are added. These wont have any localized labels. + .Select(a => a.MetadataId!.Value); + foreach (var attr in attributesAllExplicitlyAdded) { - var groupEntries = tablegroupstring.Split(';', StringSplitOptions.RemoveEmptyEntries); - foreach (var g in groupEntries) - { - var tables = g.Split(':'); - if (tables.Length != 2) - { - logger.LogError($"Invalid format for tablegroup entry: ({g})"); - continue; - } - - var logicalNames = tables[1].Split(',', StringSplitOptions.RemoveEmptyEntries); - foreach (var logicalName in logicalNames) - if (!tablegroups.TryAdd(logicalName.Trim().ToLower(), tables[0].Trim())) - { - logger.LogWarning($"Dublicate logicalname detected: {logicalName} (already in tablegroup '{tablegroups[logicalName]}', dublicate found in group '{g}')"); - continue; - } - } + attributesInSolution.Add(attr); + inclusionMap.TryAdd(attr, true); } - var (group, description) = GetGroupAndDescription(entity, tablegroups); - - entityIconMap.TryGetValue(entity.LogicalName, out string? iconBase64); - - return new Record( - entity.DisplayName.UserLocalizedLabel?.Label ?? string.Empty, - entity.SchemaName, - group, - description?.PrettyDescription(), - entity.IsAuditEnabled.Value, - entity.IsActivity ?? false, - entity.IsCustomEntity ?? false, - entity.OwnershipType ?? OwnershipTypes.UserOwned, - entity.HasNotes ?? false, - attributes, - oneToMany.Concat(manyToMany).ToList(), - securityRoles, - keys, - iconBase64); - } - private static Attribute GetAttribute(AttributeMetadata metadata, EntityMetadata entity, Dictionary logicalToSchema, Dictionary>> attributeUsages, ILogger logger) - { - Attribute attr = metadata switch - { - PicklistAttributeMetadata picklist => new ChoiceAttribute(picklist), - MultiSelectPicklistAttributeMetadata multiSelect => new ChoiceAttribute(multiSelect), - LookupAttributeMetadata lookup => new LookupAttribute(lookup, logicalToSchema, logger), - StateAttributeMetadata state => new ChoiceAttribute(state), - StatusAttributeMetadata status => new StatusAttribute(status, (StateAttributeMetadata)entity.Attributes.First(x => x is StateAttributeMetadata)), - StringAttributeMetadata stringMetadata => new StringAttribute(stringMetadata), - IntegerAttributeMetadata integer => new IntegerAttribute(integer), - DateTimeAttributeMetadata dateTimeAttributeMetadata => new DateTimeAttribute(dateTimeAttributeMetadata), - MoneyAttributeMetadata money => new DecimalAttribute(money), - DecimalAttributeMetadata decimalAttribute => new DecimalAttribute(decimalAttribute), - MemoAttributeMetadata memo => new StringAttribute(memo), - BooleanAttributeMetadata booleanAttribute => new BooleanAttribute(booleanAttribute), - FileAttributeMetadata fileAttribute => new FileAttribute(fileAttribute), - _ => new GenericAttribute(metadata) - }; - - var schemaname = attributeUsages.GetValueOrDefault(entity.LogicalName)?.GetValueOrDefault(metadata.LogicalName) ?? []; - // also check the plural name, as some workflows like Power Automate use collectionname - var pluralname = attributeUsages.GetValueOrDefault(entity.LogicalCollectionName)?.GetValueOrDefault(metadata.LogicalName) ?? []; - - attr.AttributeUsages = [.. schemaname, .. pluralname]; - return attr; - } - - private static (string? Group, string? Description) GetGroupAndDescription(EntityMetadata entity, IDictionary tableGroups) - { - var description = entity.Description.UserLocalizedLabel?.Label ?? string.Empty; - if (!description.StartsWith("#")) - { - if (tableGroups.TryGetValue(entity.LogicalName, out var tablegroup)) - return (tablegroup, description); - return (null, description); - } + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {attributesInSolution.Count} attributes"); + var attributeLogicalToSchema = allEntityMetadata.ToDictionary(x => x.LogicalName, x => x.Attributes?.ToDictionary(attr => attr.LogicalName, attr => attr.DisplayName.ToLabelString() ?? attr.SchemaName) ?? []); - var newlineIndex = description.IndexOf("\n"); - if (newlineIndex != -1) + /// ENTITY RELATIONSHIPS + var relationshipsInSolution = solutionComponents.Where(x => x.ComponentType == 10).Select(x => x.ObjectId).ToHashSet(); + var relationshipsAllExplicitlyAdded = entitiesInSolutionMetadata.Where(e => rootBehaviourEntities.Contains(e.MetadataId!.Value)).SelectMany(e => + e.ManyToManyRelationships.Select(a => a.MetadataId!.Value) + .Concat(e.OneToManyRelationships.Select(a => a.MetadataId!.Value)) + .Concat(e.ManyToOneRelationships.Select(a => a.MetadataId!.Value))); + foreach (var rel in relationshipsAllExplicitlyAdded) { - var group = description.Substring(1, newlineIndex - 1).Trim(); - description = description.Substring(newlineIndex + 1); - return (group, description); + relationshipsInSolution.Add(rel); + inclusionMap.TryAdd(rel, true); } - var withoutHashtag = description.Substring(1).Trim(); - var firstSpace = withoutHashtag.IndexOf(" "); - if (firstSpace != -1) - return (withoutHashtag.Substring(0, firstSpace), withoutHashtag.Substring(firstSpace + 1)); - - return (withoutHashtag, null); - } - - public async Task> GetEntityMetadata(List entityObjectIds) - { - ConcurrentBag metadata = new(); - - // Disable affinity cookie - client.EnableAffinityCookie = false; + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {relationshipsInSolution.Count} relations"); - var parallelOptions = new ParallelOptions() - { - MaxDegreeOfParallelism = client.RecommendedDegreesOfParallelism - }; - - await Parallel.ForEachAsync( - source: entityObjectIds, - parallelOptions: parallelOptions, - async (objectId, token) => - { - metadata.Add(await client.RetrieveEntityAsync(objectId, token)); - }); - - return metadata; - } - public async Task> GetEntityMetadataByLogicalName(List entityLogicalNames) - { - ConcurrentBag metadata = new(); - - // Disable affinity cookie - client.EnableAffinityCookie = false; - - var parallelOptions = new ParallelOptions() - { - MaxDegreeOfParallelism = client.RecommendedDegreesOfParallelism - }; - - await Parallel.ForEachAsync( - source: entityLogicalNames, - parallelOptions: parallelOptions, - async (logicalName, token) => - { - metadata.Add(await client.RetrieveEntityByLogicalNameAsync(logicalName, token)); - }); + /// KEYS + var logicalNameToKeys = entitiesInSolutionMetadata.ToDictionary( + entity => entity.LogicalName, + entity => entity.Keys.Select(key => new Key( + key.DisplayName.ToLabelString(), + key.LogicalName, + key.KeyAttributes) + ).ToList()); - return metadata; - } + /// PROCESS ANALYSERS + var attributeUsages = new Dictionary>>(); + foreach (var registration in analyzerRegistrations) + await registration.RunAnalysisAsync(solutionIds, attributeUsages, warnings, logger, entitiesInSolutionMetadata.ToList()); - private async Task<(List SolutionIds, List SolutionEntities)> GetSolutionIds() - { - var solutionNameArg = configuration["DataverseSolutionNames"]; - if (solutionNameArg == null) + /// WORKFLOW DEPENDENCIES + Dictionary> workflowDependencies; + try { - throw new Exception("Specify one or more solutions"); + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Getting workflow dependencies for attributes"); + + // Get workflow dependencies for attributes (returns attribute ObjectId -> list of workflow ObjectIds) + var explicitComponentsList = solutionComponents.ToList(); + var workflowDependencyMap = solutionComponentService.GetWorkflowDependenciesForAttributes( + explicitComponentsList.Where(c => c.ComponentType == 2).Select(c => new SolutionComponentInfo( + c.ObjectId, + c.SolutionComponentId ?? Guid.Empty, + c.ComponentType, + c.RootComponentBehaviour, + new EntityReference("solution", c.SolutionId) + )) + ); + + // Get workflow details for all unique workflow IDs + var allWorkflowIds = workflowDependencyMap.Values.SelectMany(ids => ids).Distinct().ToList(); + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {allWorkflowIds.Count} unique workflow dependencies"); + + var workflowInfoMap = await workflowService.GetWorkflows(allWorkflowIds); + + // Convert to attribute ObjectId -> list of WorkflowInfo + workflowDependencies = workflowDependencyMap.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.Select(wid => workflowInfoMap.GetValueOrDefault(wid)).Where(w => w != null).Select(w => w!).ToList() + ); + + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Mapped workflow information for {workflowDependencies.Count} attributes"); } - var solutionNames = solutionNameArg.Split(",").Select(x => x.Trim().ToLower()).ToList(); - - var resp = await client.RetrieveMultipleAsync(new QueryExpression("solution") - { - ColumnSet = new ColumnSet("publisherid", "friendlyname", "uniquename", "solutionid"), - Criteria = new FilterExpression(LogicalOperator.And) - { - Conditions = - { - new ConditionExpression("uniquename", ConditionOperator.In, solutionNames) - } - } - }); - - return (resp.Entities.Select(e => e.GetAttributeValue("solutionid")).ToList(), resp.Entities.ToList()); - } - - public async Task> GetSolutionComponents(List solutionIds) - { - var entityQuery = new QueryExpression("solutioncomponent") - { - ColumnSet = new ColumnSet("objectid", "componenttype", "rootcomponentbehavior", "solutionid"), - Criteria = new FilterExpression(LogicalOperator.And) - { - Conditions = - { - new ConditionExpression("componenttype", ConditionOperator.In, new List() { 1, 2, 20, 29, 92 }), // entity, attribute, role, workflow/flow, sdkpluginstep (https://learn.microsoft.com/en-us/power-apps/developer/data-platform/reference/entities/solutioncomponent) - new ConditionExpression("solutionid", ConditionOperator.In, solutionIds) - } - } - }; - - return - (await client.RetrieveMultipleAsync(entityQuery)) - .Entities - .Select(e => (e.GetAttributeValue("objectid"), e.GetAttributeValue("componenttype").Value, e.Contains("rootcomponentbehavior") ? e.GetAttributeValue("rootcomponentbehavior").Value : -1, e.GetAttributeValue("solutionid"))) - .ToList(); - } - - private async Task>> GetSecurityRoles(List rolesInSolution, Dictionary priviledges) - { - if (rolesInSolution.Count == 0) return []; - - var query = new QueryExpression("role") - { - ColumnSet = new ColumnSet("name"), - Criteria = new FilterExpression(LogicalOperator.And) - { - Conditions = - { - new ConditionExpression("roleid", ConditionOperator.In, rolesInSolution) - } - }, - LinkEntities = - { - new LinkEntity("role", "roleprivileges", "roleid", "roleid", JoinOperator.Inner) - { - EntityAlias = "rolepriv", - Columns = new ColumnSet("privilegedepthmask"), - LinkEntities = - { - new LinkEntity("roleprivileges", "privilege", "privilegeid", "privilegeid", JoinOperator.Inner) - { - EntityAlias = "priv", - Columns = new ColumnSet("accessright"), - LinkEntities = - { - new LinkEntity("privilege", "privilegeobjecttypecodes", "privilegeid", "privilegeid", JoinOperator.Inner) - { - EntityAlias = "privotc", - Columns = new ColumnSet("objecttypecode") - } - } - } - } - } - } - }; - - var roles = await client.RetrieveMultipleAsync(query); - - var privileges = roles.Entities.Select(e => - { - var name = e.GetAttributeValue("name"); - var depth = (PrivilegeDepth)e.GetAttributeValue("rolepriv.privilegedepthmask").Value; - var accessRight = (AccessRights)e.GetAttributeValue("priv.accessright").Value; - var objectTypeCode = e.GetAttributeValue("privotc.objecttypecode").Value as string; - - return new - { - name, - depth, - accessRight, - objectTypeCode = objectTypeCode ?? string.Empty - }; - }); - - static PrivilegeDepth? GetDepth(Dictionary dict, AccessRights right, SecurityPrivilegeMetadata? meta) + catch (Exception ex) { - if (!dict.TryGetValue(right, out var value)) - return meta?.CanBeGlobal ?? false ? 0 : null; - return value; + logger.LogWarning(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to get workflow dependencies, continuing without them"); + workflowDependencies = new Dictionary>(); } - return privileges - .GroupBy(x => x.objectTypeCode) - .ToDictionary(byLogicalName => byLogicalName.Key, byLogicalName => - byLogicalName - .GroupBy(x => x.name) - .Select(byRole => - { - var accessRights = byRole - .GroupBy(x => x.accessRight) - .ToDictionary(x => x.Key, x => x.First().depth); - - var priviledgeMetadata = priviledges.GetValueOrDefault(byLogicalName.Key) ?? []; - - return new SecurityRole( - byRole.Key, - byLogicalName.Key, - GetDepth(accessRights, AccessRights.CreateAccess, priviledgeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.Create)), - GetDepth(accessRights, AccessRights.ReadAccess, priviledgeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.Read)), - GetDepth(accessRights, AccessRights.WriteAccess, priviledgeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.Write)), - GetDepth(accessRights, AccessRights.DeleteAccess, priviledgeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.Delete)), - GetDepth(accessRights, AccessRights.AppendAccess, priviledgeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.Append)), - GetDepth(accessRights, AccessRights.AppendToAccess, priviledgeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.AppendTo)), - GetDepth(accessRights, AccessRights.AssignAccess, priviledgeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.Assign)), - GetDepth(accessRights, AccessRights.ShareAccess, priviledgeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.Share)) - ); - }) - .ToList()); - } - - private async Task> GetEntityIconMap(IEnumerable entities) - { - var logicalNameToIconName = - entities - .Where(x => x.IconVectorName != null) - .ToDictionary(x => x.LogicalName, x => x.IconVectorName); - - var query = new QueryExpression("webresource") - { - ColumnSet = new ColumnSet("content", "name"), - Criteria = new FilterExpression(LogicalOperator.And) - { - Conditions = - { - new ConditionExpression("name", ConditionOperator.In, logicalNameToIconName.Values.ToList()) - } - } - }; - - var webresources = await client.RetrieveMultipleAsync(query); - var iconNameToSvg = webresources.Entities.ToDictionary(x => x.GetAttributeValue("name"), x => x.GetAttributeValue("content")); - - var logicalNameToSvg = - logicalNameToIconName - .Where(x => iconNameToSvg.ContainsKey(x.Value) && !string.IsNullOrEmpty(iconNameToSvg[x.Value])) - .ToDictionary(x => x.Key, x => iconNameToSvg.GetValueOrDefault(x.Value) ?? string.Empty); - - var sourceDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - var iconDirectory = Path.Combine(sourceDirectory ?? string.Empty, "../../../entityicons"); - - var iconFiles = Directory.GetFiles(iconDirectory).ToDictionary(x => Path.GetFileNameWithoutExtension(x), x => x); - - foreach (var entity in entities) - { - if (logicalNameToSvg.ContainsKey(entity.LogicalName)) - { - continue; - } - - var iconKey = $"svg_{entity.ObjectTypeCode}"; - if (iconFiles.ContainsKey(iconKey)) + var records = + entitiesInSolutionMetadata + .Select(entMeta => { - logicalNameToSvg[entity.LogicalName] = Convert.ToBase64String(File.ReadAllBytes(iconFiles[iconKey])); - } - } - - return logicalNameToSvg; - } - - private async Task TokenProviderFunction(string dataverseUrl, IMemoryCache cache, ILogger logger) - { - var cacheKey = $"AccessToken_{dataverseUrl}"; - - logger.LogTrace($"Attempting to retrieve access token for {dataverseUrl}"); - - return (await cache.GetOrCreateAsync(cacheKey, async cacheEntry => - { - cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(50); - var credential = GetTokenCredential(logger); - var scope = BuildScopeString(dataverseUrl); - - return await FetchAccessToken(credential, scope, logger); - })).Token; - } - - private TokenCredential GetTokenCredential(ILogger logger) - { - - if (configuration["DataverseClientId"] != null && configuration["DataverseClientSecret"] != null) - return new ClientSecretCredential(configuration["TenantId"], configuration["DataverseClientId"], configuration["DataverseClientSecret"]); - - return new DefaultAzureCredential(); // in azure this will be managed identity, locally this depends... se midway of this post for the how local identity is chosen: https://dreamingincrm.com/2021/11/16/connecting-to-dataverse-from-function-app-using-managed-identity/ - } - - private static string BuildScopeString(string dataverseUrl) - { - return $"{GetCoreUrl(dataverseUrl)}/.default"; - } + var relevantAttributes = entMeta.Attributes.Where(attr => attributesInSolution.Contains(attr.MetadataId!.Value)).ToList(); + var relevantManyToManyRelations = relationshipService.ConvertManyToManyRelationships(entMeta.ManyToManyRelationships.Where(rel => relationshipsInSolution.Contains(rel.MetadataId!.Value)), entMeta.LogicalName, inclusionMap, publisherMap, componentSolutionMap, entMeta.MetadataId!.Value); + var relevantOneToManyRelations = relationshipService.ConvertOneToManyRelationships(entMeta.OneToManyRelationships.Where(rel => relationshipsInSolution.Contains(rel.MetadataId!.Value)), true, logicalToSchema, attributeLogicalToSchema, inclusionMap, publisherMap, componentSolutionMap, entMeta.MetadataId!.Value); + var relevantManyToOneRelations = relationshipService.ConvertOneToManyRelationships(entMeta.ManyToOneRelationships.Where(rel => relationshipsInSolution.Contains(rel.MetadataId!.Value)), false, logicalToSchema, attributeLogicalToSchema, inclusionMap, publisherMap, componentSolutionMap, entMeta.MetadataId!.Value); + var relevantRelationships = relevantManyToManyRelations.Concat(relevantManyToOneRelations).Concat(relevantOneToManyRelations).ToList(); + + logicalNameToSecurityRoles.TryGetValue(entMeta.LogicalName, out var securityRoles); + logicalNameToKeys.TryGetValue(entMeta.LogicalName, out var keys); + + return recordMappingService.CreateRecord( + entMeta, + relevantAttributes, + relevantRelationships, + logicalToSchema, + securityRoles ?? [], + keys ?? [], + entityIconMap, + attributeUsages, + inclusionMap, + workflowDependencies, + publisherMap, + componentSolutionMap); + }) + .ToList(); - private static string GetCoreUrl(string url) - { - var uri = new Uri(url); - return $"{uri.Scheme}://{uri.Host}"; - } - - private static async Task FetchAccessToken(TokenCredential credential, string scope, ILogger logger) - { - var tokenRequestContext = new TokenRequestContext(new[] { scope }); - - try - { - logger.LogTrace("Requesting access token..."); - var accessToken = await credential.GetTokenAsync(tokenRequestContext, CancellationToken.None); - logger.LogTrace("Access token successfully retrieved."); - return accessToken; - } - catch (Exception ex) - { - logger.LogError($"Failed to retrieve access token: {ex.Message}"); - throw; - } + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] GetFilteredMetadata completed - returning empty results"); + return (records, warnings); } } @@ -873,20 +319,41 @@ public async Task RunAnalysisAsync( ILogger logger, List entityMetadata) { + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Starting {componentTypeName} analysis"); var stopwatch = Stopwatch.StartNew(); - var components = await queryFunc(solutionIds); - var componentList = components.ToList(); + IEnumerable components; + try + { + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Querying {componentTypeName} from Dataverse"); + components = await queryFunc(solutionIds); + } + catch (Exception ex) + { + logger.LogError(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to query {componentTypeName}"); + throw; + } - logger.LogInformation($"There are {componentList.Count} {componentTypeName} in the environment."); + var componentList = components.ToList(); + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] There are {componentList.Count} {componentTypeName} in the environment."); + int processedCount = 0; foreach (var component in componentList) { - await analyzer.AnalyzeComponentAsync(component, attributeUsages, warnings, entityMetadata); + try + { + await analyzer.AnalyzeComponentAsync(component, attributeUsages, warnings, entityMetadata); + processedCount++; + } + catch (Exception ex) + { + logger.LogError(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to analyze {componentTypeName} component (processed {processedCount}/{componentList.Count})"); + // Continue with next component instead of throwing + } } stopwatch.Stop(); - logger.LogInformation($"{componentTypeName} analysis took {stopwatch.ElapsedMilliseconds} ms."); + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {componentTypeName} analysis completed - processed {processedCount}/{componentList.Count} components in {stopwatch.ElapsedMilliseconds} ms"); } } } diff --git a/Generator/Extensions/LabelExtensions.cs b/Generator/Extensions/LabelExtensions.cs new file mode 100644 index 0000000..896e846 --- /dev/null +++ b/Generator/Extensions/LabelExtensions.cs @@ -0,0 +1,8 @@ +using Microsoft.Xrm.Sdk; + +namespace Generator.Extensions; + +public static class LabelExtensions +{ + public static string ToLabelString(this Label label) => label?.UserLocalizedLabel?.Label ?? label?.LocalizedLabels.FirstOrDefault()?.Label ?? "(no name)"; +} diff --git a/Generator/Generator.csproj b/Generator/Generator.csproj index 73b95c5..c467319 100644 --- a/Generator/Generator.csproj +++ b/Generator/Generator.csproj @@ -11,6 +11,7 @@ + diff --git a/Generator/MetadataExtensions.cs b/Generator/MetadataExtensions.cs index 6c081eb..8b387ec 100644 --- a/Generator/MetadataExtensions.cs +++ b/Generator/MetadataExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.Xrm.Sdk.Metadata; +using Generator.Extensions; +using Microsoft.Xrm.Sdk.Metadata; namespace Generator; @@ -27,8 +28,8 @@ public static bool StandardFieldHasChanged(this AttributeMetadata attribute, str var languagecode = attribute.DisplayName.UserLocalizedLabel?.LanguageCode; var fields = GetDefaultFields(entityDisplayName, languagecode); - var hasTextChanged = fields.StandardDescriptionHasChanged(attribute.LogicalName, attribute.Description.UserLocalizedLabel?.Label ?? string.Empty) - || fields.StandardDisplayNameHasChanged(attribute.LogicalName, attribute.DisplayName.UserLocalizedLabel?.Label ?? string.Empty); + var hasTextChanged = fields.StandardDescriptionHasChanged(attribute.LogicalName, attribute.Description.ToLabelString()) + || fields.StandardDisplayNameHasChanged(attribute.LogicalName, attribute.DisplayName.ToLabelString()); // Check if options have been added to statecode or statuscode var hasOptionsChanged = attribute switch @@ -183,7 +184,9 @@ private static bool StatusCodeOptionsHaveChanged(StatusAttributeMetadata status) ( "actualend", "Faktisk slutning", "Aktivitetens faktiske sluttidspunkt." ), ( "actualstart", "Faktisk start", "Aktivitetens faktiske starttidspunkt." ), ( "bcc", "Bcc", "Angiv de modtagere, der er inkluderet i maildistributionen, men som ikke vises til andre modtagere." ), + ( "bcc", "Bcc", "Bcc-modtagere af aktiviteten." ), ( "cc", "Cc", "Angiv de modtagere, der skal have en kopi af mailen." ), + ( "cc", "Cc", "Cc-modtagere af aktiviteten." ), ( "allparties", "Alle parter i aktiviteter", "Alle aktivitetsparter, der er knyttet til denne aktivitet." ), ( "community", "Social kanal", "Viser, hvor kontakt om den sociale aktivitet stammer fra, f.eks. fra Twitter eller Facebook. Dette felt er skrivebeskyttet." ), ( "createdby", "Oprettet af", "Entydigt id for den bruger, der oprettede aktiviteten." ), @@ -249,5 +252,12 @@ private static bool StatusCodeOptionsHaveChanged(StatusAttributeMetadata status) ( "versionnumber", "Versionsnummer", "Versionsnummer" ), ( "versionnumber", "Versionsnummer", "Versionsnummer for aktiviteten." ), ( "organizationid", "Organisations-id", "Entydigt id for organisationen" ), + ( "organizer", "Arrangør", "Den person, der organiserede aktiviteten." ), + ( "from", "Fra", "Person, som aktiviteten stammer fra." ), + ( "to", "Til", "Person, der er modtager af aktiviteten." ), + ( "customers", "Kunder", "Kunde, som aktiviteten er tilknyttet." ), + ( "partners", "Underleverandører", "Underleverandør, som aktiviteten er tilknyttet." ), + ( "requiredattendees", "Nødvendige deltagere", "Liste over nødvendige deltagere i denne aktivitet." ), + ( "optionalattendees", "Valgfrie deltagere", "Liste over valgfrie deltagere i denne aktivitet." ), }; } diff --git a/Generator/Program.cs b/Generator/Program.cs index 11c451b..1672cf7 100644 --- a/Generator/Program.cs +++ b/Generator/Program.cs @@ -1,25 +1,126 @@ -using Generator; +using Azure.Core; +using Azure.Identity; +using Generator; +using Generator.Services; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.PowerPlatform.Dataverse.Client; var configuration = new ConfigurationBuilder() .AddEnvironmentVariables() .AddJsonFile("appsettings.local.json", optional: true) .Build(); -var verbose = configuration.GetValue("Verbosity", LogLevel.Information); +var verbose = configuration.GetValue("Verbosity", LogLevel.Warning); -using var loggerFactory = LoggerFactory.Create(builder => +// Set up dependency injection +var services = new ServiceCollection(); + +// Add logging +services.AddLogging(builder => { builder .SetMinimumLevel(verbose) .AddConsole(); }); -var logger = loggerFactory.CreateLogger(); -var dataverseService = new DataverseService(configuration, logger); -var (entities, warnings, solutions) = await dataverseService.GetFilteredMetadata(); +// Add configuration as a singleton +services.AddSingleton(configuration); + +// Add ServiceClient as a singleton +services.AddSingleton(sp => +{ + var config = sp.GetRequiredService(); + var loggerFactory = sp.GetRequiredService(); + var logger = loggerFactory.CreateLogger("ServiceClient"); + var cache = new MemoryCache(new MemoryCacheOptions()); + + var dataverseUrl = config["DataverseUrl"]; + if (dataverseUrl == null) + { + throw new Exception("DataverseUrl is required"); + } + + return new ServiceClient( + instanceUrl: new Uri(dataverseUrl), + tokenProviderFunction: async url => await GetTokenAsync(url, cache, logger, config)); +}); + +// Register services +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); + +// Build service provider +var serviceProvider = services.BuildServiceProvider(); + +// Resolve and use DataverseService +var dataverseService = serviceProvider.GetRequiredService(); +var (entities, warnings) = await dataverseService.GetFilteredMetadata(); -var websiteBuilder = new WebsiteBuilder(configuration, entities, warnings, solutions); +var websiteBuilder = new WebsiteBuilder(configuration, entities, warnings); websiteBuilder.AddData(); +// Token provider function +static async Task GetTokenAsync(string dataverseUrl, IMemoryCache cache, ILogger logger, IConfiguration configuration) +{ + var cacheKey = $"AccessToken_{dataverseUrl}"; + + logger.LogTrace($"Attempting to retrieve access token for {dataverseUrl}"); + + return (await cache.GetOrCreateAsync(cacheKey, async cacheEntry => + { + cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(50); + var credential = GetTokenCredential(logger, configuration); + var scope = BuildScopeString(dataverseUrl); + + return await FetchAccessToken(credential, scope, logger); + }))!.Token; +} + +static TokenCredential GetTokenCredential(ILogger logger, IConfiguration configuration) +{ + if (configuration["DataverseClientId"] != null && configuration["DataverseClientSecret"] != null) + return new ClientSecretCredential(configuration["TenantId"], configuration["DataverseClientId"], configuration["DataverseClientSecret"]); + + return new DefaultAzureCredential(); // in azure this will be managed identity, locally this depends... se midway of this post for the how local identity is chosen: https://dreamingincrm.com/2021/11/16/connecting-to-dataverse-from-function-app-using-managed-identity/ +} + +static string BuildScopeString(string dataverseUrl) +{ + return $"{GetCoreUrl(dataverseUrl)}/.default"; +} + +static string GetCoreUrl(string url) +{ + var uri = new Uri(url); + return $"{uri.Scheme}://{uri.Host}"; +} + +static async Task FetchAccessToken(TokenCredential credential, string scope, ILogger logger) +{ + var tokenRequestContext = new TokenRequestContext(new[] { scope }); + + try + { + logger.LogTrace("Requesting access token..."); + var accessToken = await credential.GetTokenAsync(tokenRequestContext, CancellationToken.None); + logger.LogTrace("Access token successfully retrieved."); + return accessToken; + } + catch (Exception ex) + { + logger.LogError($"Failed to retrieve access token: {ex.Message}"); + throw; + } +} + diff --git a/Generator/Services/AttributeMappingService.cs b/Generator/Services/AttributeMappingService.cs new file mode 100644 index 0000000..2c6b943 --- /dev/null +++ b/Generator/Services/AttributeMappingService.cs @@ -0,0 +1,105 @@ +using Generator.DTO; +using Generator.DTO.Attributes; +using Generator.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Xrm.Sdk.Metadata; +using Attribute = Generator.DTO.Attributes.Attribute; + +namespace Generator.Services +{ + /// + /// Service responsible for mapping Dataverse AttributeMetadata to DTO Attribute objects + /// + internal class AttributeMappingService + { + private readonly ILogger logger; + private readonly SolutionService solutionService; + + public AttributeMappingService(ILogger logger, SolutionService solutionService) + { + this.logger = logger; + this.solutionService = solutionService; + } + + /// + /// Maps AttributeMetadata to the appropriate DTO Attribute type + /// + public Attribute MapAttribute( + AttributeMetadata metadata, + EntityMetadata entity, + Dictionary logicalToSchema, + Dictionary>> attributeUsages, + Dictionary inclusionMap, + Dictionary> workflowDependencies, + Dictionary publisherMap, + Dictionary> componentSolutionMap) + { + Attribute attr = metadata switch + { + PicklistAttributeMetadata picklist => new ChoiceAttribute(picklist), + MultiSelectPicklistAttributeMetadata multiSelect => new ChoiceAttribute(multiSelect), + LookupAttributeMetadata lookup => new LookupAttribute(lookup, logicalToSchema), + StateAttributeMetadata state => new ChoiceAttribute(state), + StatusAttributeMetadata status => new StatusAttribute(status, (StateAttributeMetadata)entity.Attributes.First(x => x is StateAttributeMetadata)), + StringAttributeMetadata stringMetadata => new StringAttribute(stringMetadata), + IntegerAttributeMetadata integer => new IntegerAttribute(integer), + DateTimeAttributeMetadata dateTimeAttributeMetadata => new DateTimeAttribute(dateTimeAttributeMetadata), + MoneyAttributeMetadata money => new DecimalAttribute(money), + DecimalAttributeMetadata decimalAttribute => new DecimalAttribute(decimalAttribute), + MemoAttributeMetadata memo => new StringAttribute(memo), + BooleanAttributeMetadata booleanAttribute => new BooleanAttribute(booleanAttribute), + FileAttributeMetadata fileAttribute => new FileAttribute(fileAttribute), + _ => new GenericAttribute(metadata) + }; + + // Get analyzer-based usages + var schemaname = attributeUsages.GetValueOrDefault(entity.LogicalName)?.GetValueOrDefault(metadata.LogicalName) ?? []; + // also check the plural name, as some workflows like Power Automate use collectionname + var pluralname = entity.LogicalCollectionName is not null ? attributeUsages.GetValueOrDefault(entity.LogicalCollectionName)?.GetValueOrDefault(metadata.LogicalName) ?? [] : []; + var analyzerUsages = new List([.. schemaname, .. pluralname]); + + // Get workflow dependency usages + var workflowUsages = workflowDependencies + .GetValueOrDefault(metadata.MetadataId!.Value, []) + .Select(w => new AttributeUsage( + Name: w.Name, + Usage: DetermineWorkflowUsageContext(w), + OperationType: OperationType.Other, + ComponentType: w.Category == 2 ? ComponentType.BusinessRule : ComponentType.ClassicWorkflow, + IsFromDependencyAnalysis: true + )) + .ToList(); + + // Combine both sources + var (pName, pPrefix) = solutionService.GetPublisherFromSchemaName(attr.SchemaName, publisherMap); + attr.PublisherName = pName; + attr.PublisherPrefix = pPrefix; + attr.AttributeUsages = [.. analyzerUsages, .. workflowUsages]; + attr.IsExplicit = inclusionMap.GetValueOrDefault(metadata.MetadataId!.Value, false); + attr.IsStandardFieldModified = MetadataExtensions.StandardFieldHasChanged(metadata, entity.DisplayName.ToLabelString(), entity.IsCustomEntity ?? false); + + // Get solution info for this attribute + // If not found directly, inherit from parent entity (for attributes added via RootComponentBehaviour=0) + attr.Solutions = componentSolutionMap.GetValueOrDefault(metadata.MetadataId!.Value) + ?? componentSolutionMap.GetValueOrDefault(entity.MetadataId!.Value, new List()); + + return attr; + } + + /// + /// Determines the usage context string for a workflow + /// + private static string DetermineWorkflowUsageContext(WorkflowInfo workflow) + { + return workflow.Category switch + { + 2 => "Business Rule", + 0 => "Workflow", + 3 => "Action", + 4 => "Business Process Flow", + 5 => "Dialog", + _ => "Workflow" + }; + } + } +} diff --git a/Generator/Services/EntityIconService.cs b/Generator/Services/EntityIconService.cs new file mode 100644 index 0000000..c5c04da --- /dev/null +++ b/Generator/Services/EntityIconService.cs @@ -0,0 +1,72 @@ +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk.Metadata; +using Microsoft.Xrm.Sdk.Query; +using System.Reflection; + +namespace Generator.Services +{ + /// + /// Service responsible for retrieving entity icons from Dataverse and local files + /// + internal class EntityIconService + { + private readonly ServiceClient client; + + public EntityIconService(ServiceClient client) + { + this.client = client; + } + + /// + /// Retrieves entity icons, first from Dataverse webresources, then from local entityicons directory + /// + public async Task> GetEntityIconMap(IEnumerable entities) + { + var logicalNameToIconName = + entities + .Where(x => x.IconVectorName != null) + .ToDictionary(x => x.LogicalName, x => x.IconVectorName); + + var query = new QueryExpression("webresource") + { + ColumnSet = new ColumnSet("content", "name"), + Criteria = new FilterExpression(LogicalOperator.And) + { + Conditions = + { + new ConditionExpression("name", ConditionOperator.In, logicalNameToIconName.Values.ToList()) + } + } + }; + + var webresources = await client.RetrieveMultipleAsync(query); + var iconNameToSvg = webresources.Entities.ToDictionary(x => x.GetAttributeValue("name"), x => x.GetAttributeValue("content")); + + var logicalNameToSvg = + logicalNameToIconName + .Where(x => iconNameToSvg.ContainsKey(x.Value) && !string.IsNullOrEmpty(iconNameToSvg[x.Value])) + .ToDictionary(x => x.Key, x => iconNameToSvg.GetValueOrDefault(x.Value) ?? string.Empty); + + var sourceDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + var iconDirectory = Path.Combine(sourceDirectory ?? string.Empty, "../../../entityicons"); + + var iconFiles = Directory.GetFiles(iconDirectory).ToDictionary(x => Path.GetFileNameWithoutExtension(x), x => x); + + foreach (var entity in entities) + { + if (logicalNameToSvg.ContainsKey(entity.LogicalName)) + { + continue; + } + + var iconKey = $"svg_{entity.ObjectTypeCode}"; + if (iconFiles.ContainsKey(iconKey)) + { + logicalNameToSvg[entity.LogicalName] = Convert.ToBase64String(File.ReadAllBytes(iconFiles[iconKey])); + } + } + + return logicalNameToSvg; + } + } +} diff --git a/Generator/Services/EntityMetadataService.cs b/Generator/Services/EntityMetadataService.cs new file mode 100644 index 0000000..761f27d --- /dev/null +++ b/Generator/Services/EntityMetadataService.cs @@ -0,0 +1,86 @@ +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk.Metadata; +using System.Collections.Concurrent; + +namespace Generator.Services +{ + /// + /// Service responsible for retrieving entity metadata from Dataverse + /// + internal class EntityMetadataService + { + private readonly ServiceClient client; + + public EntityMetadataService(ServiceClient client) + { + this.client = client; + } + + /// + /// Retrieves entity metadata by object IDs + /// + public async Task> GetEntityMetadataByObjectIds(IEnumerable entityObjectIds) + { + ConcurrentBag metadata = new(); + ConcurrentBag failedIds = new(); + + // Disable affinity cookie + client.EnableAffinityCookie = false; + + var parallelOptions = new ParallelOptions() + { + MaxDegreeOfParallelism = client.RecommendedDegreesOfParallelism + }; + + await Parallel.ForEachAsync( + source: entityObjectIds, + parallelOptions: parallelOptions, + async (objectId, token) => + { + try + { + metadata.Add(await client.RetrieveEntityAsync(objectId, token)); + } + catch (Exception ex) + { + // Entity doesn't exist or cannot be retrieved - log and continue + Console.WriteLine($"Warning: Failed to retrieve entity with ID {objectId}: {ex.Message}"); + failedIds.Add(objectId); + } + }); + + if (failedIds.Any()) + { + Console.WriteLine($"Warning: Failed to retrieve {failedIds.Count} entities out of {entityObjectIds.Count()}. IDs: {string.Join(", ", failedIds)}"); + } + + return metadata; + } + + /// + /// Retrieves entity metadata by logical names + /// + public async Task> GetEntityMetadataByLogicalNames(List entityLogicalNames) + { + ConcurrentBag metadata = new(); + + // Disable affinity cookie + client.EnableAffinityCookie = false; + + var parallelOptions = new ParallelOptions() + { + MaxDegreeOfParallelism = client.RecommendedDegreesOfParallelism + }; + + await Parallel.ForEachAsync( + source: entityLogicalNames, + parallelOptions: parallelOptions, + async (logicalName, token) => + { + metadata.Add(await client.RetrieveEntityByLogicalNameAsync(logicalName, token)); + }); + + return metadata; + } + } +} diff --git a/Generator/Services/Plugins/PluginAnalyzer.cs b/Generator/Services/Plugins/PluginAnalyzer.cs index e9cd80d..1f07b99 100644 --- a/Generator/Services/Plugins/PluginAnalyzer.cs +++ b/Generator/Services/Plugins/PluginAnalyzer.cs @@ -24,7 +24,7 @@ public override async Task AnalyzeComponentAsync(SDKStep sdkStep, Dictionary + /// Service responsible for creating Record DTOs from entity metadata + /// Orchestrates attribute mapping, relationship mapping, and grouping logic + /// + internal class RecordMappingService + { + private readonly AttributeMappingService attributeMappingService; + private readonly RelationshipService relationshipService; + private readonly SolutionService solutionService; + + public RecordMappingService( + AttributeMappingService attributeMappingService, + RelationshipService relationshipService, + IConfiguration configuration, + ILogger logger, + ILogger relationshipLogger, + SolutionService solutionService) + { + this.attributeMappingService = attributeMappingService; + this.relationshipService = relationshipService; + this.solutionService = solutionService; + } + + /// + /// Creates a Record DTO from entity metadata + /// + public Record CreateRecord( + EntityMetadata entity, + List relevantAttributes, + List relevantRelationships, + Dictionary logicalToSchema, + List securityRoles, + List keys, + Dictionary entityIconMap, + Dictionary>> attributeUsages, + Dictionary inclusionMap, + Dictionary> workflowDependencies, + Dictionary publisherMap, + Dictionary> componentSolutionMap) + { + var attributes = + relevantAttributes + .Select(metadata => attributeMappingService.MapAttribute(metadata, entity, logicalToSchema, attributeUsages, inclusionMap, workflowDependencies, publisherMap, componentSolutionMap)) + .Where(x => !string.IsNullOrEmpty(x.DisplayName)) + .ToList(); + + var tablegroups = relationshipService.ParseTableGroups(); + var (group, description) = relationshipService.GetGroupAndDescription(entity, tablegroups); + + entityIconMap.TryGetValue(entity.LogicalName, out string? iconBase64); + + var (pName, pPrefix) = solutionService.GetPublisherFromSchemaName(entity.SchemaName, publisherMap); + + // Get solution info for this entity + var entitySolutions = componentSolutionMap.GetValueOrDefault(entity.MetadataId!.Value, new List()); + + return new Record( + entity.DisplayName.ToLabelString(), + entity.SchemaName, + group, + description?.PrettyDescription(), + entity.IsAuditEnabled.Value, + entity.IsActivity ?? false, + entity.IsCustomEntity ?? false, + pName, + pPrefix, + entity.OwnershipType ?? OwnershipTypes.UserOwned, + entity.HasNotes ?? false, + attributes, + relevantRelationships, + securityRoles, + keys, + iconBase64, + entitySolutions); + } + } +} diff --git a/Generator/Services/RelationshipService.cs b/Generator/Services/RelationshipService.cs new file mode 100644 index 0000000..de69bc7 --- /dev/null +++ b/Generator/Services/RelationshipService.cs @@ -0,0 +1,165 @@ +using Generator.DTO; +using Generator.DTO.Attributes; +using Generator.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Xrm.Sdk.Metadata; + +namespace Generator.Services; + +/// +/// Service responsible for mapping entity relationships +/// +internal class RelationshipService +{ + private readonly IConfiguration configuration; + private readonly ILogger logger; + private readonly SolutionService solutionService; + + public RelationshipService(IConfiguration configuration, ILogger logger, SolutionService solutionService) + { + this.configuration = configuration; + this.logger = logger; + this.solutionService = solutionService; + } + + /// + /// Parses table groups from configuration + /// + public Dictionary ParseTableGroups() + { + Dictionary tablegroups = []; // logicalname -> group + var tablegroupstring = configuration["TableGroups"]; + if (tablegroupstring?.Length > 0) + { + var groupEntries = tablegroupstring.Split(';', StringSplitOptions.RemoveEmptyEntries); + foreach (var g in groupEntries) + { + var tables = g.Split(':'); + if (tables.Length != 2) + { + logger.LogError($"Invalid format for tablegroup entry: ({g})"); + continue; + } + + var logicalNames = tables[1].Split(',', StringSplitOptions.RemoveEmptyEntries); + foreach (var logicalName in logicalNames) + if (!tablegroups.TryAdd(logicalName.Trim().ToLower(), tables[0].Trim())) + { + logger.LogWarning($"Dublicate logicalname detected: {logicalName} (already in tablegroup '{tablegroups[logicalName]}', dublicate found in group '{g}')"); + continue; + } + } + } + return tablegroups; + } + + /// + /// Extracts group and description from entity metadata + /// + public (string? Group, string? Description) GetGroupAndDescription(EntityMetadata entity, IDictionary tableGroups) + { + var description = entity.Description.ToLabelString(); + if (!description.StartsWith("#")) + { + if (tableGroups.TryGetValue(entity.LogicalName, out var tablegroup)) + return (tablegroup, description); + return (null, description); + } + + var newlineIndex = description.IndexOf("\n"); + if (newlineIndex != -1) + { + var group = description.Substring(1, newlineIndex - 1).Trim(); + description = description.Substring(newlineIndex + 1); + return (group, description); + } + + var withoutHashtag = description.Substring(1).Trim(); + var firstSpace = withoutHashtag.IndexOf(" "); + if (firstSpace != -1) + return (withoutHashtag.Substring(0, firstSpace), withoutHashtag.Substring(firstSpace + 1)); + + return (withoutHashtag, null); + } + + /// + /// Converts many-to-many relationship metadata to Relationship DTOs + /// + public IEnumerable ConvertManyToManyRelationships( + IEnumerable relationships, + string entityLogicalName, + Dictionary inclusionMap, + Dictionary publisherMap, + Dictionary> componentSolutionMap, + Guid entityMetadataId) + { + return relationships.Select(rel => + { + var (pName, pPrefix) = solutionService.GetPublisherFromSchemaName(rel.SchemaName, publisherMap); + + // Get solution info for this relationship + // If not found directly, inherit from parent entity + var relationshipSolutions = componentSolutionMap.GetValueOrDefault(rel.MetadataId!.Value) + ?? componentSolutionMap.GetValueOrDefault(entityMetadataId, new List()); + + return new Relationship( + rel.IsCustomRelationship ?? false, + $"{rel.Entity1AssociatedMenuConfiguration.Label.ToLabelString()} ⟷ {rel.Entity2AssociatedMenuConfiguration.Label.ToLabelString()}", + entityLogicalName, + "-", + rel.SchemaName, + "N:N", + inclusionMap[rel.MetadataId!.Value], + pName, + pPrefix, + null, + relationshipSolutions); + }); + } + + /// + /// Converts one-to-many relationship metadata to Relationship DTOs + /// + public IEnumerable ConvertOneToManyRelationships( + IEnumerable relationships, + bool isOneToMany, + Dictionary logicalToSchema, + Dictionary> attributeMapping, + Dictionary inclusionMap, + Dictionary publisherMap, + Dictionary> componentSolutionMap, + Guid entityMetadataId) + { + return relationships.Select(rel => + { + var (pName, pPrefix) = solutionService.GetPublisherFromSchemaName(rel.SchemaName, publisherMap); + + // Get solution info for this relationship + // If not found directly, inherit from parent entity + var relationshipSolutions = componentSolutionMap.GetValueOrDefault(rel.MetadataId!.Value) + ?? componentSolutionMap.GetValueOrDefault(entityMetadataId, new List()); + + var lookupName = !isOneToMany + ? (attributeMapping.ContainsKey(rel.ReferencingEntity) && attributeMapping[rel.ReferencingEntity].ContainsKey(rel.ReferencingAttribute) ? attributeMapping[rel.ReferencingEntity][rel.ReferencingAttribute] : "unknown") + : (attributeMapping.ContainsKey(rel.ReferencedEntity) && attributeMapping[rel.ReferencedEntity].ContainsKey(rel.ReferencedAttribute) ? attributeMapping[rel.ReferencedEntity][rel.ReferencedAttribute] : "unknown"); + + var tableSchema = isOneToMany + ? (logicalToSchema.ContainsKey(rel.ReferencingEntity) ? logicalToSchema[rel.ReferencingEntity].Name : rel.ReferencingEntity) + : (logicalToSchema.ContainsKey(rel.ReferencedEntity) ? logicalToSchema[rel.ReferencedEntity].Name : rel.ReferencedEntity); + + return new Relationship( + rel.IsCustomRelationship ?? false, + rel.ReferencingEntityNavigationPropertyName ?? rel.ReferencingEntity, + tableSchema, + lookupName, + rel.SchemaName, + !isOneToMany ? "N:1" : "1:N", + inclusionMap[rel.MetadataId!.Value], + pName, + pPrefix, + rel.CascadeConfiguration, + relationshipSolutions); + }); + } +} diff --git a/Generator/Services/SecurityRoleService.cs b/Generator/Services/SecurityRoleService.cs new file mode 100644 index 0000000..834aadd --- /dev/null +++ b/Generator/Services/SecurityRoleService.cs @@ -0,0 +1,121 @@ +using Generator.DTO; +using Microsoft.Crm.Sdk.Messages; +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Metadata; +using Microsoft.Xrm.Sdk.Query; + +namespace Generator.Services +{ + /// + /// Service responsible for querying and mapping security roles + /// + internal class SecurityRoleService + { + private readonly ServiceClient client; + + public SecurityRoleService(ServiceClient client) + { + this.client = client; + } + + /// + /// Retrieves and maps security roles with their privileges + /// + public async Task>> GetSecurityRoles( + List rolesInSolution, + Dictionary privileges) + { + if (rolesInSolution.Count == 0) return []; + + var query = new QueryExpression("role") + { + ColumnSet = new ColumnSet("name"), + Criteria = new FilterExpression(LogicalOperator.And) + { + Conditions = + { + new ConditionExpression("roleid", ConditionOperator.In, rolesInSolution) + } + }, + LinkEntities = + { + new LinkEntity("role", "roleprivileges", "roleid", "roleid", JoinOperator.Inner) + { + EntityAlias = "rolepriv", + Columns = new ColumnSet("privilegedepthmask"), + LinkEntities = + { + new LinkEntity("roleprivileges", "privilege", "privilegeid", "privilegeid", JoinOperator.Inner) + { + EntityAlias = "priv", + Columns = new ColumnSet("accessright"), + LinkEntities = + { + new LinkEntity("privilege", "privilegeobjecttypecodes", "privilegeid", "privilegeid", JoinOperator.Inner) + { + EntityAlias = "privotc", + Columns = new ColumnSet("objecttypecode") + } + } + } + } + } + } + }; + + var roles = await client.RetrieveMultipleAsync(query); + + var rolePrivileges = roles.Entities.Select(e => + { + var name = e.GetAttributeValue("name"); + var depth = (PrivilegeDepth)e.GetAttributeValue("rolepriv.privilegedepthmask").Value; + var accessRight = (AccessRights)e.GetAttributeValue("priv.accessright").Value; + var objectTypeCode = e.GetAttributeValue("privotc.objecttypecode").Value as string; + + return new + { + name, + depth, + accessRight, + objectTypeCode = objectTypeCode ?? string.Empty + }; + }); + + static PrivilegeDepth? GetDepth(Dictionary dict, AccessRights right, SecurityPrivilegeMetadata? meta) + { + if (!dict.TryGetValue(right, out var value)) + return meta?.CanBeGlobal ?? false ? 0 : null; + return value; + } + + return rolePrivileges + .GroupBy(x => x.objectTypeCode) + .ToDictionary(byLogicalName => byLogicalName.Key, byLogicalName => + byLogicalName + .GroupBy(x => x.name) + .Select(byRole => + { + var accessRights = byRole + .GroupBy(x => x.accessRight) + .ToDictionary(x => x.Key, x => x.First().depth); + + var privilegeMetadata = privileges.GetValueOrDefault(byLogicalName.Key) ?? []; + + return new SecurityRole( + byRole.Key, + byLogicalName.Key, + GetDepth(accessRights, AccessRights.CreateAccess, privilegeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.Create)), + GetDepth(accessRights, AccessRights.ReadAccess, privilegeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.Read)), + GetDepth(accessRights, AccessRights.WriteAccess, privilegeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.Write)), + GetDepth(accessRights, AccessRights.DeleteAccess, privilegeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.Delete)), + GetDepth(accessRights, AccessRights.AppendAccess, privilegeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.Append)), + GetDepth(accessRights, AccessRights.AppendToAccess, privilegeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.AppendTo)), + GetDepth(accessRights, AccessRights.AssignAccess, privilegeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.Assign)), + GetDepth(accessRights, AccessRights.ShareAccess, privilegeMetadata.FirstOrDefault(p => p.PrivilegeType == PrivilegeType.Share)) + ); + }) + .ToList()); + } + } +} diff --git a/Generator/Services/SolutionComponentService.cs b/Generator/Services/SolutionComponentService.cs new file mode 100644 index 0000000..a7e7ed9 --- /dev/null +++ b/Generator/Services/SolutionComponentService.cs @@ -0,0 +1,437 @@ +using Microsoft.Crm.Sdk.Messages; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Messages; +using Microsoft.Xrm.Sdk.Query; + +namespace Generator.Services; + +public class ComponentInfo +{ + public int ComponentType { get; set; } + public Guid ObjectId { get; set; } + public Guid? SolutionComponentId { get; set; } + public bool IsExplicit { get; set; } + public int RootComponentBehaviour { get; set; } + public Guid SolutionId { get; set; } + + public override bool Equals(object? obj) + { + return obj is ComponentInfo info && + ComponentType == info.ComponentType && + ObjectId == info.ObjectId; + } + + public override int GetHashCode() + { + return HashCode.Combine(ComponentType, ObjectId); + } +} + +public record SolutionComponentInfo( + Guid ObjectId, + Guid SolutionComponentId, + int ComponentType, + int RootComponentBehaviour, + EntityReference SolutionId + ); + +public record DependencyInfo( + Guid DependencyId, + int DependencyType, + Guid DependentComponentNodeId, + Guid RequiredComponentNodeId + ); + +public record ComponentNodeInfo( + Guid NodeId, + int ComponentType, + Guid ObjectId, + EntityReference SolutionId + ); + +public class SolutionComponentService +{ + private readonly ServiceClient _client; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public SolutionComponentService(ServiceClient client, IConfiguration configuration, ILogger logger) + { + _client = client; + _configuration = configuration; + _logger = logger; + } + + public IEnumerable GetAllSolutionComponents(List solutionIds) + { + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Getting all solution components for solutions: {string.Join(", ", solutionIds)}"); + var allComponents = new HashSet(); + + // Get explicit components from solution + IEnumerable explicitComponents; + try + { + explicitComponents = GetExplicitComponents(solutionIds); + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {explicitComponents.Count()} explicit components"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to get explicit components"); + throw; + } + + // Add explicit components - determine if explicit or implicit based on RootComponentBehaviour + foreach (var comp in explicitComponents) + { + // RootComponentBehaviour: + // 0 (IncludeSubcomponents) = Explicitly added with all subcomponents + // 1 (DoNotIncludeSubcomponents) = Explicitly added without subcomponents + // 2 (IncludeAsShellOnly) = Only shell/definition included + // If not present or other values, treat as implicit + var isExplicitlyAdded = comp.RootComponentBehaviour == 0 || comp.RootComponentBehaviour == 1 || comp.RootComponentBehaviour == 2; + + allComponents.Add(new ComponentInfo + { + ComponentType = comp.ComponentType, + ObjectId = comp.ObjectId, + SolutionComponentId = comp.SolutionComponentId, + RootComponentBehaviour = comp.RootComponentBehaviour, + IsExplicit = isExplicitlyAdded, + SolutionId = comp.SolutionId.Id + }); + } + + // Get required dependencies for attributes only (component type 2) + try + { + var attributeComponents = explicitComponents.Where(c => c.ComponentType == 2).ToList(); + if (attributeComponents.Any()) + { + var dependencies = GetRequiredComponents(attributeComponents); + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {dependencies.Count} dependency relationships for attributes"); + + // Get unique component node IDs + var requiredNodeIds = dependencies.Select(d => d.RequiredComponentNodeId).Distinct().ToList(); + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Retrieving component information for {requiredNodeIds.Count} dependency nodes"); + + // Retrieve component node information + var componentNodes = GetComponentNodes(requiredNodeIds); + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Retrieved {componentNodes.Count} component nodes"); + + // Add required components as implicit + foreach (var node in componentNodes) + { + if (node.ComponentType is 1) continue; // skip entities (just adds hidden M-M tables) + var componentInfo = new ComponentInfo + { + ComponentType = node.ComponentType, + ObjectId = node.ObjectId, + SolutionComponentId = null, + RootComponentBehaviour = -1, + IsExplicit = false, // Required dependencies are implicitly included + SolutionId = node.SolutionId.Id + }; + + // Only add if not already present as explicit component + if (!allComponents.Contains(componentInfo)) + { + allComponents.Add(componentInfo); + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to get required components, continuing without them"); + } + + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Total components found: {allComponents.Count} (Explicit: {allComponents.Count(c => c.IsExplicit)}, Implicit: {allComponents.Count(c => !c.IsExplicit)})"); + return allComponents; + } + + private IEnumerable GetExplicitComponents(List solutionIds) + { + var query = new QueryExpression("solutioncomponent") + { + ColumnSet = new ColumnSet("objectid", "componenttype", "solutioncomponentid", "rootcomponentbehavior", "solutionid"), + Criteria = new FilterExpression(LogicalOperator.And) + { + Conditions = + { + new ConditionExpression("componenttype", ConditionOperator.In, new List() { 1, 2, 10, 20, 62 }), // 1=entity, 2=attribute, 10=1:N relationship, 20=security role (https://learn.microsoft.com/en-us/power-apps/developer/data-platform/reference/entities/solutioncomponent) + new ConditionExpression("solutionid", ConditionOperator.In, solutionIds) + } + } + }; + + return _client.RetrieveMultiple(query).Entities + .Select(e => new SolutionComponentInfo( + e.GetAttributeValue("objectid"), + e.GetAttributeValue("solutioncomponentid"), + e.GetAttributeValue("componenttype").Value, + e.Contains("rootcomponentbehavior") ? e.GetAttributeValue("rootcomponentbehavior").Value : -1, + e.GetAttributeValue("solutionid"))); + } + + private List GetComponentNodes(List nodeIds) + { + if (!nodeIds.Any()) + { + return new List(); + } + + var results = new List(); + var query = new QueryExpression("dependencynode") + { + ColumnSet = new ColumnSet("dependencynodeid", "componenttype", "objectid", "basesolutionid"), + Criteria = new FilterExpression(LogicalOperator.And) + { + Conditions = + { + new ConditionExpression("dependencynodeid", ConditionOperator.In, nodeIds) + } + } + }; + + try + { + var response = _client.RetrieveMultiple(query); + foreach (var entity in response.Entities) + { + results.Add(new ComponentNodeInfo( + entity.GetAttributeValue("dependencynodeid"), + entity.GetAttributeValue("componenttype").Value, + entity.GetAttributeValue("objectid"), + entity.GetAttributeValue("basesolutionid") + )); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to retrieve component nodes."); + } + + return results; + } + + private HashSet GetDependentComponents(IEnumerable components) + { + var results = new HashSet(); + var componentsList = components.ToList(); + int totalCount = componentsList.Count; + int processedCount = 0; + int errorCount = 0; + const int batchSize = 100; // Dataverse recommends batches of 100-1000 + + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Retrieving dependent components for {totalCount} components in batches of {batchSize}"); + + for (int i = 0; i < totalCount; i += batchSize) + { + var batch = componentsList.Skip(i).Take(batchSize).ToList(); + var batchNum = (i / batchSize) + 1; + var totalBatches = (int)Math.Ceiling((double)totalCount / batchSize); + + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Processing batch {batchNum}/{totalBatches} ({batch.Count} components)"); + + var executeMultiple = new ExecuteMultipleRequest + { + Settings = new ExecuteMultipleSettings + { + ContinueOnError = true, + ReturnResponses = true + }, + Requests = new OrganizationRequestCollection() + }; + + foreach (var component in batch) + { + executeMultiple.Requests.Add(new RetrieveDependentComponentsRequest + { + ComponentType = component.ComponentType, + ObjectId = component.ObjectId + }); + } + + try + { + var response = (ExecuteMultipleResponse)_client.Execute(executeMultiple); + + for (int j = 0; j < response.Responses.Count; j++) + { + var item = response.Responses[j]; + + if (item.Fault != null) + { + errorCount++; + var component = batch[j]; + _logger.LogWarning($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to retrieve dependents for component type {component.ComponentType}, ObjectId {component.ObjectId}: {item.Fault.Message}"); + continue; + } + + var dependentResponse = (RetrieveDependentComponentsResponse)item.Response; + foreach (var dep in dependentResponse.EntityCollection.Entities) + { + results.Add(new DependencyInfo( + dep.GetAttributeValue("dependencyid"), + dep.GetAttributeValue("dependencytype").Value, + dep.GetAttributeValue("dependentcomponentnodeid").Id, + dep.GetAttributeValue("requiredcomponentnodeid").Id)); + } + processedCount++; + } + } + catch (Exception ex) + { + errorCount += batch.Count; + _logger.LogError(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to execute batch {batchNum}. Continuing..."); + } + } + + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Dependent components: Processed {processedCount}/{totalCount} components, {errorCount} errors, {results.Count} dependencies found"); + return results; + } + + private HashSet GetRequiredComponents(IEnumerable components) + { + var results = new HashSet(); + var componentsList = components.ToList(); + int totalCount = componentsList.Count; + int processedCount = 0; + int errorCount = 0; + const int batchSize = 100; // Dataverse recommends batches of 100-1000 + + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Retrieving required components for {totalCount} components in batches of {batchSize}"); + + for (int i = 0; i < totalCount; i += batchSize) + { + var batch = componentsList.Skip(i).Take(batchSize).ToList(); + var batchNum = (i / batchSize) + 1; + var totalBatches = (int)Math.Ceiling((double)totalCount / batchSize); + + var executeMultiple = new ExecuteMultipleRequest + { + Settings = new ExecuteMultipleSettings + { + ContinueOnError = true, + ReturnResponses = true + }, + Requests = new OrganizationRequestCollection() + }; + + foreach (var component in batch) + { + executeMultiple.Requests.Add(new RetrieveRequiredComponentsRequest + { + ComponentType = component.ComponentType, + ObjectId = component.ObjectId + }); + } + + try + { + var response = (ExecuteMultipleResponse)_client.Execute(executeMultiple); + + for (int j = 0; j < response.Responses.Count; j++) + { + var item = response.Responses[j]; + + if (item.Fault != null) + { + errorCount++; + var component = batch[j]; + _logger.LogWarning($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to retrieve required components for component type {component.ComponentType}, ObjectId {component.ObjectId}: {item.Fault.Message}"); + continue; + } + + var requiredResponse = (RetrieveRequiredComponentsResponse)item.Response; + foreach (var dep in requiredResponse.EntityCollection.Entities) + { + results.Add(new DependencyInfo( + dep.GetAttributeValue("dependencyid"), + dep.GetAttributeValue("dependencytype").Value, + dep.GetAttributeValue("dependentcomponentnodeid").Id, + dep.GetAttributeValue("requiredcomponentnodeid").Id)); + } + processedCount++; + } + } + catch (Exception ex) + { + errorCount += batch.Count; + _logger.LogError(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to execute batch {batchNum}. Continuing..."); + } + } + + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Required components: Processed {processedCount}/{totalCount} components, {errorCount} errors, {results.Count} dependencies found"); + return results; + } + + /// + /// Gets workflow dependencies for attributes by finding workflows (type 29) that depend on specified attributes + /// + /// List of attribute components to check for dependencies + /// Dictionary mapping attribute ObjectId to list of workflow ObjectIds that depend on it + public Dictionary> GetWorkflowDependenciesForAttributes(IEnumerable attributeComponents) + { + var workflowDependencies = new Dictionary>(); + + // Filter to only attributes (component type 2) + var attributes = attributeComponents.Where(c => c.ComponentType == 2).ToList(); + + if (!attributes.Any()) + { + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] No attributes to check for workflow dependencies"); + return workflowDependencies; + } + + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Checking {attributes.Count} attributes for workflow dependencies"); + + // Get all dependent components for attributes + var dependencies = GetDependentComponents(attributes); + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {dependencies.Count} total dependencies for attributes"); + + if (!dependencies.Any()) + return workflowDependencies; + + // Get unique component node IDs for both dependents and required components + var allNodeIds = dependencies + .SelectMany(d => new[] { d.DependentComponentNodeId, d.RequiredComponentNodeId }) + .Distinct() + .ToList(); + + if (!allNodeIds.Any()) + return workflowDependencies; + + // Retrieve component node information for all nodes + var allNodes = GetComponentNodes(allNodeIds); + + // Filter to only workflow components (type 29) + var workflowNodes = allNodes.Where(n => n.ComponentType == 29).ToList(); + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {workflowNodes.Count} workflow dependencies"); + + // Build mapping: attribute ObjectId -> list of workflow ObjectIds + foreach (var dependency in dependencies) + { + var workflowNode = workflowNodes.FirstOrDefault(n => n.NodeId == dependency.DependentComponentNodeId); + if (workflowNode != null) + { + // Find the attribute this dependency is for + var requiredNode = allNodes.FirstOrDefault(n => n.NodeId == dependency.RequiredComponentNodeId); + if (requiredNode != null && requiredNode.ComponentType == 2) // Ensure it's an attribute + { + if (!workflowDependencies.ContainsKey(requiredNode.ObjectId)) + { + workflowDependencies[requiredNode.ObjectId] = new List(); + } + workflowDependencies[requiredNode.ObjectId].Add(workflowNode.ObjectId); + } + } + } + + _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Mapped workflow dependencies to {workflowDependencies.Count} attributes"); + return workflowDependencies; + } +} \ No newline at end of file diff --git a/Generator/Services/SolutionService.cs b/Generator/Services/SolutionService.cs new file mode 100644 index 0000000..80b4f62 --- /dev/null +++ b/Generator/Services/SolutionService.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace Generator.Services +{ + /// + /// Service responsible for solution queries and component mapping + /// + internal class SolutionService + { + private readonly ServiceClient client; + private readonly IConfiguration configuration; + private readonly ILogger logger; + + public SolutionService(ServiceClient client, IConfiguration configuration, ILogger logger) + { + this.client = client; + this.configuration = configuration; + this.logger = logger; + } + + /// + /// Retrieves solution IDs based on configuration + /// + public async Task<(List SolutionIds, List SolutionEntities)> GetSolutionIds() + { + var solutionNameArg = configuration["DataverseSolutionNames"]; + if (solutionNameArg == null) + { + throw new Exception("Specify one or more solutions"); + } + var solutionNames = solutionNameArg.Split(",").Select(x => x.Trim().ToLower()).ToList(); + + var resp = await client.RetrieveMultipleAsync(new QueryExpression("solution") + { + ColumnSet = new ColumnSet("publisherid", "friendlyname", "uniquename", "solutionid"), + Criteria = new FilterExpression(LogicalOperator.And) + { + Conditions = + { + new ConditionExpression("uniquename", ConditionOperator.In, solutionNames) + } + } + }); + + return (resp.Entities.Select(e => e.GetAttributeValue("solutionid")).ToList(), resp.Entities.ToList()); + } + + /// + /// Creates Solution DTOs with their components + /// + public async Task> GetPublisherMapAsync( + List solutionEntities) + { + // Fetch all unique publishers for the solutions + var publisherIds = solutionEntities + .Select(s => s.GetAttributeValue("publisherid").Id) + .Distinct() + .ToList(); + + var publisherQuery = new QueryExpression("publisher") + { + ColumnSet = new ColumnSet("publisherid", "friendlyname", "customizationprefix"), + Criteria = new FilterExpression(LogicalOperator.And) + { + Conditions = + { + new ConditionExpression("publisherid", ConditionOperator.In, publisherIds) + } + } + }; + + var publishers = await client.RetrieveMultipleAsync(publisherQuery); + return publishers.Entities.ToDictionary( + p => p.GetAttributeValue("publisherid"), + p => ( + Name: p.GetAttributeValue("friendlyname") ?? "Unknown Publisher", + Prefix: p.GetAttributeValue("customizationprefix") ?? string.Empty + )); + } + + /// + /// Extracts publisher information from schema name + /// + public (string PublisherName, string PublisherPrefix) GetPublisherFromSchemaName( + string schemaName, + Dictionary publisherLookup) + { + // Extract prefix from schema name (e.g., "contoso_entity" -> "contoso") + var parts = schemaName.Split('_', 2); + + if (parts.Length == 2) + { + var prefix = parts[0]; + + // Find publisher by matching prefix + foreach (var publisher in publisherLookup.Values) + { + if (publisher.Prefix.Equals(prefix, StringComparison.OrdinalIgnoreCase)) + { + return (publisher.Name, publisher.Prefix); + } + } + } + + // Default to Microsoft if no prefix or prefix not found + return ("Microsoft", ""); + } + } +} diff --git a/Generator/Services/WebResources/WebResourceAnalyzer.cs b/Generator/Services/WebResources/WebResourceAnalyzer.cs index 2e02115..67ba62f 100644 --- a/Generator/Services/WebResources/WebResourceAnalyzer.cs +++ b/Generator/Services/WebResources/WebResourceAnalyzer.cs @@ -80,7 +80,8 @@ private async Task AnalyzeOnChangeHandlersAsync( webResource.Name, attributeName.Type, attributeName.Operation, - SupportedType + SupportedType, + false )); } } @@ -107,7 +108,8 @@ private async Task AnalyzeOnChangeHandlersAsync( webResource.Name, reference.Context, ConvertOperationString(reference.Operation), - SupportedType + SupportedType, + false )); } @@ -127,7 +129,8 @@ private async Task AnalyzeOnChangeHandlersAsync( webResource.Name, reference.Context, ConvertOperationString(reference.Operation), - SupportedType + SupportedType, + false )); } } diff --git a/Generator/Services/WorkflowService.cs b/Generator/Services/WorkflowService.cs new file mode 100644 index 0000000..c3cac7f --- /dev/null +++ b/Generator/Services/WorkflowService.cs @@ -0,0 +1,68 @@ +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace Generator.Services +{ + /// + /// Service responsible for querying workflow details (business rules and classic workflows) + /// + internal class WorkflowService + { + private readonly ServiceClient client; + + public WorkflowService(ServiceClient client) + { + this.client = client; + } + + /// + /// Retrieves workflow details for specified workflow IDs + /// + /// List of workflow IDs to query + /// Dictionary mapping workflow ID to WorkflowInfo + public async Task> GetWorkflows(List workflowIds) + { + if (workflowIds.Count == 0) + return []; + + var query = new QueryExpression("workflow") + { + ColumnSet = new ColumnSet("workflowid", "name", "category", "type"), + Criteria = new FilterExpression(LogicalOperator.And) + { + Conditions = + { + new ConditionExpression("workflowid", ConditionOperator.In, workflowIds) + } + } + }; + + var results = await client.RetrieveMultipleAsync(query); + + return results.Entities.ToDictionary( + e => e.Id, + e => new WorkflowInfo( + e.Id, + e.GetAttributeValue("name"), + e.GetAttributeValue("category").Value, + e.GetAttributeValue("type").Value + ) + ); + } + } + + /// + /// Represents workflow information + /// + /// Unique identifier of the workflow + /// Display name of the workflow + /// Workflow category (2 = Business Rule, 0 = Workflow, etc.) + /// Workflow type (1 = Definition, 2 = Activation, etc.) + public record WorkflowInfo( + Guid WorkflowId, + string Name, + int Category, + int Type + ); +} diff --git a/Generator/WebsiteBuilder.cs b/Generator/WebsiteBuilder.cs index e4ade62..0661ebd 100644 --- a/Generator/WebsiteBuilder.cs +++ b/Generator/WebsiteBuilder.cs @@ -14,12 +14,11 @@ internal class WebsiteBuilder private readonly IEnumerable solutions; private readonly string OutputFolder; - public WebsiteBuilder(IConfiguration configuration, IEnumerable records, IEnumerable warnings, IEnumerable components) + public WebsiteBuilder(IConfiguration configuration, IEnumerable records, IEnumerable warnings) { this.configuration = configuration; this.records = records; this.warnings = warnings; - this.solutions = components; // Assuming execution in bin/xxx/net8.0 OutputFolder = configuration["OutputFolder"] ?? Path.Combine(System.Reflection.Assembly.GetExecutingAssembly().Location, "../../../../../Website/generated"); @@ -28,12 +27,13 @@ public WebsiteBuilder(IConfiguration configuration, IEnumerable records, internal void AddData() { var sb = new StringBuilder(); - sb.AppendLine("import { GroupType, SolutionWarningType, SolutionType } from \"@/lib/Types\";"); + sb.AppendLine("import { GroupType, SolutionWarningType } from \"@/lib/Types\";"); sb.AppendLine(""); sb.AppendLine($"export const LastSynched: Date = new Date('{DateTimeOffset.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ}');"); var logoUrl = configuration.GetValue("Logo", defaultValue: null); var jsValue = logoUrl != null ? $"\"{logoUrl}\"" : "null"; sb.AppendLine($"export const Logo: string | null = {jsValue};"); + sb.AppendLine($"export const SolutionCount: number = {configuration["DataverseSolutionNames"]?.Split(",").Length ?? -1};"); sb.AppendLine(""); // ENTITIES @@ -66,15 +66,6 @@ internal void AddData() } sb.AppendLine("]"); - // SOLUTION COMPONENTS - sb.AppendLine(""); - sb.AppendLine("export let Solutions: SolutionType[] = ["); - foreach (var solution in solutions) - { - sb.AppendLine($" {JsonConvert.SerializeObject(solution)},"); - } - sb.AppendLine("]"); - File.WriteAllText(Path.Combine(OutputFolder, "Data.ts"), sb.ToString()); } } diff --git a/SharedTools/SharedTools.projitems b/SharedTools/SharedTools.projitems deleted file mode 100644 index 55e62ed..0000000 --- a/SharedTools/SharedTools.projitems +++ /dev/null @@ -1,14 +0,0 @@ - - - - $(MSBuildAllProjects);$(MSBuildThisFileFullPath) - true - 668737cf-1205-43e7-9ce2-1e451fd8c8a7 - - - SharedTools - - - - - \ No newline at end of file diff --git a/SharedTools/SharedTools.shproj b/SharedTools/SharedTools.shproj deleted file mode 100644 index 0dd7cf5..0000000 --- a/SharedTools/SharedTools.shproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - 668737cf-1205-43e7-9ce2-1e451fd8c8a7 - 14.0 - - - - - - - - diff --git a/Tools/Scripts/AddWebResourceDescription.csproj b/Tools/Scripts/AddWebResourceDescription.csproj deleted file mode 100644 index 9254fca..0000000 --- a/Tools/Scripts/AddWebResourceDescription.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - Exe - net8.0 - enable - enable - - $(NoWarn);IDE0005;CA1303;CA1308;MA0011;SA1122;SA1513;SA1518;MA0009;MA0023;CA1031;CA2234;MA0002;CA1849 - - - - - - - - - diff --git a/Tools/Scripts/Program.cs b/Tools/Scripts/Program.cs deleted file mode 100644 index f62a48d..0000000 --- a/Tools/Scripts/Program.cs +++ /dev/null @@ -1,294 +0,0 @@ -using Azure.Core; -using Azure.Identity; -using Microsoft.Identity.Client; -using Microsoft.PowerPlatform.Dataverse.Client; -using Microsoft.PowerPlatform.Dataverse.Client.Model; -using Microsoft.Xrm.Sdk; -using Microsoft.Xrm.Sdk.Query; -using System.IdentityModel.Tokens.Jwt; -using System.Net.Http.Headers; -using System.Text.RegularExpressions; - -#nullable enable -#pragma warning disable MA0048 // File name must match type name - This is a top-level program -#pragma warning disable S1075 // Refactor your code not to use hardcoded absolute paths or URIs - -// Validate command-line arguments -if (args.Length == 0) -{ - Console.WriteLine("Error: Please provide a folder path as argument."); - Console.WriteLine("Usage: AddWebResourceDescription [dataverse-url]"); - Console.WriteLine(" folder-path: Path to TypeScript project folder"); - Console.WriteLine(" dataverse-url: (Optional) Dataverse URL, or set DATAVERSE_URL environment variable"); - Console.WriteLine("\nEnvironment variables:"); - Console.WriteLine(" DATAVERSE_URL: Dataverse environment URL"); - Console.WriteLine(" DATAVERSE_TENANT_ID: (Optional) Azure AD Tenant ID for multi-tenant scenarios"); - return 1; -} - -string folderPath = args[0]; -string? dataverseUrl = args.Length > 1 ? args[1] : Environment.GetEnvironmentVariable("DATAVERSE_URL"); - -if (!Directory.Exists(folderPath)) -{ - Console.WriteLine($"Error: Directory '{folderPath}' does not exist."); - return 1; -} - -if (string.IsNullOrEmpty(dataverseUrl)) -{ - Console.WriteLine("Error: Dataverse URL not provided."); - Console.WriteLine("Either pass as second argument or set DATAVERSE_URL environment variable."); - Console.WriteLine("Example: https://yourorg.crm.dynamics.com"); - return 1; -} - -Console.WriteLine($"Scanning TypeScript files in: {folderPath}"); -Console.WriteLine("=".PadRight(80, '=')); - -// Find all TypeScript files recursively -var tsFiles = Directory.GetFiles(folderPath, "*.ts", SearchOption.AllDirectories); -Console.WriteLine($"Found {tsFiles.Length} TypeScript files.\n"); - -// Dictionary to store file -> entity schema name mapping -var fileEntityMap = new Dictionary(); -var filesWithoutEntity = new List(); - -// Regex patterns -// Pattern 1: Match exported onLoad function -var onLoadPattern = @"export\s+(?:async\s+)?function\s+onLoad\s*\([\s\S]*?\)\s*\{([\s\S]*?)\n\}"; - -// Pattern 2: Match getFormContext() with angle bracket cast: x.getFormContext() -var castAngleBracketPattern = @"<(Form\.[^>]+)>\s*\w+\.getFormContext\(\)"; - -// Pattern 3: Match getFormContext() with 'as' cast: x.getFormContext() as Form.Entity.Type.Name -var castAsPattern = @"\w+\.getFormContext\(\)\s+as\s+(Form\.\S+)"; - -// Pattern 4: Extract entity schema name from Form... -var formTypePattern = @"Form\.(\w+)\.(?:Main|QuickCreate|QuickView|Card)\."; - -foreach (var tsFile in tsFiles) -{ - var relativePath = Path.GetRelativePath(folderPath, tsFile); - var content = File.ReadAllText(tsFile); - - // Find exported onLoad function - var onLoadMatch = Regex.Match(content, onLoadPattern, RegexOptions.Multiline); - - if (!onLoadMatch.Success) - continue; // Skip files without exported onLoad - - var onLoadBody = onLoadMatch.Groups[1].Value; - - // Look for getFormContext() with casts - string? formType = null; - - // Try angle bracket cast first - var angleBracketMatch = Regex.Match(onLoadBody, castAngleBracketPattern); - if (angleBracketMatch.Success) - { - formType = angleBracketMatch.Groups[1].Value; - } - else - { - // Try 'as' cast - var asMatch = Regex.Match(onLoadBody, castAsPattern); - if (asMatch.Success) - { - formType = asMatch.Groups[1].Value; - } - } - - if (formType == null) - { - filesWithoutEntity.Add(relativePath); - Console.WriteLine($"⚠️ {relativePath}"); - Console.WriteLine($" Warning: Could not find getFormContext() cast in onLoad function.\n"); - continue; - } - - // Extract entity schema name from Form... - var entityMatch = Regex.Match(formType, formTypePattern); - if (!entityMatch.Success) - { - filesWithoutEntity.Add(relativePath); - Console.WriteLine($"⚠️ {relativePath}"); - Console.WriteLine($" Warning: Could not parse entity from form type: {formType}\n"); - continue; - } - - string entitySchemaName = entityMatch.Groups[1].Value; - fileEntityMap[relativePath] = entitySchemaName; - - Console.WriteLine($"✓ {relativePath}"); - Console.WriteLine($" Entity: {entitySchemaName} (from {formType})\n"); -} - -Console.WriteLine("=".PadRight(80, '=')); -Console.WriteLine($"Summary: {fileEntityMap.Count} files with entity mapping, {filesWithoutEntity.Count} warnings.\n"); - -if (fileEntityMap.Count == 0) -{ - Console.WriteLine("No files to update. Exiting."); - return 0; -} - -// Connect to Dataverse using Azure Default Credentials -Console.WriteLine("Connecting to Dataverse..."); -Console.WriteLine($"Target URL: {dataverseUrl}"); -Console.WriteLine("Authentication: Azure DefaultAzureCredential\n"); - -try -{ - // Token provider function using DefaultAzureCredential - var credential = new DefaultAzureCredential(); - - Task TokenProviderFunction(string url) - { - var scope = $"{GetCoreUrl(url)}/.default"; - var tokenRequestContext = new TokenRequestContext([scope]); - var token = credential.GetToken(tokenRequestContext, CancellationToken.None); - return Task.FromResult(token.Token); - } - - using var serviceClient = new ServiceClient(new Uri(dataverseUrl), tokenProviderFunction: TokenProviderFunction); - Console.WriteLine($"IsReady: {serviceClient.IsReady}"); - Console.WriteLine($"LastError: {serviceClient.LastError}"); - Console.WriteLine($"LastException: {serviceClient.LastException?.ToString()}"); - - if (!serviceClient.IsReady) - { - Console.WriteLine($"\nError: Failed to connect to Dataverse."); - Console.WriteLine($"Last Error: {serviceClient.LastError ?? "(null)"}"); - - if (serviceClient.LastException != null) - { - Console.WriteLine($"Exception Type: {serviceClient.LastException.GetType().Name}"); - Console.WriteLine($"Exception Message: {serviceClient.LastException.Message}"); - - if (serviceClient.LastException.InnerException != null) - { - Console.WriteLine($"Inner Exception Type: {serviceClient.LastException.InnerException.GetType().Name}"); - Console.WriteLine($"Inner Exception Message: {serviceClient.LastException.InnerException.Message}"); - } - - Console.WriteLine($"\nFull Stack Trace:"); - Console.WriteLine(serviceClient.LastException.ToString()); - } - else - { - Console.WriteLine("No exception details available."); - } - - Console.WriteLine("\nTroubleshooting:"); - Console.WriteLine(" - Verify you have access to the Dataverse environment"); - Console.WriteLine(" - Check the Dataverse URL is correct"); - Console.WriteLine(" - Ensure your account has permissions in this environment"); - return 1; - } - - Console.WriteLine($"✓ Connected to: {serviceClient.ConnectedOrgFriendlyName}"); - Console.WriteLine($" Organization ID: {serviceClient.ConnectedOrgId}\n"); - - // Extract root folder name from file location - string rootFolderName = new DirectoryInfo(folderPath).Name; - Console.WriteLine($"Root folder: {rootFolderName}"); - Console.WriteLine($"Querying all webresources starting with: {rootFolderName}/\n"); - - // Query all webresources that start with the root folder name - var query = new QueryExpression("webresource") - { - ColumnSet = new ColumnSet("webresourceid", "name", "description"), - Criteria = new FilterExpression - { - Conditions = - { - new ConditionExpression("name", ConditionOperator.BeginsWith, rootFolderName), - }, - }, - }; - - var allWebResources = serviceClient.RetrieveMultiple(query); - Console.WriteLine($"Found {allWebResources.Entities.Count} webresources in Dataverse.\n"); - - // Create dictionary of webresources by name for fast lookup - var webResourceDict = allWebResources.Entities - .ToDictionary( - wr => wr.GetAttributeValue("name"), - wr => wr, - StringComparer.OrdinalIgnoreCase); - - // Update webresources - int updatedCount = 0; - int notFoundCount = 0; - int alreadyTaggedCount = 0; - - foreach (var kvp in fileEntityMap) - { - var relativePath = kvp.Key; - var entitySchemaName = kvp.Value; - - // Convert file path to webresource name (replace backslashes with forward slashes, .ts -> .js) - string webResourceName = $"{rootFolderName}/{relativePath.Replace('\\', '/').Replace(".ts", ".js", StringComparison.Ordinal)}"; - - // Try to find the webresource in our dictionary - if (!webResourceDict.TryGetValue(webResourceName, out var webResource)) - { - // Try without root folder prefix (in case files already include it) - string alternativeName = relativePath.Replace('\\', '/').Replace(".ts", ".js", StringComparison.Ordinal); - if (!webResourceDict.TryGetValue(alternativeName, out webResource)) - { - Console.WriteLine($"⚠️ Webresource not found: {webResourceName}"); - notFoundCount++; - continue; - } - else - { - webResourceName = alternativeName; // Use the alternative name for display - } - } - - var currentDescription = webResource.GetAttributeValue("description") ?? string.Empty; - - // Check if ENTITY tag already exists - string entityTag = $"ENTITY:{entitySchemaName}"; - if (currentDescription.Contains(entityTag, StringComparison.Ordinal)) - { - Console.WriteLine($"ℹ️ Already tagged: {webResourceName}"); - alreadyTaggedCount++; - continue; - } - - // Append entity tag to description - string newDescription = string.IsNullOrWhiteSpace(currentDescription) - ? entityTag - : $"{currentDescription}\n{entityTag}"; - - webResource["description"] = newDescription; - - serviceClient.Update(webResource); - updatedCount++; - - Console.WriteLine($"✓ Updated: {webResourceName} -> {entityTag}"); - } - - Console.WriteLine("\n" + "=".PadRight(80, '=')); - Console.WriteLine($"Update complete:"); - Console.WriteLine($" {updatedCount} updated"); - Console.WriteLine($" {alreadyTaggedCount} already tagged"); - Console.WriteLine($" {notFoundCount} not found"); -} -catch (Exception ex) -{ - Console.WriteLine($"Error: {ex.Message}"); - return 1; -} - -return 0; - -// Helper function to extract core URL from Dataverse URL -static string GetCoreUrl(string dataverseUrl) -{ - var uri = new Uri(dataverseUrl); - return $"{uri.Scheme}://{uri.Host}"; -} diff --git a/SharedTools/addWebResourceDescription.cs b/Tools/Scripts/addWebResourceDescription.cs similarity index 70% rename from SharedTools/addWebResourceDescription.cs rename to Tools/Scripts/addWebResourceDescription.cs index bbe848a..27577a3 100644 --- a/SharedTools/addWebResourceDescription.cs +++ b/Tools/Scripts/addWebResourceDescription.cs @@ -1,17 +1,16 @@ #!/usr/bin/dotnet run -#:package Microsoft.PowerPlatform.Dataverse.Client@1.2.* -#:package Azure.Identity@1.13.* -#:package System.Text.RegularExpressions@* -// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -// DOES NOT WORK AS SERVICECLIENT IS NOT SUPPORTED IN .NET 10 -// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +#:package Microsoft.PowerPlatform.Dataverse.Client@1.2.10 +#:package Azure.Identity@1.13.2 +#:package System.Text.RegularExpressions@* +#:property PublishAot=false using Azure.Core; using Azure.Identity; using Microsoft.PowerPlatform.Dataverse.Client; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; +using System; using System.Text.RegularExpressions; // Validate command-line arguments @@ -141,28 +140,88 @@ try { // Token provider function using DefaultAzureCredential + // This will try credentials in order: Environment, Managed Identity, Visual Studio, Azure CLI, etc. var credential = new DefaultAzureCredential(); - string TokenProviderFunction(string url) + // Calculate resource scope once (use UriPartial.Authority like the working example) + var resource = $"{new Uri(dataverseUrl).GetLeftPart(UriPartial.Authority)}/.default"; + Console.WriteLine($"Testing authentication with scope: {resource}"); + + try + { + var tokenRequestContext = new TokenRequestContext([resource]); + var testToken = await credential.GetTokenAsync(tokenRequestContext, default); + Console.WriteLine($"✓ Successfully obtained access token (expires: {testToken.ExpiresOn:yyyy-MM-dd HH:mm:ss})\n"); + } + catch (Exception authEx) { - var scope = $"{GetCoreUrl(url)}/.default"; - var tokenRequestContext = new TokenRequestContext([scope]); - var token = credential.GetToken(tokenRequestContext, CancellationToken.None); + Console.WriteLine($"❌ Failed to obtain access token."); + Console.WriteLine($"Error: {authEx.Message}\n"); + Console.WriteLine("Troubleshooting:"); + Console.WriteLine(" - Run 'az login' to authenticate with Azure CLI"); + Console.WriteLine(" - Run 'az account show' to verify your login"); + Console.WriteLine($" - Verify you have access to: {dataverseUrl}"); + return 1; + } + + // Token provider function - ignore the url parameter and use our pre-calculated resource + async Task TokenProvider(string url) + { + var tokenRequestContext = new TokenRequestContext([resource]); + var token = await credential.GetTokenAsync(tokenRequestContext, default); return token.Token; } - using var serviceClient = new ServiceClient( - instanceUrl: new Uri(dataverseUrl), - tokenProviderFunction: url => TokenProviderFunction(url)); + Console.WriteLine("Creating ServiceClient..."); + ServiceClient serviceClient; + + try + { + serviceClient = new ServiceClient( + instanceUrl: new Uri(dataverseUrl), + tokenProviderFunction: TokenProvider); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Exception during ServiceClient creation:"); + Console.WriteLine($"Message: {ex.Message}"); + Console.WriteLine($"Type: {ex.GetType().FullName}"); + Console.WriteLine($"\nStack Trace:\n{ex.StackTrace}"); + + if (ex.InnerException != null) + { + Console.WriteLine($"\nInner Exception:"); + Console.WriteLine($"Message: {ex.InnerException.Message}"); + Console.WriteLine($"Type: {ex.InnerException.GetType().FullName}"); + Console.WriteLine($"\nInner Stack Trace:\n{ex.InnerException.StackTrace}"); + } + return 1; + } - if (!serviceClient.IsReady) + if (serviceClient == null || !serviceClient.IsReady) { - Console.WriteLine($"Error: Failed to connect to Dataverse."); - Console.WriteLine($"Details: {serviceClient.LastError}"); + var errorMsg = serviceClient?.LastError ?? "ServiceClient failed to initialize"; + Console.WriteLine($"❌ ServiceClient created but not ready."); + Console.WriteLine($"LastError: {errorMsg}"); + + if (serviceClient?.LastException != null) + { + Console.WriteLine($"\nLastException Message: {serviceClient.LastException.Message}"); + Console.WriteLine($"LastException Type: {serviceClient.LastException.GetType().FullName}"); + Console.WriteLine($"\nLastException Stack Trace:\n{serviceClient.LastException.StackTrace}"); + + if (serviceClient.LastException.InnerException != null) + { + Console.WriteLine($"\nLastException Inner Exception:"); + Console.WriteLine($"Message: {serviceClient.LastException.InnerException.Message}"); + Console.WriteLine($"Type: {serviceClient.LastException.InnerException.GetType().FullName}"); + } + } + Console.WriteLine("\nTroubleshooting:"); - Console.WriteLine(" - Run 'az login' to authenticate with Azure CLI"); - Console.WriteLine(" - Verify you have access to the Dataverse environment"); - Console.WriteLine(" - Check the Dataverse URL is correct"); + Console.WriteLine(" - Verify you have the System Administrator or System Customizer role"); + Console.WriteLine(" - Check firewall/network access to the Dataverse URL"); + Console.WriteLine(" - Try accessing the URL in a browser to verify it's reachable"); return 1; } @@ -264,10 +323,3 @@ string TokenProviderFunction(string url) } return 0; - -// Helper function to extract core URL from Dataverse URL -static string GetCoreUrl(string dataverseUrl) -{ - var uri = new Uri(dataverseUrl); - return $"{uri.Scheme}://{uri.Host}"; -} diff --git a/Website/components/datamodelview/Attributes.tsx b/Website/components/datamodelview/Attributes.tsx index c17156a..4cbee50 100644 --- a/Website/components/datamodelview/Attributes.tsx +++ b/Website/components/datamodelview/Attributes.tsx @@ -233,7 +233,7 @@ export const Attributes = ({ entity, search = "", onVisibleCountChange }: IAttri ))} - + + {(searchQuery || typeFilter !== "all") && ( + ) : ( + } + label={highlightMatch(relationship.TableSchema, highlightTerm)} + size="small" + disabled + sx={{ + fontSize: { xs: '0.625rem', md: '0.875rem' }, + height: { xs: '16px', md: '24px' }, + backgroundColor: 'grey.100', + color: 'grey.600', + '& .MuiChip-icon': { + fontSize: { xs: '0.5rem', md: '0.75rem' } + } + }} + /> + )} + + + {relationship.LookupDisplayName} + + + {relationship.RelationshipType} + + + + + + {relationship.RelationshipSchema} + + + )} + + )} diff --git a/Website/components/datamodelview/TimeSlicedSearch.tsx b/Website/components/datamodelview/TimeSlicedSearch.tsx index f3e421c..fffbda8 100644 --- a/Website/components/datamodelview/TimeSlicedSearch.tsx +++ b/Website/components/datamodelview/TimeSlicedSearch.tsx @@ -237,8 +237,8 @@ export const TimeSlicedSearch = ({ }; const searchInput = ( - - + + diff --git a/Website/components/datamodelview/dataLoaderWorker.ts b/Website/components/datamodelview/dataLoaderWorker.ts index a2cdbf8..b4fc209 100644 --- a/Website/components/datamodelview/dataLoaderWorker.ts +++ b/Website/components/datamodelview/dataLoaderWorker.ts @@ -1,5 +1,5 @@ import { EntityType } from '@/lib/Types'; -import { Groups, SolutionWarnings, Solutions } from '../../generated/Data'; +import { Groups, SolutionWarnings, SolutionCount } from '../../generated/Data'; self.onmessage = function () { const entityMap = new Map(); @@ -8,5 +8,5 @@ self.onmessage = function () { entityMap.set(entity.SchemaName, entity); }); }); - self.postMessage({ groups: Groups, entityMap: entityMap, warnings: SolutionWarnings, solutions: Solutions }); + self.postMessage({ groups: Groups, entityMap: entityMap, warnings: SolutionWarnings, solutionCount: SolutionCount }); }; \ No newline at end of file diff --git a/Website/components/diagramview/diagram-elements/RelationshipLink.ts b/Website/components/diagramview/diagram-elements/RelationshipLink.ts index b7901ef..ef7af75 100644 --- a/Website/components/diagramview/diagram-elements/RelationshipLink.ts +++ b/Website/components/diagramview/diagram-elements/RelationshipLink.ts @@ -97,11 +97,11 @@ export const updateLinkMarkers = (link: dia.Link) => { // Set markers based on included relationships includedRelationships.forEach((relInfo) => { - if (relInfo.RelationshipType === '1-M') { + if (relInfo.RelationshipType === '1:N') { link.attr('line/targetMarker', circleMarker); - } else if (relInfo.RelationshipType === 'M-1' || relInfo.RelationshipType === 'SELF') { + } else if (relInfo.RelationshipType === 'N:1' || relInfo.RelationshipType === 'SELF') { link.attr('line/sourceMarker', circleMarker); - } else if (relInfo.RelationshipType === 'M-M') { + } else if (relInfo.RelationshipType === 'N:N') { link.attr('line/targetMarker', circleMarker); link.attr('line/sourceMarker', circleMarker); } diff --git a/Website/components/diagramview/smaller-components/RelationshipProperties.tsx b/Website/components/diagramview/smaller-components/RelationshipProperties.tsx index 6782681..e909959 100644 --- a/Website/components/diagramview/smaller-components/RelationshipProperties.tsx +++ b/Website/components/diagramview/smaller-components/RelationshipProperties.tsx @@ -100,7 +100,7 @@ const RelationshipProperties = ({ relationships, linkId }: IRelationshipProperti )} - {rel.IsManyToMany && ( + {rel.RelationshipType === "N:N" && ( )} {rel.isIncluded === undefined && ( diff --git a/Website/components/homeview/HomeView.tsx b/Website/components/homeview/HomeView.tsx index 07e6b69..2d0c3a6 100644 --- a/Website/components/homeview/HomeView.tsx +++ b/Website/components/homeview/HomeView.tsx @@ -23,6 +23,13 @@ export const HomeView = ({ }: IHomeViewProps) => { // Carousel data const carouselItems: CarouselItem[] = [ + { + image: '/processes.jpg', + title: 'Implicit data!', + text: "Now you can see the implicit components automatically added by the Platform (hidden in the solution components). And while we were at it, we also chose to display the dependent classical workflows and business rules that the Platform makes visible to you. Don't forget to check out the Insights for new additional dashboards.", + type: '(v2.2.6) Patch update', + action: () => router.push('/processes') + }, { image: '/documentation.jpg', title: 'Connect to your Azure DevOps!', @@ -47,12 +54,6 @@ export const HomeView = ({ }: IHomeViewProps) => { actionlabel: 'Try it out', action: () => router.push('/processes') }, - { - image: '/upgrade.jpg', - title: 'Data Model Viewer 2.0.0!', - text: "The UI has been refreshed for an even cleaner, more modern look with enhanced functionality. And we've upgraded the tech stack to ensure easier maintainability.", - type: '(v2.0.0) Announcement' - } ]; const goToPrevious = () => { diff --git a/Website/components/insightsview/overview/InsightsOverviewView.tsx b/Website/components/insightsview/overview/InsightsOverviewView.tsx index 3b3605a..3010f0b 100644 --- a/Website/components/insightsview/overview/InsightsOverviewView.tsx +++ b/Website/components/insightsview/overview/InsightsOverviewView.tsx @@ -2,7 +2,7 @@ import { InfoCard } from "@/components/shared/elements/InfoCard"; import { useDatamodelData } from "@/contexts/DatamodelDataContext"; import { ComponentIcon, InfoIcon, ProcessesIcon, SolutionIcon, WarningIcon } from "@/lib/icons"; import { generateLiquidCheeseSVG } from "@/lib/svgart"; -import { Box, Grid, Paper, Stack, Tooltip, Typography, useTheme } from "@mui/material"; +import { Box, Grid, IconButton, Paper, Stack, Tooltip, Typography, useTheme } from "@mui/material"; import { ResponsiveBar } from "@nivo/bar"; import { ResponsivePie } from "@nivo/pie"; import { useMemo } from "react"; @@ -14,12 +14,16 @@ interface InsightsOverviewViewProps { const InsightsOverviewView = ({ }: InsightsOverviewViewProps) => { const theme = useTheme(); - const { groups, solutions } = useDatamodelData(); + const { groups, solutionCount } = useDatamodelData(); const totalAttributeUsageCount = useMemo(() => { return groups.reduce((acc, group) => acc + group.Entities.reduce((acc, entity) => acc + entity.Attributes.reduce((acc, attr) => acc + attr.AttributeUsages.length, 0), 0), 0); }, [groups]) + const totalComponentsCount = useMemo(() => { + return groups.reduce((acc, group) => acc + 1 + group.Entities.reduce((acc, entity) => acc + entity.Attributes.length + entity.Relationships.length, 0), 0); + }, [groups]) + const missingIconEntities = useMemo(() => { const iconsMissing = groups.flatMap(group => group.Entities.filter(entity => !entity.IconBase64)); return iconsMissing; @@ -71,17 +75,13 @@ const InsightsOverviewView = ({ }: InsightsOverviewViewProps) => { const allEntities = groups.flatMap(group => group.Entities); const auditEnabled = allEntities.filter(entity => entity.IsAuditEnabled).length; - const auditDisabled = allEntities.filter(entity => !entity.IsAuditEnabled).length; const activities = allEntities.filter(entity => entity.IsActivity).length; const notesEnabled = allEntities.filter(entity => entity.IsNotesEnabled).length; - const notesDisabled = allEntities.filter(entity => !entity.IsNotesEnabled).length; return [ { id: 'Audit Enabled', label: 'Audit Enabled', value: auditEnabled }, - { id: 'Audit Disabled', label: 'Audit Disabled', value: auditDisabled }, { id: 'Activities', label: 'Activities', value: activities }, { id: 'Notes Enabled', label: 'Notes Enabled', value: notesEnabled }, - { id: 'Notes Disabled', label: 'Notes Disabled', value: notesDisabled }, ].filter(item => item.value > 0); // Only show categories with values }, [groups]); @@ -104,27 +104,112 @@ const InsightsOverviewView = ({ }: InsightsOverviewViewProps) => { }, [groups]); const publisherComponentData = useMemo(() => { - // Count components per publisher by looking at each component's publisher - const publisherCounts: Record = {}; + // Get all entities to look up IsExplicit for attributes and relationships + const allEntities = groups.flatMap(group => group.Entities); + + // Create lookup maps for attributes and relationships by schema name + const attributeMap = new Map(); + const relationshipMap = new Map(); + + allEntities.forEach(entity => { + entity.Attributes.forEach(attr => { + attributeMap.set(attr.SchemaName, attr.IsExplicit); + }); + entity.Relationships.forEach(rel => { + relationshipMap.set(rel.RelationshipSchema, rel.IsExplicit); + }); + }); - solutions.forEach(solution => { - solution.Components.forEach(component => { - const publisher = component.PublisherName; + // Count components per publisher with explicit/implicit breakdown + const publisherCounts: Record = {}; + + groups.forEach(group => { + group.Entities.forEach(entity => { + const publisher = entity.PublisherName || "Unknown Publisher"; if (!publisherCounts[publisher]) { - publisherCounts[publisher] = 0; + publisherCounts[publisher] = { explicit: 0, implicit: 0 }; } - publisherCounts[publisher]++; + publisherCounts[publisher].explicit++; + + entity.Attributes.forEach(attr => { + const isExplicit = attr.IsExplicit; + const publisher = entity.PublisherName || "Unknown Publisher"; + if (!publisherCounts[publisher]) { + publisherCounts[publisher] = { explicit: 0, implicit: 0 }; + } + if (isExplicit) publisherCounts[publisher].explicit++; + else publisherCounts[publisher].implicit++; + }); + + entity.Relationships.forEach(rel => { + const isExplicit = rel.IsExplicit; + const publisher = entity.PublisherName || "Unknown Publisher"; + if (!publisherCounts[publisher]) { + publisherCounts[publisher] = { explicit: 0, implicit: 0 }; + } + if (isExplicit) publisherCounts[publisher].explicit++; + else publisherCounts[publisher].implicit++; + }); }); }); - // Convert to chart format and sort by component count (descending) + // Convert to chart format and sort by total component count (descending) return Object.entries(publisherCounts) - .map(([publisher, count]) => ({ + .map(([publisher, counts]) => ({ publisher: publisher, - components: count + explicit: counts.explicit, + implicit: counts.implicit })) - .sort((a, b) => b.components - a.components); - }, [solutions]); + .sort((a, b) => (b.explicit + b.implicit) - (a.explicit + a.implicit)); + }, [groups]); + + const attributeUsageByComponentType = useMemo(() => { + const allEntities = groups.flatMap(group => group.Entities); + const allAttributeUsages = allEntities.flatMap(entity => + entity.Attributes.flatMap(attr => attr.AttributeUsages) + ); + + // Count by component type + const componentTypeCounts: Record = {}; + allAttributeUsages.forEach(usage => { + componentTypeCounts[usage.ComponentType] = (componentTypeCounts[usage.ComponentType] || 0) + 1; + }); + + // Map component type numbers to labels + const componentTypeLabels: Record = { + 0: 'Power Automate Flow', + 1: 'Plugin', + 2: 'Web Resource', + 3: 'Workflow Activity', + 4: 'Custom API', + 5: 'Business Rule', + 6: 'Classic Workflow' + }; + + return Object.entries(componentTypeCounts).map(([type, count]) => ({ + id: componentTypeLabels[parseInt(type)] || `Type ${type}`, + label: componentTypeLabels[parseInt(type)] || `Type ${type}`, + value: count + })); + }, [groups]); + + const attributeUsageBySource = useMemo(() => { + const allEntities = groups.flatMap(group => group.Entities); + const allAttributeUsages = allEntities.flatMap(entity => + entity.Attributes.flatMap(attr => attr.AttributeUsages) + ); + + const analyzerDetected = allAttributeUsages.filter(usage => !usage.IsFromDependencyAnalysis).length; + const dependencyDetected = allAttributeUsages.filter(usage => usage.IsFromDependencyAnalysis).length; + + return [ + { + category: 'Detection Source', + 'Analyzer Detected': analyzerDetected, + 'Dependency Detected': dependencyDetected + } + ]; + }, [groups]); return ( @@ -200,7 +285,7 @@ const InsightsOverviewView = ({ }: InsightsOverviewViewProps) => { @@ -209,7 +294,7 @@ const InsightsOverviewView = ({ }: InsightsOverviewViewProps) => { acc + solution.Components.length, 0)} + value={totalComponentsCount} iconSrc={ComponentIcon} /> @@ -217,7 +302,7 @@ const InsightsOverviewView = ({ }: InsightsOverviewViewProps) => { @@ -225,9 +310,16 @@ const InsightsOverviewView = ({ }: InsightsOverviewViewProps) => { - - Data Model Distribution: Standard vs Custom - + + + Data Model Distribution: Standard vs Custom + + + + {InfoIcon} + + + { - - Components by Publisher - + + + Components by Publisher (Stacked by Explicit/Implicit) + + + + {InfoIcon} + + + { labelSkipWidth={12} labelSkipHeight={12} labelTextColor={{ from: 'color', modifiers: [['darker', 3]] }} + legends={[ + { + dataFrom: 'keys', + anchor: 'bottom-right', + direction: 'column', + justify: false, + translateX: 120, + translateY: 0, + itemsSpacing: 2, + itemWidth: 100, + itemHeight: 20, + itemDirection: 'left-to-right', + itemOpacity: 0.85, + symbolSize: 20, + effects: [ + { + on: 'hover', + style: { + itemOpacity: 1 + } + } + ] + } + ]} role="application" ariaLabel="Components by publisher bar chart" - barAriaLabel={e => `${e.indexValue}: ${e.formattedValue} components`} + barAriaLabel={e => `${e.indexValue}: ${e.formattedValue} ${e.id} components`} theme={{ background: 'transparent', text: { @@ -459,6 +583,25 @@ const InsightsOverviewView = ({ }: InsightsOverviewViewProps) => { strokeDasharray: '4 4' } }, + legends: { + title: { + text: { + fontSize: 11, + fill: theme.palette.text.primary + } + }, + text: { + fontSize: 11, + fill: theme.palette.text.primary + }, + ticks: { + line: {}, + text: { + fontSize: 10, + fill: theme.palette.text.primary + } + } + }, tooltip: { container: { background: theme.palette.background.paper, @@ -473,9 +616,16 @@ const InsightsOverviewView = ({ }: InsightsOverviewViewProps) => { - - Entity Features Distribution - + + + Entity Features Distribution + + + + {InfoIcon} + + + { - - Attribute Types Distribution - + + + Attribute Types Distribution + + + + {InfoIcon} + + + { + + + + + + Attribute Process Dependencies by Type + + + + {InfoIcon} + + + + + + + + + + + + + + Attribute Process Dependencies by Detection Source + + + + {InfoIcon} + + + + + `${e.id}: ${e.formattedValue}`} + theme={{ + background: 'transparent', + text: { + fontSize: 12, + fill: theme.palette.text.primary, + outlineWidth: 0, + outlineColor: 'transparent' + }, + axis: { + domain: { + line: { + stroke: theme.palette.divider, + strokeWidth: 1 + } + }, + legend: { + text: { + fontSize: 12, + fill: theme.palette.text.primary + } + }, + ticks: { + line: { + stroke: theme.palette.divider, + strokeWidth: 1 + }, + text: { + fontSize: 11, + fill: theme.palette.text.primary + } + } + }, + grid: { + line: { + stroke: theme.palette.divider, + strokeWidth: 1, + strokeDasharray: '4 4' + } + }, + legends: { + title: { + text: { + fontSize: 11, + fill: theme.palette.text.primary + } + }, + text: { + fontSize: 11, + fill: theme.palette.text.primary + }, + ticks: { + line: {}, + text: { + fontSize: 10, + fill: theme.palette.text.primary + } + } + }, + tooltip: { + container: { + background: theme.palette.background.paper, + color: theme.palette.text.primary, + } + } + }} + /> + + + ) } diff --git a/Website/components/insightsview/solutions/InsightsSolutionView.tsx b/Website/components/insightsview/solutions/InsightsSolutionView.tsx index bf6df3d..c3ed7b8 100644 --- a/Website/components/insightsview/solutions/InsightsSolutionView.tsx +++ b/Website/components/insightsview/solutions/InsightsSolutionView.tsx @@ -1,174 +1,189 @@ import { useDatamodelData } from '@/contexts/DatamodelDataContext' -import { Paper, Typography, Box, Grid, useTheme } from '@mui/material' +import { Paper, Typography, Box, Grid, useTheme, Tooltip, IconButton } from '@mui/material' import React, { useMemo, useState } from 'react' -import { ResponsiveChord, RibbonDatum } from '@nivo/chord' -import { SolutionComponentType, SolutionComponentTypeEnum } from '@/lib/Types' +import { ResponsiveHeatMap } from '@nivo/heatmap' +import { SolutionComponentTypeEnum } from '@/lib/Types' import { generateEnvelopeSVG } from '@/lib/svgart' +import { InfoIcon } from '@/lib/icons' interface InsightsSolutionViewProps { } +interface HeatMapCell { + serieId: string; + data: { + x: string; + y: number; + }; + value: number | null; +} + const InsightsSolutionView = ({ }: InsightsSolutionViewProps) => { - const { solutions } = useDatamodelData(); + const { groups } = useDatamodelData(); const theme = useTheme(); - const [selectedSolution, setSelectedSolution] = useState<{ sourceSolution: { name: string; color: string }; targetSolution: { name: string; color: string }; sharedComponents: SolutionComponentType[] } | undefined>(undefined); - - const chordData = useMemo(() => { - if (!solutions || solutions.length === 0) { - return { matrix: [], keys: [] }; - } + const [selectedSolution, setSelectedSolution] = useState<{ Solution1: string, Solution2: string, Components: { Name: string; SchemaName: string; ComponentType: SolutionComponentTypeEnum }[] } | undefined>(undefined); - // Create a mapping of components shared between solutions - const componentMap = new Map>(); - const solutionNames = solutions.map(sol => sol.Name); + const solutions = useMemo(() => { + const solutionMap: Map = new Map(); + groups.forEach(group => { + group.Entities.forEach(entity => { - // Track which solutions contain each component - solutions.forEach(solution => { - solution.Components.forEach(component => { - if (!componentMap.has(component.SchemaName)) { - componentMap.set(component.SchemaName, new Set()); + if (!entity.Solutions || entity.Solutions.length === 0) { + console.log(`Entity ${entity.DisplayName} has no solutions.`); } - componentMap.get(component.SchemaName)!.add(solution.Name); + + entity.Solutions.forEach(solution => { + if (!solutionMap.has(solution.Name)) { + solutionMap.set(solution.Name, [{ Name: entity.DisplayName, SchemaName: entity.SchemaName, ComponentType: SolutionComponentTypeEnum.Entity }]); + } else { + solutionMap.get(solution.Name)!.push({ Name: entity.DisplayName, SchemaName: entity.SchemaName, ComponentType: SolutionComponentTypeEnum.Entity }); + } + + entity.Attributes.forEach(attribute => { + if (!attribute.Solutions || attribute.Solutions.length === 0) { + console.log(`Attr ${attribute.DisplayName} has no solutions.`); + } + + attribute.Solutions.forEach(attrSolution => { + if (!solutionMap.has(attrSolution.Name)) { + solutionMap.set(attrSolution.Name, [{ Name: attribute.DisplayName, SchemaName: attribute.SchemaName, ComponentType: SolutionComponentTypeEnum.Attribute }]); + } else { + solutionMap.get(attrSolution.Name)!.push({ Name: attribute.DisplayName, SchemaName: attribute.SchemaName, ComponentType: SolutionComponentTypeEnum.Attribute }); + } + }); + }); + + entity.Relationships.forEach(relationship => { + if (!relationship.Solutions || relationship.Solutions.length === 0) { + console.log(`Relationship ${relationship.Name} has no solutions.`); + } + + relationship.Solutions.forEach(relSolution => { + if (!solutionMap.has(relSolution.Name)) { + solutionMap.set(relSolution.Name, [{ Name: relationship.Name, SchemaName: relationship.RelationshipSchema, ComponentType: SolutionComponentTypeEnum.Relationship }]); + } else { + solutionMap.get(relSolution.Name)!.push({ Name: relationship.Name, SchemaName: relationship.RelationshipSchema, ComponentType: SolutionComponentTypeEnum.Relationship }); + } + }); + }); + }); }); }); - // Create matrix showing relationships between solutions based on shared components - const matrix = solutionNames.map(solutionA => - solutionNames.map(solutionB => { - const solutionAComponents = solutions.find(s => s.Name === solutionA)?.Components || []; - const solutionBComponents = solutions.find(s => s.Name === solutionB)?.Components || []; - - if (solutionA === solutionB) { - // For self-reference, return the total number of components in the solution - return solutionAComponents.length; - } - - let sharedComponents = 0; - solutionAComponents.forEach(componentA => { - if (solutionBComponents.some(componentB => - componentB.SchemaName === componentA.SchemaName && - componentB.ComponentType === componentA.ComponentType - )) { - sharedComponents++; + return solutionMap; + }, [groups]); + + const solutionMatrix = useMemo(() => { + const solutionNames = Array.from(solutions.keys()); + + // Create a cache for symmetric calculations + const cache = new Map(); + + const matrix: Array<{ + solution1: string; + solution2: string; + sharedComponents: { Name: string; SchemaName: string; ComponentType: SolutionComponentTypeEnum }[]; + count: number; + }> = []; + + for (let i = 0; i < solutionNames.length; i++) { + for (let j = 0; j < solutionNames.length; j++) { + const solution1 = solutionNames[i]; + const solution2 = solutionNames[j]; + + if (i === j) { + matrix.push({ + solution1, + solution2, + sharedComponents: [], + count: 0 + }); + } else { + // Create a consistent cache key regardless of order + const cacheKey = i < j ? `${solution1}|${solution2}` : `${solution2}|${solution1}`; + + let result = cache.get(cacheKey); + + if (!result) { + // Calculate intersection only once for each pair + const components1 = solutions.get(solution1) || []; + const components2 = solutions.get(solution2) || []; + + // Find true intersection: components that exist in BOTH solutions + const sharedComponents = components1.filter(c1 => + components2.some(c2 => c2.SchemaName === c1.SchemaName && c2.ComponentType === c1.ComponentType) + ); + + result = { + sharedComponents, + count: sharedComponents.length + }; + + cache.set(cacheKey, result); } - }); - - return sharedComponents; - }) - ); + + matrix.push({ + solution1, + solution2, + sharedComponents: result.sharedComponents, + count: result.count + }); + } + } + } + + // Transform data for Nivo HeatMap format + const heatmapData = solutionNames.map((solution1, i) => { + const dataPoints = solutionNames.map((solution2, j) => { + const matrixIndex = i * solutionNames.length + j; + const cell = matrix[matrixIndex]; + return { + x: solution2, + y: cell.count + }; + }); + + return { + id: solution1, + data: dataPoints + }; + }); return { + solutionNames, matrix, - keys: solutionNames + heatmapData }; }, [solutions]); - const hasData = chordData.keys.length > 0 && - chordData.matrix.some(row => row.some(value => value > 0)); - - const onRibbonSelect = ({ source, target }: RibbonDatum) => { - if (source.id === target.id) return <>; - const sourceSolution = chordData.keys[source.index]; - const targetSolution = chordData.keys[target.index]; - - // Get the actual solutions data for more details - const sourceSolutionData = solutions?.find(s => s.Name === sourceSolution); - const targetSolutionData = solutions?.find(s => s.Name === targetSolution); - - // Calculate shared components for detailed info - const sourceComponents = sourceSolutionData?.Components || []; - const targetComponents = targetSolutionData?.Components || []; - - const sharedComponents = sourceComponents.filter(sourceComp => - targetComponents.some(targetComp => - targetComp.SchemaName === sourceComp.SchemaName && - targetComp.ComponentType === sourceComp.ComponentType - ) - ); - - setSelectedSolution({ sourceSolution: { name: sourceSolution, color: source.color }, targetSolution: { name: targetSolution, color: target.color }, sharedComponents }); - } + const onCellSelect = (cellData: HeatMapCell) => { + const solution1 = cellData.serieId as string; + const solution2 = cellData.data.x as string; - const RibbonTooltip = ({ source, target }: RibbonDatum) => { - if (source.id === target.id) return <>; - - const sourceSolution = chordData.keys[source.index]; - const targetSolution = chordData.keys[target.index]; - const sharedCount = chordData.matrix[source.index][target.index]; - - // Get the actual solutions data for more details - const sourceSolutionData = solutions?.find(s => s.Name === sourceSolution); - const targetSolutionData = solutions?.find(s => s.Name === targetSolution); - - // Calculate shared components for detailed info - const sourceComponents = sourceSolutionData?.Components || []; - const targetComponents = targetSolutionData?.Components || []; - - const sharedComponents = sourceComponents.filter(sourceComp => - targetComponents.some(targetComp => - targetComp.SchemaName === sourceComp.SchemaName && - targetComp.ComponentType === sourceComp.ComponentType - ) - ); - - if (!solutions || solutions.length === 0) { - return ( - - - Solutions - - - No solution data available to analyze component relationships. - - - ); - } + if (solution1 === solution2) return; - return ( - - - {sourceSolution} ↔ {targetSolution} - - - {sharedCount} shared component{sharedCount !== 1 ? 's' : ''} - - {sharedComponents.length > 0 && ( - - - Shared Components: - - {sharedComponents.slice(0, 5).map((component, index) => ( - - • {component.Name} ({component.ComponentType === SolutionComponentTypeEnum.Entity ? 'Entity' : - component.ComponentType === SolutionComponentTypeEnum.Attribute ? 'Attribute' : 'Relationship'}) - - ))} - {sharedComponents.length > 5 && ( - - ... and {sharedComponents.length - 5} more - - )} - - )} - - ); - }; + // Find the shared components from the matrix + const i = solutionMatrix.solutionNames.indexOf(solution1); + const j = solutionMatrix.solutionNames.indexOf(solution2); + + if (i !== -1 && j !== -1) { + const matrixIndex = i * solutionMatrix.solutionNames.length + j; + const cell = solutionMatrix.matrix[matrixIndex]; + + setSelectedSolution({ + Solution1: solution1, + Solution2: solution2, + Components: cell.sharedComponents + }); + } + } return ( - + - { backgroundPosition: "center", backgroundSize: "cover", color: 'primary.contrastText', - }}> + }}> Solution Insights @@ -187,57 +202,105 @@ const InsightsSolutionView = ({ }: InsightsSolutionViewProps) => { - - Solution Relations - + + + Solution Relations + + + + {InfoIcon} + + + - This chord diagram visualizes shared components between different solutions. - The thickness of connections indicates the number of components shared between solutions. + Click on any cell to see the shared components between two solutions. - - {hasData ? ( - - } - onRibbonClick={ribbon => onRibbonSelect(ribbon)} - enableLabel={true} - label="id" - labelOffset={12} - labelTextColor={{ from: 'color', modifiers: [['darker', 1]] }} - colors={{ scheme: 'category10' }} - isInteractive={true} - animate={true} - motionConfig="gentle" - theme={{ - text: { - color: "text.primary", - fontSize: 12, - fontWeight: 600 - }, - }} - /> - - ) : ( - - - No shared components found between solutions. - Each solution appears to have unique components. - - - )} + + + onCellSelect(cell)} + hoverTarget="cell" + tooltip={({ cell }: { cell: HeatMapCell }) => ( + + + {cell.serieId} × {cell.data.x} + + + {cell.serieId === cell.data.x ? 'Same solution' : `${cell.value} shared components`} + + + )} + theme={{ + text: { + fill: theme.palette.text.primary + }, + tooltip: { + container: { + background: theme.palette.background.paper, + color: theme.palette.text.primary + } + } + }} + /> + @@ -248,27 +311,24 @@ const InsightsSolutionView = ({ }: InsightsSolutionViewProps) => { {selectedSolution ? ( - - {selectedSolution.sourceSolution.name} -and- {selectedSolution.targetSolution.name} - - {selectedSolution.sharedComponents.length > 0 ? ( + {selectedSolution.Components.length > 0 ? ( - Shared Components: ({selectedSolution.sharedComponents.length}) + Shared Components: ({selectedSolution.Components.length})
    - {selectedSolution.sharedComponents.map(component => ( + {selectedSolution.Components.map(component => (
  • {component.Name} ({ component.ComponentType === SolutionComponentTypeEnum.Entity ? 'Entity' : component.ComponentType === SolutionComponentTypeEnum.Attribute - ? 'Attribute' - : component.ComponentType === SolutionComponentTypeEnum.Relationship - ? 'Relationship' - : 'Unknown' + ? 'Attribute' + : component.ComponentType === SolutionComponentTypeEnum.Relationship + ? 'Relationship' + : 'Unknown' })
  • @@ -284,7 +344,7 @@ const InsightsSolutionView = ({ }: InsightsSolutionViewProps) => { ) : ( - Select a connection in the chord diagram to see details about shared components between solutions. + Select a cell in the matrix to see details about shared components between solutions. )} diff --git a/Website/components/processesview/ProcessesView.tsx b/Website/components/processesview/ProcessesView.tsx index 6a85552..93ac534 100644 --- a/Website/components/processesview/ProcessesView.tsx +++ b/Website/components/processesview/ProcessesView.tsx @@ -15,9 +15,9 @@ import { useSearchParams } from 'next/navigation' interface IProcessesViewProps { } interface AttributeSearchResult { - attribute: AttributeType - entity: EntityType - group: string + attribute: AttributeType + entity: EntityType + group: string } export const ProcessesView = ({ }: IProcessesViewProps) => { @@ -44,8 +44,8 @@ export const ProcessesView = ({ }: IProcessesViewProps) => { })) ) ); - const foundAttribute = d.find(result => - result.attribute.SchemaName === initialAttribute + const foundAttribute = d.find(result => + result.attribute.SchemaName === initialAttribute && result.entity.SchemaName === initialEntity); if (foundAttribute) { setSelectedAttribute(foundAttribute); @@ -64,7 +64,7 @@ export const ProcessesView = ({ }: IProcessesViewProps) => { }); }); return acc; - }, { } as Record); + }, {} as Record); }, [groups]) const chartData = useMemo(() => { @@ -76,10 +76,10 @@ export const ProcessesView = ({ }: IProcessesViewProps) => { if (acc[componentTypeName]) { acc[componentTypeName].value += 1; } else { - acc[componentTypeName] = { + acc[componentTypeName] = { id: componentTypeName, - label: componentTypeName, - value: 1 + label: componentTypeName, + value: 1 }; } return acc; @@ -102,7 +102,7 @@ export const ProcessesView = ({ }: IProcessesViewProps) => { group.Entities.forEach(entity => { entity.Attributes.forEach(attribute => { if (attribute.AttributeUsages.length === 0) return; // Only search attributes with usages - const basicMatch = + const basicMatch = attribute.DisplayName.toLowerCase().includes(query) || attribute.SchemaName.toLowerCase().includes(query) || (attribute.Description && attribute.Description.toLowerCase().includes(query)) @@ -110,16 +110,16 @@ export const ProcessesView = ({ }: IProcessesViewProps) => { // Check options for ChoiceAttribute and StatusAttribute let optionsMatch = false if (attribute.AttributeType === 'ChoiceAttribute' || attribute.AttributeType === 'StatusAttribute') { - optionsMatch = attribute.Options.some(option => - option.Name.toLowerCase().includes(query) + optionsMatch = attribute.Options.some(option => + option.Name.toLowerCase().includes(query) ) } if (basicMatch || optionsMatch) { results.push({ - attribute, - entity, - group: group.Name + attribute, + entity, + group: group.Name }) } }) @@ -149,16 +149,16 @@ export const ProcessesView = ({ }: IProcessesViewProps) => { const getAttributeTypeLabel = (attributeType: string) => { switch (attributeType) { - case 'ChoiceAttribute': return 'Choice' - case 'DateTimeAttribute': return 'Date Time' - case 'LookupAttribute': return 'Lookup' - case 'StringAttribute': return 'Text' - case 'IntegerAttribute': return 'Number' - case 'DecimalAttribute': return 'Decimal' - case 'BooleanAttribute': return 'Yes/No' - case 'StatusAttribute': return 'Status' - case 'FileAttribute': return 'File' - default: return attributeType.replace('Attribute', '') + case 'ChoiceAttribute': return 'Choice' + case 'DateTimeAttribute': return 'Date Time' + case 'LookupAttribute': return 'Lookup' + case 'StringAttribute': return 'Text' + case 'IntegerAttribute': return 'Number' + case 'DecimalAttribute': return 'Decimal' + case 'BooleanAttribute': return 'Yes/No' + case 'StatusAttribute': return 'Status' + case 'FileAttribute': return 'File' + default: return attributeType.replace('Attribute', '') } } @@ -170,6 +170,10 @@ export const ProcessesView = ({ }: IProcessesViewProps) => { return } />; case ComponentType.WebResource: return } />; + case ComponentType.ClassicWorkflow: + return } />; + case ComponentType.BusinessRule: + return } />; } } @@ -189,10 +193,10 @@ export const ProcessesView = ({ }: IProcessesViewProps) => { { { + + + + + + {/* Search Bar */} - setSearchTerm(e.target.value)} - slotProps={{ - input: { - startAdornment: ( - - - - ), - } - }} - sx={{ - '& .MuiOutlinedInput-root': { - backgroundColor: 'background.paper', - borderRadius: '8px', - '& fieldset': { - borderColor: 'divider', - }, - '&:hover fieldset': { - borderColor: 'primary.main', - }, - '&.Mui-focused fieldset': { - borderColor: 'primary.main', - }, - }, - '& .MuiInputBase-input': { - fontSize: '1.1rem', - padding: '14px 16px', - }, - }} - /> + setSearchTerm(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + } + }} + sx={{ + '& .MuiOutlinedInput-root': { + backgroundColor: 'background.paper', + borderRadius: '8px', + '& fieldset': { + borderColor: 'divider', + }, + '&:hover fieldset': { + borderColor: 'primary.main', + }, + '&.Mui-focused fieldset': { + borderColor: 'primary.main', + }, + }, + '& .MuiInputBase-input': { + fontSize: '1.1rem', + padding: '14px 16px', + }, + }} + /> {/* Search Results */} {searchTerm.trim() && searchTerm.length >= 2 && !isSearching && ( - - - Attribute Search Results ({searchResults.length}) - - - {searchResults.length > 0 ? ( - - - {searchResults.map((result, index) => ( - - handleAttributeSelect(result)} - selected={selectedAttribute?.attribute.SchemaName === result.attribute.SchemaName && - selectedAttribute?.entity.SchemaName === result.entity.SchemaName} + + + Attribute Search Results ({searchResults.length}) + + + {searchResults.length > 0 ? ( + - - - {result.attribute.DisplayName} - - - {result.attribute.IsCustomAttribute && ( - - )} - - } - secondary={ - - - {result.entity.DisplayName} • {result.group} - - - {result.attribute.SchemaName} - - - } - /> - - - ))} - - - ) : ( - - No attributes found matching "{searchTerm}" - - )} - + + {searchResults.map((result, index) => ( + + handleAttributeSelect(result)} + selected={selectedAttribute?.attribute.SchemaName === result.attribute.SchemaName && + selectedAttribute?.entity.SchemaName === result.entity.SchemaName} + > + + + {result.attribute.DisplayName} + + + {result.attribute.IsCustomAttribute && ( + + )} + + } + secondary={ + + + {result.entity.DisplayName} • {result.group} + + + {result.attribute.SchemaName} + + + } + /> + + + ))} + + + ) : ( + + No attributes found matching "{searchTerm}" + + )} + )} {searchTerm.trim() && searchTerm.length < 2 && ( - - Enter at least 2 characters to search attributes - + + Enter at least 2 characters to search attributes + )} {/* GRID WITH SELECTED ATTRIBUTE */} {selectedAttribute && ( - - - setSelectedAttribute(null)}>} - className='flex flex-col items-center justify-center h-full w-full' - > - - - Selected Attribute - - - [{selectedAttribute.group} ({selectedAttribute.entity.DisplayName})]: {selectedAttribute.attribute.DisplayName} - - - - {chartData.length > 0 ? ( - + + setSelectedAttribute(null)}>} + className='flex flex-col items-center justify-center h-full w-full' + > + + + Selected Attribute + + + [{selectedAttribute.group} ({selectedAttribute.entity.DisplayName})]: {selectedAttribute.attribute.DisplayName} + + + + {chartData.length > 0 ? ( + - ) : ( - - No usage data available - - )} - - - - - - - Processes - - - {selectedAttribute?.attribute.AttributeUsages.length === 0 ? ( - - No process usage data available for this attribute - - ) : ( - - + ) : ( + + No usage data available + + )} + + + + + + + Processes + + + {selectedAttribute?.attribute.AttributeUsages.length === 0 ? ( + + No process usage data available for this attribute + + ) : ( + - - - - Process - - - Name - - - Type - - - Usage - - - - - {selectedAttribute?.attribute.AttributeUsages.map((usage, idx) => ( - - - {getProcessChip(usage.ComponentType)} +
    + + + + Process - - {usage.Name} + + Name - - {OperationType[usage.OperationType]} + + Type - - {usage.Usage} + + Usage - ))} - -
    -
    - )} -
    -
    -
    -
    )} + + + {selectedAttribute?.attribute.AttributeUsages.map((usage, idx) => ( + + + {getProcessChip(usage.ComponentType)} + + + {usage.Name} + + + {OperationType[usage.OperationType]} + + + {usage.Usage} + + + ))} + + + + )} + + +
    + )} {/* Warnings */} - { {warnings.filter(warning => warning.Type === WarningType.Attribute).length > 0 ? ( {warnings.filter(warning => warning.Type === WarningType.Attribute).map((warning, index) => ( - {warning.Message} diff --git a/Website/components/shared/Header.tsx b/Website/components/shared/Header.tsx index c72417c..e88635c 100644 --- a/Website/components/shared/Header.tsx +++ b/Website/components/shared/Header.tsx @@ -2,7 +2,7 @@ import { useLoading } from '@/hooks/useLoading'; import { useAuth } from '@/contexts/AuthContext'; import { useSettings } from '@/contexts/SettingsContext'; import { useRouter, usePathname } from 'next/navigation'; -import { AppBar, Toolbar, Box, LinearProgress, Button, Stack } from '@mui/material'; +import { AppBar, Toolbar, Box, LinearProgress, Button, Stack, Tooltip } from '@mui/material'; import SettingsPane from './elements/SettingsPane'; import { useIsMobile } from '@/hooks/use-mobile'; import { useSidebar } from '@/contexts/SidebarContext'; @@ -72,49 +72,57 @@ const Header = ({ }: HeaderProps) => { /> )} {isMobile && !sidebarOpen && isAuthenticated && ( - + + + )} {/* Right side - navigation buttons */} - + + + {isAuthenticated && ( <> - - + + + + + + )} diff --git a/Website/components/shared/Sidebar.tsx b/Website/components/shared/Sidebar.tsx index ee2026f..c65e1f7 100644 --- a/Website/components/shared/Sidebar.tsx +++ b/Website/components/shared/Sidebar.tsx @@ -41,12 +41,14 @@ const Sidebar = ({ }: SidebarProps) => { href: '/insights', icon: InsightsIcon, active: pathname === '/insights', + new: true, }, { label: 'Metadata', href: '/metadata', icon: MetadataIcon, active: pathname === '/metadata', + new: true, }, { label: 'Diagram', @@ -54,13 +56,13 @@ const Sidebar = ({ }: SidebarProps) => { icon: DiagramIcon, active: pathname === '/diagram', disabled: isMobile, - new: true, }, { label: 'Processes', href: '/processes', icon: ProcessesIcon, active: pathname === '/processes', + new: true, } ]; diff --git a/Website/components/shared/elements/StatCard.tsx b/Website/components/shared/elements/StatCard.tsx index 6a65292..e498b16 100644 --- a/Website/components/shared/elements/StatCard.tsx +++ b/Website/components/shared/elements/StatCard.tsx @@ -6,20 +6,20 @@ interface IStatCardProps { title: string value: number | string highlightedWord: string - tooltipTitle: string - tooltipWarning: string + tooltipTitle?: string + tooltipWarning?: string imageSrc: string imageAlt?: string } -export const StatCard = ({ - title, - value, - highlightedWord, - tooltipTitle, - tooltipWarning, - imageSrc, - imageAlt = "Icon" +export const StatCard = ({ + title, + value, + highlightedWord, + tooltipTitle, + tooltipWarning, + imageSrc, + imageAlt = "Icon" }: IStatCardProps) => { const [animatedValue, setAnimatedValue] = useState(0) const targetValue = typeof value === 'number' ? value : parseInt(value.toString()) || 0 @@ -30,7 +30,7 @@ export const StatCard = ({ return } - const duration = 1000 + const duration = 1000 const steps = 60 const increment = targetValue / steps const stepDuration = duration / steps @@ -50,25 +50,41 @@ export const StatCard = ({ return () => clearInterval(timer) }, [targetValue]) + const fullTitle = `${highlightedWord} ${title}`; + return ( - - {highlightedWord} {title} - + + + {highlightedWord} {title} + + {animatedValue} - - - - - See {tooltipWarning} - - - + {(tooltipTitle && tooltipWarning) ? ( + + + + + See {tooltipWarning} + + + ) : } - + diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx index 1ea1ed8..12e1469 100644 --- a/Website/contexts/DatamodelDataContext.tsx +++ b/Website/contexts/DatamodelDataContext.tsx @@ -1,7 +1,7 @@ 'use client' import React, { createContext, useContext, useReducer, ReactNode } from "react"; -import { AttributeType, EntityType, GroupType, SolutionType, SolutionWarningType } from "@/lib/Types"; +import { AttributeType, EntityType, GroupType, SolutionWarningType } from "@/lib/Types"; import { useSearchParams } from "next/navigation"; interface DataModelAction { @@ -12,7 +12,7 @@ interface DatamodelDataState extends DataModelAction { groups: GroupType[]; entityMap?: Map; warnings: SolutionWarningType[]; - solutions: SolutionType[]; + solutionCount: number; search: string; filtered: Array< | { type: 'group'; group: GroupType } @@ -24,7 +24,7 @@ interface DatamodelDataState extends DataModelAction { const initialState: DatamodelDataState = { groups: [], warnings: [], - solutions: [], + solutionCount: 0, search: "", filtered: [], @@ -42,12 +42,12 @@ const datamodelDataReducer = (state: DatamodelDataState, action: any): Datamodel return { ...state, entityMap: action.payload }; case "SET_WARNINGS": return { ...state, warnings: action.payload }; + case "SET_SOLUTION_COUNT": + return { ...state, solutionCount: action.payload }; case "SET_SEARCH": return { ...state, search: action.payload }; case "SET_FILTERED": return { ...state, filtered: action.payload }; - case "SET_SOLUTIONS": - return { ...state, solutions: action.payload }; case "APPEND_FILTERED": return { ...state, filtered: [...state.filtered, ...action.payload] }; default: @@ -70,7 +70,7 @@ export const DatamodelDataProvider = ({ children }: { children: ReactNode }) => dispatch({ type: "SET_GROUPS", payload: e.data.groups || [] }); dispatch({ type: "SET_ENTITIES", payload: e.data.entityMap || new Map() }); dispatch({ type: "SET_WARNINGS", payload: e.data.warnings || [] }); - dispatch({ type: "SET_SOLUTIONS", payload: e.data.solutions || [] }); + dispatch({ type: "SET_SOLUTION_COUNT", payload: e.data.solutionCount || 0 }); worker.terminate(); }; worker.postMessage({}); diff --git a/Website/lib/Types.ts b/Website/lib/Types.ts index 99043f2..f6a2eb8 100644 --- a/Website/lib/Types.ts +++ b/Website/lib/Types.ts @@ -13,22 +13,6 @@ export type GroupType = { Entities: EntityType[] } -export type SolutionType = { - Name: string, - PublisherName: string, - PublisherPrefix: string, - Components: SolutionComponentType[] -} - -export type SolutionComponentType = { - Name: string, - SchemaName: string, - Description: string | null, - ComponentType: SolutionComponentTypeEnum, - PublisherName: string, - PublisherPrefix: string, -} - export const enum OwnershipType { None = 0, UserOwned = 1, @@ -38,6 +22,11 @@ export const enum OwnershipType { BusinessParented = 16 } +export type SolutionInfoType = { + Id: string, + Name: string, +} + export type EntityType = { DisplayName: string, SchemaName: string, @@ -54,6 +43,10 @@ export type EntityType = { Keys: Key[], IconBase64: string | null, visibleAttributeSchemaNames?: string[], + PublisherName?: string, + PublisherPrefix?: string, + Solutions: SolutionInfoType[], + } export const enum SolutionComponentTypeEnum { @@ -74,17 +67,17 @@ export const enum CalculationMethods { Rollup = 1, } -export enum ComponentType -{ +export enum ComponentType { PowerAutomateFlow, Plugin, WebResource, WorkflowActivity, - CustomApi + CustomApi, + BusinessRule, + ClassicWorkflow } -export enum OperationType -{ +export enum OperationType { Create, Read, Update, @@ -97,7 +90,8 @@ export type UsageType = { Name: string, ComponentType: ComponentType, Usage: string, - OperationType: OperationType + OperationType: OperationType, + IsFromDependencyAnalysis: boolean } export type BaseAttribute = { @@ -106,13 +100,17 @@ export type BaseAttribute = { IsPrimaryName: boolean; IsCustomAttribute: boolean; IsStandardFieldModified: boolean; - DisplayName: string, - SchemaName: string, - Description: string | null, - RequiredLevel: RequiredLevel, - IsAuditEnabled: boolean, - IsColumnSecured: boolean, - CalculationMethod: CalculationMethods | null, + DisplayName: string; + SchemaName: string; + Description: string | null; + RequiredLevel: RequiredLevel; + IsAuditEnabled: boolean; + IsColumnSecured: boolean; + CalculationMethod: CalculationMethods | null; + IsExplicit: boolean; + PublisherName?: string, + PublisherPrefix?: string, + Solutions: SolutionInfoType[], } export type ChoiceAttributeType = BaseAttribute & { @@ -229,8 +227,12 @@ export type RelationshipType = { TableSchema: string, LookupDisplayName: string, RelationshipSchema: string, - IsManyToMany: boolean, - CascadeConfiguration: CascadeConfigurationType | null + IsExplicit: boolean, + RelationshipType: "N:N" | "1:N" | "N:1", + CascadeConfiguration: CascadeConfigurationType | null, + PublisherName?: string, + PublisherPrefix?: string, + Solutions: SolutionInfoType[], } export enum PrivilegeDepth { diff --git a/Website/lib/diagram/models/relationship-information.ts b/Website/lib/diagram/models/relationship-information.ts index 36d63dd..70a0bf2 100644 --- a/Website/lib/diagram/models/relationship-information.ts +++ b/Website/lib/diagram/models/relationship-information.ts @@ -3,8 +3,7 @@ export type RelationshipInformation = { sourceEntityDisplayName: string, targetEntitySchemaName: string, targetEntityDisplayName: string, - RelationshipType: '1-M' | 'M-1' | 'M-M' | 'SELF', RelationshipSchemaName: string, - IsManyToMany: boolean, + RelationshipType: "N:N" | "1:N" | "N:1" | "SELF", isIncluded?: boolean, } \ No newline at end of file diff --git a/Website/lib/diagram/relationship-helpers.ts b/Website/lib/diagram/relationship-helpers.ts index ea1230b..424de6c 100644 --- a/Website/lib/diagram/relationship-helpers.ts +++ b/Website/lib/diagram/relationship-helpers.ts @@ -17,7 +17,7 @@ export const getAllRelationshipsBetween = (sourceEntity: EntityType, targetEntit // Helper to add relationship if not duplicate const addRelationship = (rel: RelationshipInformation) => { // For M-M relationships, use schema to detect duplicates - if (rel.IsManyToMany && rel.RelationshipSchemaName) { + if (rel.RelationshipType === 'N:N' && rel.RelationshipSchemaName) { if (seenSchemas.has(rel.RelationshipSchemaName)) { return; // Skip duplicate M-M relationship } @@ -32,15 +32,13 @@ export const getAllRelationshipsBetween = (sourceEntity: EntityType, targetEntit if (sourceEntity.Relationships) { sourceEntity.Relationships.forEach(rel => { if (rel.TableSchema?.toLowerCase() === targetEntity.SchemaName.toLowerCase()) { - const direction = rel.IsManyToMany ? 'M-M' : (isSelfReferencing ? 'SELF' : '1-M'); addRelationship({ sourceEntitySchemaName: sourceEntity.SchemaName, sourceEntityDisplayName: sourceEntity.DisplayName, targetEntitySchemaName: targetEntity.SchemaName, targetEntityDisplayName: targetEntity.DisplayName, - RelationshipType: direction, + RelationshipType: isSelfReferencing ? "SELF" : rel.RelationshipType, RelationshipSchemaName: rel.RelationshipSchema, - IsManyToMany: rel.IsManyToMany, }); } }); @@ -54,15 +52,13 @@ export const getAllRelationshipsBetween = (sourceEntity: EntityType, targetEntit // Normalize to source -> target perspective // Target pointing to source means: target (many) -> source (one) // From source perspective: source (one) <- target (many) = M-1 - const direction = rel.IsManyToMany ? 'M-M' : 'M-1'; addRelationship({ sourceEntitySchemaName: sourceEntity.SchemaName, sourceEntityDisplayName: sourceEntity.DisplayName, targetEntitySchemaName: targetEntity.SchemaName, targetEntityDisplayName: targetEntity.DisplayName, - RelationshipType: direction, + RelationshipType: rel.RelationshipType, RelationshipSchemaName: rel.RelationshipSchema, - IsManyToMany: rel.IsManyToMany, }); } }); diff --git a/Website/package-lock.json b/Website/package-lock.json index 6ea16f3..5c3fbc1 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -14,8 +14,8 @@ "@mui/material": "^7.3.2", "@mui/material-nextjs": "^7.3.2", "@nivo/bar": "^0.99.0", - "@nivo/chord": "^0.99.0", "@nivo/core": "^0.99.0", + "@nivo/heatmap": "^0.99.0", "@nivo/pie": "^0.99.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", @@ -1725,30 +1725,6 @@ "integrity": "sha512-UxA8zb+NPwqmNm81hoyUZSMAikgjU1ukLf4KybVNyV8ejcJM+BUFXsb8DxTcLdt4nmCFHqM56GaJQv2hnAHmzg==", "license": "MIT" }, - "node_modules/@nivo/chord": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/chord/-/chord-0.99.0.tgz", - "integrity": "sha512-7LxXIAil4zqUCJ0AaUObhfIU32ABSW+AZiImN/bulMNyAo79ky4o7TMS38u/W6o+tfBAoFkouY1VgGmVJLpc3A==", - "license": "MIT", - "dependencies": { - "@nivo/arcs": "0.99.0", - "@nivo/colors": "0.99.0", - "@nivo/core": "0.99.0", - "@nivo/legends": "0.99.0", - "@nivo/text": "0.99.0", - "@nivo/theming": "0.99.0", - "@nivo/tooltip": "0.99.0", - "@react-spring/core": "9.4.5 || ^9.7.2 || ^10.0", - "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", - "@types/d3-chord": "^3.0.1", - "@types/d3-shape": "^3.1.6", - "d3-chord": "^1.0.6", - "d3-shape": "^3.2.0" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, "node_modules/@nivo/colors": { "version": "0.99.0", "resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.99.0.tgz", @@ -1798,6 +1774,30 @@ "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, + "node_modules/@nivo/heatmap": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/heatmap/-/heatmap-0.99.0.tgz", + "integrity": "sha512-cnd5V6XYLvHtQ8GObUyYyEgmAUa5/ERYVmNXR1znA+yzmB+tGthJsOeGar+ntCb43pIL5HbhEaD3YpsYzBxOhQ==", + "license": "MIT", + "dependencies": { + "@nivo/annotations": "0.99.0", + "@nivo/axes": "0.99.0", + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/legends": "0.99.0", + "@nivo/scales": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/core": "9.4.5 || ^9.7.2 || ^10.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-scale": "^4.0.8", + "d3-scale": "^4.0.2" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, "node_modules/@nivo/legends": { "version": "0.99.0", "resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.99.0.tgz", @@ -2344,12 +2344,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@types/d3-chord": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", - "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", - "license": "MIT" - }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -3457,28 +3451,6 @@ "node": ">=12" } }, - "node_modules/d3-chord": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-1.0.6.tgz", - "integrity": "sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1", - "d3-path": "1" - } - }, - "node_modules/d3-chord/node_modules/d3-array": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", - "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-chord/node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" - }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", diff --git a/Website/package.json b/Website/package.json index 1b12159..b134141 100644 --- a/Website/package.json +++ b/Website/package.json @@ -17,9 +17,9 @@ "@mui/material": "^7.3.2", "@mui/material-nextjs": "^7.3.2", "@nivo/bar": "^0.99.0", - "@nivo/chord": "^0.99.0", "@nivo/core": "^0.99.0", "@nivo/pie": "^0.99.0", + "@nivo/heatmap": "^0.99.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "class-variance-authority": "^0.7.1", @@ -48,4 +48,4 @@ "eslint-config-next": "15.0.3", "typescript": "^5" } -} +} \ No newline at end of file diff --git a/Website/public/businessrule.svg b/Website/public/businessrule.svg new file mode 100644 index 0000000..8fe35bc --- /dev/null +++ b/Website/public/businessrule.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/Website/public/classicalworkflow.svg b/Website/public/classicalworkflow.svg new file mode 100644 index 0000000..cd0ad16 --- /dev/null +++ b/Website/public/classicalworkflow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Website/stubs/Data.ts b/Website/stubs/Data.ts index e75fbbc..367957e 100644 --- a/Website/stubs/Data.ts +++ b/Website/stubs/Data.ts @@ -1,10 +1,11 @@ /// Used in github workflow to generate stubs for data /// This file is a stub and should not be modified directly. -import { GroupType, SolutionType, SolutionWarningType } from "@/lib/Types"; +import { GroupType, SolutionWarningType } from "@/lib/Types"; export const LastSynched: Date = new Date(); export const Logo: string | null = null; +export const SolutionCount: number = 0; export let Groups: GroupType[] = [ { @@ -34,9 +35,11 @@ export let Groups: GroupType[] = [ "IsAuditEnabled": false, "IsColumnSecured": false, "CalculationMethod": null, + "IsExplicit": true, "Format": "Text", "MaxLength": 100, - "AttributeUsages": [] + "AttributeUsages": [], + "Solutions": [], }, { "AttributeType": "StringAttribute", @@ -52,8 +55,10 @@ export let Groups: GroupType[] = [ "IsColumnSecured": false, "CalculationMethod": null, "Format": "Text", + "IsExplicit": true, "MaxLength": 160, - "AttributeUsages": [] + "AttributeUsages": [], + "Solutions": [], }, { "AttributeType": "StringAttribute", @@ -65,12 +70,14 @@ export let Groups: GroupType[] = [ "SchemaName": "telephone1", "Description": "The main phone number for the account", "RequiredLevel": 0, + "IsExplicit": false, "IsAuditEnabled": false, "IsColumnSecured": false, "CalculationMethod": null, "Format": "Phone", "MaxLength": 50, - "AttributeUsages": [] + "AttributeUsages": [], + "Solutions": [], }, { "AttributeType": "LookupAttribute", @@ -82,10 +89,12 @@ export let Groups: GroupType[] = [ "SchemaName": "primarycontactid", "Description": "The primary contact for the account", "RequiredLevel": 0, + "IsExplicit": false, "IsAuditEnabled": true, "IsColumnSecured": false, "CalculationMethod": null, "AttributeUsages": [], + "Solutions": [], "Targets": [ { "Name": "Contact", @@ -97,129 +106,11 @@ export let Groups: GroupType[] = [ "Relationships": [], "SecurityRoles": [], "Keys": [], - "IconBase64": null + "IconBase64": null, + "Solutions": [], } ] } ]; -export let SolutionWarnings: SolutionWarningType[] = []; - -export let Solutions: SolutionType[] = [ - { - Name: "Sample Solution", - PublisherName: "Contoso", - PublisherPrefix: "contoso", - Components: [ - { - Name: "Sample Entity", - SchemaName: "contoso_entity", - Description: "A sample entity for demonstration purposes.", - ComponentType: 1, - PublisherName: "Contoso", - PublisherPrefix: "contoso" - }, - { - Name: "Sample Attribute", - SchemaName: "contoso_attribute", - Description: "A sample attribute for demonstration purposes.", - ComponentType: 2, - PublisherName: "Contoso", - PublisherPrefix: "contoso" - } - ] - }, - { - Name: "Microsoft Solution", - PublisherName: "Microsoft", - PublisherPrefix: "msft", - Components: [ - { - Name: "Account Entity", - SchemaName: "account", - Description: "Standard account entity.", - ComponentType: 1, - PublisherName: "Microsoft", - PublisherPrefix: "" - }, - { - Name: "Contact Entity", - SchemaName: "contact", - Description: "Standard contact entity.", - ComponentType: 1, - PublisherName: "Microsoft", - PublisherPrefix: "" - }, - { - Name: "Lead Entity", - SchemaName: "lead", - Description: "Standard lead entity.", - ComponentType: 1, - PublisherName: "Microsoft", - PublisherPrefix: "" - }, - { - Name: "Opportunity Entity", - SchemaName: "opportunity", - Description: "Standard opportunity entity.", - ComponentType: 1, - PublisherName: "Microsoft", - PublisherPrefix: "" - }, - { - Name: "Email Relationship", - SchemaName: "email_account", - Description: "Email to account relationship.", - ComponentType: 3, - PublisherName: "Microsoft", - PublisherPrefix: "" - } - ] - }, - { - Name: "Fabrikam Solution", - PublisherName: "Fabrikam", - PublisherPrefix: "fab", - Components: [ - { - Name: "Custom Project Entity", - SchemaName: "fab_project", - Description: "Custom project tracking entity.", - ComponentType: 1, - PublisherName: "Fabrikam", - PublisherPrefix: "fab" - }, - { - Name: "Custom Task Entity", - SchemaName: "fab_task", - Description: "Custom task entity.", - ComponentType: 1, - PublisherName: "Fabrikam", - PublisherPrefix: "fab" - }, - { - Name: "Custom Attribute", - SchemaName: "fab_priority", - Description: "Priority attribute.", - ComponentType: 2, - PublisherName: "Fabrikam", - PublisherPrefix: "fab" - } - ] - }, - { - Name: "AdventureWorks Solution", - PublisherName: "AdventureWorks", - PublisherPrefix: "adv", - Components: [ - { - Name: "Product Entity", - SchemaName: "adv_product", - Description: "Product catalog entity.", - ComponentType: 1, - PublisherName: "AdventureWorks", - PublisherPrefix: "adv" - } - ] - } -]; \ No newline at end of file +export let SolutionWarnings: SolutionWarningType[] = []; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6156d24..cc0fc91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,9 +5,505 @@ "packages": { "": { "dependencies": { + "@nivo/heatmap": "^0.99.0", "semver": "^7.7.3" } }, + "node_modules/@nivo/annotations": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/annotations/-/annotations-0.99.0.tgz", + "integrity": "sha512-jCuuXPbvpaqaz4xF7k5dv0OT2ubn5Nt0gWryuTe/8oVsC/9bzSuK8bM9vBty60m9tfO+X8vUYliuaCDwGksC2g==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/axes": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/axes/-/axes-0.99.0.tgz", + "integrity": "sha512-3KschnmEL0acRoa7INSSOSEFwJLm54aZwSev7/r8XxXlkgRBriu6ReZy/FG0wfN+ljZ4GMvx+XyIIf6kxzvrZg==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/scales": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-format": "^1.4.1", + "@types/d3-time-format": "^2.3.1", + "d3-format": "^1.4.4", + "d3-time-format": "^3.0.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/colors": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.99.0.tgz", + "integrity": "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-color": "^3.0.0", + "@types/d3-scale": "^4.0.8", + "@types/d3-scale-chromatic": "^3.0.0", + "d3-color": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/core": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.99.0.tgz", + "integrity": "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g==", + "license": "MIT", + "dependencies": { + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-shape": "^3.1.6", + "d3-color": "^3.1.0", + "d3-format": "^1.4.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "d3-shape": "^3.2.0", + "d3-time-format": "^3.0.0", + "lodash": "^4.17.21", + "react-virtualized-auto-sizer": "^1.0.26", + "use-debounce": "^10.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nivo/donate" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/heatmap": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/heatmap/-/heatmap-0.99.0.tgz", + "integrity": "sha512-cnd5V6XYLvHtQ8GObUyYyEgmAUa5/ERYVmNXR1znA+yzmB+tGthJsOeGar+ntCb43pIL5HbhEaD3YpsYzBxOhQ==", + "license": "MIT", + "dependencies": { + "@nivo/annotations": "0.99.0", + "@nivo/axes": "0.99.0", + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/legends": "0.99.0", + "@nivo/scales": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/core": "9.4.5 || ^9.7.2 || ^10.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-scale": "^4.0.8", + "d3-scale": "^4.0.2" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/legends": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.99.0.tgz", + "integrity": "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-scale": "^4.0.8", + "d3-scale": "^4.0.2" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/scales": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/scales/-/scales-0.99.0.tgz", + "integrity": "sha512-g/2K4L6L8si6E2BWAHtFVGahtDKbUcO6xHJtlIZMwdzaJc7yB16EpWLK8AfI/A42KadLhJSJqBK3mty+c7YZ+w==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "^3.0.4", + "@types/d3-scale": "^4.0.8", + "@types/d3-time": "^1.1.1", + "@types/d3-time-format": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-time": "^1.0.11", + "d3-time-format": "^3.0.0", + "lodash": "^4.17.21" + } + }, + "node_modules/@nivo/scales/node_modules/@types/d3-time-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.4.tgz", + "integrity": "sha512-or9DiDnYI1h38J9hxKEsw513+KVuFbEVhl7qdxcaudoiqWWepapUen+2vAriFGexr6W5+P4l9+HJrB39GG+oRg==", + "license": "MIT" + }, + "node_modules/@nivo/text": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/text/-/text-0.99.0.tgz", + "integrity": "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/theming": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/theming/-/theming-0.99.0.tgz", + "integrity": "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/tooltip": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.99.0.tgz", + "integrity": "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@react-spring/animated": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz", + "integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz", + "integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.3.tgz", + "integrity": "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.3.tgz", + "integrity": "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.3.tgz", + "integrity": "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==", + "license": "MIT" + }, + "node_modules/@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-mLxrC1MSWupOSncXN/HOlWUAAIffAEBaI4+PKy2uMPsKe4FNZlk7qrbTjmzJXITQQqBHivaks4Td18azgqnotA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.4.tgz", + "integrity": "sha512-JIvy2HjRInE+TXOmIGN5LCmeO0hkFZx5f9FZ7kiN+D+YTcc8pptsiLiuHsvwxwC7VVKmJ2ExHUgNlAiV7vQM9g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.3.4.tgz", + "integrity": "sha512-xdDXbpVO74EvadI3UDxjxTdR6QIxm1FKzEA/+F8tL4GWWUg/hgvBqf6chql64U5A9ZUGWo7pEu4eNlyLwbKdhg==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale/node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-virtualized-auto-sizer": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", + "integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==", + "license": "MIT", + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -19,6 +515,18 @@ "engines": { "node": ">=10" } + }, + "node_modules/use-debounce": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.6.tgz", + "integrity": "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } } } } diff --git a/package.json b/package.json index ef45ab4..89125f3 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "@nivo/heatmap": "^0.99.0", "semver": "^7.7.3" } }