diff --git a/Generator/DTO/SolutionComponent.cs b/Generator/DTO/SolutionComponent.cs
index 1f5f279..e474f55 100644
--- a/Generator/DTO/SolutionComponent.cs
+++ b/Generator/DTO/SolutionComponent.cs
@@ -1,10 +1,49 @@
namespace Generator.DTO;
+///
+/// Solution component types from Dataverse.
+/// See: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/reference/entities/solutioncomponent
+///
public enum SolutionComponentType
{
Entity = 1,
Attribute = 2,
- Relationship = 3,
+ OptionSet = 9,
+ Relationship = 10,
+ EntityKey = 14,
+ SecurityRole = 20,
+ SavedQuery = 26,
+ Workflow = 29,
+ RibbonCustomization = 50,
+ SavedQueryVisualization = 59,
+ SystemForm = 60,
+ WebResource = 61,
+ SiteMap = 62,
+ ConnectionRole = 63,
+ HierarchyRule = 65,
+ CustomControl = 66,
+ FieldSecurityProfile = 70,
+ ModelDrivenApp = 80,
+ PluginAssembly = 91,
+ SDKMessageProcessingStep = 92,
+ CanvasApp = 300,
+ ConnectionReference = 372,
+ EnvironmentVariableDefinition = 380,
+ EnvironmentVariableValue = 381,
+ Dataflow = 418,
+ ConnectionRoleObjectTypeCode = 3233,
+ CustomAPI = 10240,
+ CustomAPIRequestParameter = 10241,
+ CustomAPIResponseProperty = 10242,
+ PluginPackage = 10639,
+ OrganizationSetting = 10563,
+ AppAction = 10645,
+ AppActionRule = 10948,
+ FxExpression = 11492,
+ DVFileSearch = 11723,
+ DVFileSearchAttribute = 11724,
+ DVFileSearchEntity = 11725,
+ AISkillConfig = 12075,
}
public record SolutionComponent(
@@ -14,3 +53,22 @@ public record SolutionComponent(
SolutionComponentType ComponentType,
string PublisherName,
string PublisherPrefix);
+
+///
+/// Represents a solution component with its solution membership info for the insights view.
+///
+public record SolutionComponentData(
+ string Name,
+ string SchemaName,
+ SolutionComponentType ComponentType,
+ Guid ObjectId,
+ bool IsExplicit,
+ string? RelatedTable = null);
+
+///
+/// Collection of solution components grouped by solution.
+///
+public record SolutionComponentCollection(
+ Guid SolutionId,
+ string SolutionName,
+ List Components);
diff --git a/Generator/DataverseService.cs b/Generator/DataverseService.cs
index fed21bd..f5b3f59 100644
--- a/Generator/DataverseService.cs
+++ b/Generator/DataverseService.cs
@@ -24,6 +24,7 @@ internal class DataverseService
private readonly EntityIconService entityIconService;
private readonly RecordMappingService recordMappingService;
private readonly SolutionComponentService solutionComponentService;
+ private readonly SolutionComponentExtractor solutionComponentExtractor;
private readonly WorkflowService workflowService;
private readonly RelationshipService relationshipService;
@@ -38,6 +39,7 @@ public DataverseService(
EntityIconService entityIconService,
RecordMappingService recordMappingService,
SolutionComponentService solutionComponentService,
+ SolutionComponentExtractor solutionComponentExtractor,
WorkflowService workflowService,
RelationshipService relationshipService)
{
@@ -49,6 +51,7 @@ public DataverseService(
this.recordMappingService = recordMappingService;
this.workflowService = workflowService;
this.relationshipService = relationshipService;
+ this.solutionComponentExtractor = solutionComponentExtractor;
// Register all analyzers with their query functions
analyzerRegistrations = new List
@@ -69,7 +72,7 @@ public DataverseService(
this.solutionComponentService = solutionComponentService;
}
- public async Task<(IEnumerable, IEnumerable)> GetFilteredMetadata()
+ public async Task<(IEnumerable, IEnumerable, IEnumerable)> GetFilteredMetadata()
{
// used to collect warnings for the insights dashboard
var warnings = new List();
@@ -275,8 +278,79 @@ public DataverseService(
})
.ToList();
- logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] GetFilteredMetadata completed - returning empty results");
- return (records, warnings);
+ /// SOLUTION COMPONENTS FOR INSIGHTS
+ List solutionComponentCollections;
+ try
+ {
+ logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Extracting solution components for insights view");
+
+ // Build name lookups from entity metadata for the extractor
+ var entityNameLookup = entitiesInSolutionMetadata.ToDictionary(
+ e => e.MetadataId!.Value,
+ e => e.DisplayName.ToLabelString() ?? e.SchemaName);
+
+ var attributeNameLookup = entitiesInSolutionMetadata
+ .SelectMany(e => e.Attributes.Where(a => a.MetadataId.HasValue))
+ .ToDictionary(
+ a => a.MetadataId!.Value,
+ a => a.DisplayName.ToLabelString() ?? a.SchemaName);
+
+ var relationshipNameLookup = entitiesInSolutionMetadata
+ .SelectMany(e => e.ManyToManyRelationships.Cast()
+ .Concat(e.OneToManyRelationships)
+ .Concat(e.ManyToOneRelationships))
+ .Where(r => r.MetadataId.HasValue)
+ .DistinctBy(r => r.MetadataId!.Value)
+ .ToDictionary(
+ r => r.MetadataId!.Value,
+ r => r.SchemaName);
+
+ // Build entity lookups for attributes, relationships, and keys (maps component ID to parent entity name)
+ var attributeEntityLookup = entitiesInSolutionMetadata
+ .SelectMany(e => e.Attributes.Where(a => a.MetadataId.HasValue)
+ .Select(a => (AttributeId: a.MetadataId!.Value, EntityName: e.DisplayName.ToLabelString() ?? e.LogicalName)))
+ .ToDictionary(x => x.AttributeId, x => x.EntityName);
+
+ var relationshipEntityLookup = entitiesInSolutionMetadata
+ .SelectMany(e => e.ManyToManyRelationships.Cast()
+ .Concat(e.OneToManyRelationships)
+ .Concat(e.ManyToOneRelationships)
+ .Where(r => r.MetadataId.HasValue)
+ .Select(r => (RelationshipId: r.MetadataId!.Value, EntityName: e.DisplayName.ToLabelString() ?? e.LogicalName)))
+ .DistinctBy(x => x.RelationshipId)
+ .ToDictionary(x => x.RelationshipId, x => x.EntityName);
+
+ var keyEntityLookup = entitiesInSolutionMetadata
+ .SelectMany(e => (e.Keys ?? Array.Empty())
+ .Where(k => k.MetadataId.HasValue)
+ .Select(k => (KeyId: k.MetadataId!.Value, EntityName: e.DisplayName.ToLabelString() ?? e.LogicalName)))
+ .ToDictionary(x => x.KeyId, x => x.EntityName);
+
+ // Build solution name lookup
+ var solutionNameLookup = solutionLookup.ToDictionary(
+ kvp => kvp.Key,
+ kvp => kvp.Value.Name);
+
+ solutionComponentCollections = await solutionComponentExtractor.ExtractSolutionComponentsAsync(
+ solutionIds,
+ solutionNameLookup,
+ entityNameLookup,
+ attributeNameLookup,
+ relationshipNameLookup,
+ attributeEntityLookup,
+ relationshipEntityLookup,
+ keyEntityLookup);
+
+ logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Extracted components for {solutionComponentCollections.Count} solutions");
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Failed to extract solution components for insights, continuing without them");
+ solutionComponentCollections = new List();
+ }
+
+ logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] GetFilteredMetadata completed");
+ return (records, warnings, solutionComponentCollections);
}
}
diff --git a/Generator/Program.cs b/Generator/Program.cs
index 1672cf7..03d5cb1 100644
--- a/Generator/Program.cs
+++ b/Generator/Program.cs
@@ -59,15 +59,16 @@
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 (entities, warnings, solutionComponents) = await dataverseService.GetFilteredMetadata();
-var websiteBuilder = new WebsiteBuilder(configuration, entities, warnings);
+var websiteBuilder = new WebsiteBuilder(configuration, entities, warnings, solutionComponents);
websiteBuilder.AddData();
// Token provider function
diff --git a/Generator/Services/EntityIconService.cs b/Generator/Services/EntityIconService.cs
index c5c04da..853c59a 100644
--- a/Generator/Services/EntityIconService.cs
+++ b/Generator/Services/EntityIconService.cs
@@ -27,20 +27,25 @@ public async Task> GetEntityIconMap(IEnumerable x.IconVectorName != null)
.ToDictionary(x => x.LogicalName, x => x.IconVectorName);
- var query = new QueryExpression("webresource")
+ var iconNameToSvg = new Dictionary();
+
+ if (logicalNameToIconName.Count > 0)
{
- ColumnSet = new ColumnSet("content", "name"),
- Criteria = new FilterExpression(LogicalOperator.And)
+ var query = new QueryExpression("webresource")
{
- Conditions =
+ ColumnSet = new ColumnSet("content", "name"),
+ Criteria = new FilterExpression(LogicalOperator.And)
{
- new ConditionExpression("name", ConditionOperator.In, logicalNameToIconName.Values.ToList())
+ 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 webresources = await client.RetrieveMultipleAsync(query);
+ iconNameToSvg = webresources.Entities.ToDictionary(x => x.GetAttributeValue("name"), x => x.GetAttributeValue("content"));
+ }
var logicalNameToSvg =
logicalNameToIconName
diff --git a/Generator/Services/SolutionComponentExtractor.cs b/Generator/Services/SolutionComponentExtractor.cs
new file mode 100644
index 0000000..5d4fc66
--- /dev/null
+++ b/Generator/Services/SolutionComponentExtractor.cs
@@ -0,0 +1,386 @@
+using Generator.DTO;
+using Microsoft.Extensions.Logging;
+using Microsoft.PowerPlatform.Dataverse.Client;
+using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Query;
+
+namespace Generator.Services;
+
+///
+/// Extracts all solution components for the insights visualization.
+/// This is separate from SolutionComponentService which filters entity metadata extraction.
+///
+public class SolutionComponentExtractor
+{
+ private readonly ServiceClient _client;
+ private readonly ILogger _logger;
+
+ ///
+ /// All component types we want to extract for insights.
+ ///
+ private static readonly int[] SupportedComponentTypes = new[]
+ {
+ 1, // Entity
+ 2, // Attribute
+ 9, // OptionSet
+ 10, // Relationship
+ 14, // EntityKey
+ 20, // SecurityRole
+ 26, // SavedQuery
+ 29, // Workflow
+ 50, // RibbonCustomization
+ 59, // SavedQueryVisualization
+ 60, // SystemForm
+ 61, // WebResource
+ 62, // SiteMap
+ 63, // ConnectionRole
+ 65, // HierarchyRule
+ 66, // CustomControl
+ 70, // FieldSecurityProfile
+ 80, // ModelDrivenApp
+ 91, // PluginAssembly
+ 92, // SDKMessageProcessingStep
+ 300, // CanvasApp
+ 372, // ConnectionReference
+ 380, // EnvironmentVariableDefinition
+ 381, // EnvironmentVariableValue
+ 418, // Dataflow
+ };
+
+ ///
+ /// Maps component type codes to their Dataverse table, name column, primary key column, and optional entity column for name resolution.
+ /// Primary key is optional - if null, defaults to tablename + "id".
+ /// EntityColumn is used to get the related table for components like forms and views.
+ ///
+ private static readonly Dictionary ComponentTableMap = new()
+ {
+ { 20, ("role", "name", null, null) },
+ { 26, ("savedquery", "name", null, "returnedtypecode") }, // Views have returnedtypecode for the entity
+ { 29, ("workflow", "name", null, null) },
+ { 50, ("ribboncustomization", "entity", null, null) },
+ { 59, ("savedqueryvisualization", "name", null, null) },
+ { 60, ("systemform", "name", "formid", "objecttypecode") }, // Forms have objecttypecode for the entity
+ { 61, ("webresource", "name", null, null) },
+ { 62, ("sitemap", "sitemapname", null, null) },
+ { 63, ("connectionrole", "name", null, null) },
+ { 65, ("hierarchyrule", "name", null, null) },
+ { 66, ("customcontrol", "name", null, null) },
+ { 70, ("fieldsecurityprofile", "name", null, null) },
+ { 80, ("appmodule", "name", "appmoduleid", null) }, // appmodule uses appmoduleid
+ { 91, ("pluginassembly", "name", null, null) },
+ { 92, ("sdkmessageprocessingstep", "name", null, null) },
+ { 300, ("canvasapp", "name", null, null) },
+ { 372, ("connectionreference", "connectionreferencedisplayname", null, null) },
+ { 380, ("environmentvariabledefinition", "displayname", null, null) },
+ { 381, ("environmentvariablevalue", "schemaname", null, null) },
+ { 418, ("workflow", "name", null, null) }, // Dataflows are stored in workflow table with category=6
+ };
+
+ ///
+ /// Component types that should have a related table displayed.
+ ///
+ private static readonly HashSet ComponentTypesWithRelatedTable = new() { 2, 10, 14, 26, 60 }; // Attribute, Relationship, EntityKey, SavedQuery (View), SystemForm
+
+ public SolutionComponentExtractor(ServiceClient client, ILogger logger)
+ {
+ _client = client;
+ _logger = logger;
+ }
+
+ ///
+ /// Extracts all solution components grouped by solution for the insights view.
+ ///
+ public async Task> ExtractSolutionComponentsAsync(
+ List solutionIds,
+ Dictionary solutionNameLookup,
+ Dictionary? entityNameLookup = null,
+ Dictionary? attributeNameLookup = null,
+ Dictionary? relationshipNameLookup = null,
+ Dictionary? attributeEntityLookup = null,
+ Dictionary? relationshipEntityLookup = null,
+ Dictionary? keyEntityLookup = null)
+ {
+ _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Extracting solution components for {solutionIds.Count} solutions");
+
+ if (solutionIds == null || !solutionIds.Any())
+ {
+ _logger.LogWarning("No solution IDs provided for component extraction");
+ return new List();
+ }
+
+ // Query all solution components
+ var rawComponents = await QuerySolutionComponentsAsync(solutionIds);
+ _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Found {rawComponents.Count} raw solution components");
+
+ // Group by solution
+ var componentsBySolution = rawComponents
+ .GroupBy(c => c.SolutionId)
+ .ToDictionary(g => g.Key, g => g.ToList());
+
+ // Resolve names for each component type
+ var nameCache = await BuildNameCacheAsync(rawComponents, entityNameLookup, attributeNameLookup, relationshipNameLookup, attributeEntityLookup, relationshipEntityLookup, keyEntityLookup);
+
+ // Build the result collections
+ var result = new List();
+ foreach (var solutionId in solutionIds)
+ {
+ var solutionName = solutionNameLookup.GetValueOrDefault(solutionId, solutionId.ToString());
+
+ if (!componentsBySolution.TryGetValue(solutionId, out var components))
+ {
+ result.Add(new SolutionComponentCollection(solutionId, solutionName, new List()));
+ continue;
+ }
+
+ var componentDataList = components
+ .Select(c => new SolutionComponentData(
+ Name: ResolveComponentName(c, nameCache),
+ SchemaName: ResolveComponentSchemaName(c, nameCache),
+ ComponentType: (SolutionComponentType)c.ComponentType,
+ ObjectId: c.ObjectId,
+ IsExplicit: c.IsExplicit,
+ RelatedTable: ResolveRelatedTable(c, nameCache)))
+ .OrderBy(c => c.ComponentType)
+ .ThenBy(c => c.Name)
+ .ToList();
+
+ result.Add(new SolutionComponentCollection(solutionId, solutionName, componentDataList));
+ _logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Solution '{solutionName}': {componentDataList.Count} components");
+ }
+
+ return result;
+ }
+
+ private async Task> QuerySolutionComponentsAsync(List solutionIds)
+ {
+ var results = new List();
+
+ var query = new QueryExpression("solutioncomponent")
+ {
+ ColumnSet = new ColumnSet("objectid", "componenttype", "solutionid", "rootcomponentbehavior"),
+ Criteria = new FilterExpression(LogicalOperator.And)
+ {
+ Conditions =
+ {
+ new ConditionExpression("componenttype", ConditionOperator.In, SupportedComponentTypes),
+ new ConditionExpression("solutionid", ConditionOperator.In, solutionIds)
+ }
+ }
+ };
+
+ try
+ {
+ var response = await _client.RetrieveMultipleAsync(query);
+ foreach (var entity in response.Entities)
+ {
+ var componentType = entity.GetAttributeValue("componenttype")?.Value ?? 0;
+ var objectId = entity.GetAttributeValue("objectid");
+ var solutionId = entity.GetAttributeValue("solutionid")?.Id ?? Guid.Empty;
+ var rootBehavior = entity.Contains("rootcomponentbehavior")
+ ? entity.GetAttributeValue("rootcomponentbehavior")?.Value ?? -1
+ : -1;
+
+ // RootComponentBehaviour: 0, 1, 2 = explicit, other = implicit
+ var isExplicit = rootBehavior >= 0 && rootBehavior <= 2;
+
+ results.Add(new RawComponentInfo(componentType, objectId, solutionId, isExplicit));
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to query solution components");
+ }
+
+ return results;
+ }
+
+ private async Task> BuildNameCacheAsync(
+ List components,
+ Dictionary? entityNameLookup,
+ Dictionary? attributeNameLookup,
+ Dictionary? relationshipNameLookup,
+ Dictionary? attributeEntityLookup,
+ Dictionary? relationshipEntityLookup,
+ Dictionary? keyEntityLookup)
+ {
+ var cache = new Dictionary<(int, Guid), (string Name, string SchemaName, string? RelatedTable)>();
+
+ // Group components by type for batch queries
+ var componentsByType = components
+ .GroupBy(c => c.ComponentType)
+ .ToDictionary(g => g.Key, g => g.Select(c => c.ObjectId).Distinct().ToList());
+
+ foreach (var (componentType, objectIds) in componentsByType)
+ {
+ // Use provided lookups for metadata-based types
+ if (componentType == 1 && entityNameLookup != null) // Entity
+ {
+ foreach (var objectId in objectIds)
+ {
+ if (entityNameLookup.TryGetValue(objectId, out var name))
+ {
+ cache[(componentType, objectId)] = (name, name, null);
+ }
+ }
+ continue;
+ }
+
+ if (componentType == 2 && attributeNameLookup != null) // Attribute
+ {
+ foreach (var objectId in objectIds)
+ {
+ if (attributeNameLookup.TryGetValue(objectId, out var name))
+ {
+ var relatedTable = attributeEntityLookup?.GetValueOrDefault(objectId);
+ cache[(componentType, objectId)] = (name, name, relatedTable);
+ }
+ }
+ continue;
+ }
+
+ if (componentType == 10 && relationshipNameLookup != null) // Relationship
+ {
+ foreach (var objectId in objectIds)
+ {
+ if (relationshipNameLookup.TryGetValue(objectId, out var name))
+ {
+ var relatedTable = relationshipEntityLookup?.GetValueOrDefault(objectId);
+ cache[(componentType, objectId)] = (name, name, relatedTable);
+ }
+ }
+ continue;
+ }
+
+ // EntityKey - use keyEntityLookup for related table
+ if (componentType == 14)
+ {
+ foreach (var objectId in objectIds)
+ {
+ var relatedTable = keyEntityLookup?.GetValueOrDefault(objectId);
+ cache[(componentType, objectId)] = (objectId.ToString(), objectId.ToString(), relatedTable);
+ }
+ continue;
+ }
+
+ // Skip OptionSet - use ObjectId as fallback, no related table
+ if (componentType == 9)
+ {
+ foreach (var objectId in objectIds)
+ {
+ cache[(componentType, objectId)] = (objectId.ToString(), objectId.ToString(), null);
+ }
+ continue;
+ }
+
+ // Query Dataverse tables for other types
+ if (ComponentTableMap.TryGetValue(componentType, out var tableInfo))
+ {
+ var primaryKey = tableInfo.PrimaryKey ?? tableInfo.TableName + "id";
+ var namesAndEntities = await QueryComponentNamesWithEntityAsync(tableInfo.TableName, tableInfo.NameColumn, primaryKey, tableInfo.EntityColumn, objectIds);
+ foreach (var (objectId, name, relatedTable) in namesAndEntities)
+ {
+ cache[(componentType, objectId)] = (name, name, relatedTable);
+ }
+ }
+ }
+
+ return cache;
+ }
+
+ private async Task> QueryComponentNamesWithEntityAsync(
+ string tableName, string nameColumn, string primaryKey, string? entityColumn, List objectIds)
+ {
+ var result = new List<(Guid, string, string?)>();
+
+ if (!objectIds.Any())
+ return result;
+
+ try
+ {
+ var columns = new List { primaryKey, nameColumn };
+ if (!string.IsNullOrEmpty(entityColumn))
+ {
+ columns.Add(entityColumn);
+ }
+
+ var query = new QueryExpression(tableName)
+ {
+ ColumnSet = new ColumnSet(columns.ToArray()),
+ Criteria = new FilterExpression(LogicalOperator.And)
+ {
+ Conditions =
+ {
+ new ConditionExpression(primaryKey, ConditionOperator.In, objectIds)
+ }
+ }
+ };
+
+ var response = await _client.RetrieveMultipleAsync(query);
+ foreach (var entity in response.Entities)
+ {
+ var id = entity.GetAttributeValue(primaryKey);
+ var name = entity.GetAttributeValue(nameColumn) ?? id.ToString();
+ string? relatedTable = null;
+
+ if (!string.IsNullOrEmpty(entityColumn) && entity.Contains(entityColumn))
+ {
+ // The entity column can be a string (logical name) or an int (object type code)
+ var entityValue = entity[entityColumn];
+ if (entityValue is string strValue)
+ {
+ relatedTable = strValue;
+ }
+ else if (entityValue is int intValue)
+ {
+ // Object type code - we'd need entity metadata to resolve this
+ // For now, just store the numeric value as string
+ relatedTable = intValue.ToString();
+ }
+ }
+
+ result.Add((id, name, relatedTable));
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, $"Failed to query names from {tableName}. Using ObjectId as fallback.");
+ // Return empty - fallback will use ObjectId
+ }
+
+ return result;
+ }
+
+ private string ResolveComponentName(RawComponentInfo component, Dictionary<(int, Guid), (string Name, string SchemaName, string? RelatedTable)> cache)
+ {
+ if (cache.TryGetValue((component.ComponentType, component.ObjectId), out var names))
+ {
+ return names.Name;
+ }
+ return component.ObjectId.ToString();
+ }
+
+ private string ResolveComponentSchemaName(RawComponentInfo component, Dictionary<(int, Guid), (string Name, string SchemaName, string? RelatedTable)> cache)
+ {
+ if (cache.TryGetValue((component.ComponentType, component.ObjectId), out var names))
+ {
+ return names.SchemaName;
+ }
+ return component.ObjectId.ToString();
+ }
+
+ private string? ResolveRelatedTable(RawComponentInfo component, Dictionary<(int, Guid), (string Name, string SchemaName, string? RelatedTable)> cache)
+ {
+ if (!ComponentTypesWithRelatedTable.Contains(component.ComponentType))
+ {
+ return null;
+ }
+
+ if (cache.TryGetValue((component.ComponentType, component.ObjectId), out var names))
+ {
+ return names.RelatedTable;
+ }
+ return null;
+ }
+
+ private record RawComponentInfo(int ComponentType, Guid ObjectId, Guid SolutionId, bool IsExplicit);
+}
diff --git a/Generator/WebsiteBuilder.cs b/Generator/WebsiteBuilder.cs
index 0661ebd..2b10158 100644
--- a/Generator/WebsiteBuilder.cs
+++ b/Generator/WebsiteBuilder.cs
@@ -11,14 +11,19 @@ internal class WebsiteBuilder
private readonly IConfiguration configuration;
private readonly IEnumerable records;
private readonly IEnumerable warnings;
- private readonly IEnumerable solutions;
+ private readonly IEnumerable solutionComponents;
private readonly string OutputFolder;
- public WebsiteBuilder(IConfiguration configuration, IEnumerable records, IEnumerable warnings)
+ public WebsiteBuilder(
+ IConfiguration configuration,
+ IEnumerable records,
+ IEnumerable warnings,
+ IEnumerable? solutionComponents = null)
{
this.configuration = configuration;
this.records = records;
this.warnings = warnings;
+ this.solutionComponents = solutionComponents ?? Enumerable.Empty();
// Assuming execution in bin/xxx/net8.0
OutputFolder = configuration["OutputFolder"] ?? Path.Combine(System.Reflection.Assembly.GetExecutingAssembly().Location, "../../../../../Website/generated");
@@ -27,7 +32,7 @@ public WebsiteBuilder(IConfiguration configuration, IEnumerable records,
internal void AddData()
{
var sb = new StringBuilder();
- sb.AppendLine("import { GroupType, SolutionWarningType } from \"@/lib/Types\";");
+ sb.AppendLine("import { GroupType, SolutionWarningType, SolutionComponentCollectionType } 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);
@@ -66,6 +71,15 @@ internal void AddData()
}
sb.AppendLine("]");
+ // SOLUTION COMPONENTS (for insights)
+ sb.AppendLine("");
+ sb.AppendLine("export let SolutionComponents: SolutionComponentCollectionType[] = [");
+ foreach (var collection in solutionComponents)
+ {
+ sb.AppendLine($" {JsonConvert.SerializeObject(collection)},");
+ }
+ sb.AppendLine("]");
+
File.WriteAllText(Path.Combine(OutputFolder, "Data.ts"), sb.ToString());
}
}
diff --git a/Website/components/datamodelview/dataLoaderWorker.ts b/Website/components/datamodelview/dataLoaderWorker.ts
index b4fc209..a36fbdf 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, SolutionCount } from '../../generated/Data';
+import { Groups, SolutionWarnings, SolutionCount, SolutionComponents } from '../../generated/Data';
self.onmessage = function () {
const entityMap = new Map();
@@ -8,5 +8,11 @@ self.onmessage = function () {
entityMap.set(entity.SchemaName, entity);
});
});
- self.postMessage({ groups: Groups, entityMap: entityMap, warnings: SolutionWarnings, solutionCount: SolutionCount });
+ self.postMessage({
+ groups: Groups,
+ entityMap: entityMap,
+ warnings: SolutionWarnings,
+ solutionCount: SolutionCount,
+ solutionComponents: SolutionComponents
+ });
};
\ No newline at end of file
diff --git a/Website/components/insightsview/solutions/InsightsSolutionView.tsx b/Website/components/insightsview/solutions/InsightsSolutionView.tsx
index f280215..95fff94 100644
--- a/Website/components/insightsview/solutions/InsightsSolutionView.tsx
+++ b/Website/components/insightsview/solutions/InsightsSolutionView.tsx
@@ -1,10 +1,12 @@
import { useDatamodelData } from '@/contexts/DatamodelDataContext'
-import { Paper, Typography, Box, Grid, useTheme, Tooltip, IconButton } from '@mui/material'
+import { Paper, Typography, Box, Grid, useTheme, Tooltip, IconButton, Button, Collapse, FormControlLabel, Checkbox } from '@mui/material'
import React, { useMemo, useState } from 'react'
import { ResponsiveHeatMap } from '@nivo/heatmap'
-import { SolutionComponentTypeEnum } from '@/lib/Types'
+import { SolutionComponentTypeEnum, SolutionComponentDataType, ComponentTypeCategories, ComponentTypeLabels } from '@/lib/Types'
import { generateEnvelopeSVG } from '@/lib/svgart'
import { InfoIcon } from '@/lib/icons'
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
+import ExpandLessIcon from '@mui/icons-material/ExpandLess'
interface InsightsSolutionViewProps {
@@ -19,72 +21,111 @@ interface HeatMapCell {
value: number | null;
}
+// Helper to get label for component type, with fallback for unmapped types
+const getComponentTypeLabel = (type: SolutionComponentTypeEnum): string => {
+ return ComponentTypeLabels[type] || `Unknown (${type})`;
+};
+
+// Component types that should show the related table as a prefix
+const ComponentTypesWithRelatedTable = new Set([
+ SolutionComponentTypeEnum.Attribute, // Column
+ SolutionComponentTypeEnum.Relationship, // Relationship
+ SolutionComponentTypeEnum.SystemForm, // Form
+ SolutionComponentTypeEnum.EntityKey, // Key
+ SolutionComponentTypeEnum.SavedQuery, // View
+]);
+
+// Helper to check if component has related table info
+const hasRelatedTable = (comp: SolutionComponentDataType): boolean => {
+ return ComponentTypesWithRelatedTable.has(comp.ComponentType) && !!comp.RelatedTable;
+};
+
+// Helper to get sort key for components (related table + name for applicable types)
+const getComponentSortKey = (comp: SolutionComponentDataType): string => {
+ if (ComponentTypesWithRelatedTable.has(comp.ComponentType) && comp.RelatedTable) {
+ return `${comp.RelatedTable}\0${comp.Name}`;
+ }
+ return comp.Name;
+};
+
+// Get all types that are in any category (known/mapped types)
+const getAllCategorizedTypes = (): Set => {
+ const allTypes = new Set();
+ Object.values(ComponentTypeCategories).forEach(types => {
+ types.forEach(t => allTypes.add(t));
+ });
+ return allTypes;
+};
+
const InsightsSolutionView = ({ }: InsightsSolutionViewProps) => {
- const { groups } = useDatamodelData();
+ const { solutionComponents } = useDatamodelData();
const theme = useTheme();
- const [selectedSolution, setSelectedSolution] = useState<{ Solution1: string, Solution2: string, Components: { Name: string; SchemaName: string; ComponentType: SolutionComponentTypeEnum }[] } | undefined>(undefined);
-
- const solutions = useMemo(() => {
- const solutionMap: Map = new Map();
- groups.forEach(group => {
- group.Entities.forEach(entity => {
+ // Filter state - default to Entity, Attribute, Relationship for backwards compatibility
+ const [enabledComponentTypes, setEnabledComponentTypes] = useState>(
+ new Set([
+ SolutionComponentTypeEnum.Entity,
+ SolutionComponentTypeEnum.Attribute,
+ SolutionComponentTypeEnum.Relationship,
+ ])
+ );
+
+ const [filtersExpanded, setFiltersExpanded] = useState(false);
+
+ const [selectedSolution, setSelectedSolution] = useState<{
+ Solution1: string;
+ Solution2: string;
+ Components: SolutionComponentDataType[];
+ } | undefined>(undefined);
+
+ // Handle toggle of individual component type
+ const handleToggleType = (type: SolutionComponentTypeEnum, checked: boolean) => {
+ setEnabledComponentTypes(prev => {
+ const newSet = new Set(prev);
+ if (checked) {
+ newSet.add(type);
+ } else {
+ newSet.delete(type);
+ }
+ return newSet;
+ });
+ };
- if (!entity.Solutions || entity.Solutions.length === 0) {
- console.log(`Entity ${entity.DisplayName} has no solutions.`);
- }
+ // Select all component types (including unmapped ones)
+ const handleSelectAll = () => {
+ // Include all available types from the data (both categorized and unmapped)
+ setEnabledComponentTypes(new Set(availableTypes));
+ };
- 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 });
- }
+ // Clear all component types
+ const handleSelectNone = () => {
+ setEnabledComponentTypes(new Set());
+ };
- 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 });
- }
- });
- });
+ // Build filtered solution map from solutionComponents
+ const solutions = useMemo(() => {
+ const solutionMap: Map = new Map();
- 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 });
- }
- });
- });
- });
- });
+ solutionComponents.forEach(collection => {
+ const filteredComponents = collection.Components.filter(
+ comp => enabledComponentTypes.has(comp.ComponentType)
+ );
+ solutionMap.set(collection.SolutionName, filteredComponents);
});
return solutionMap;
- }, [groups]);
+ }, [solutionComponents, enabledComponentTypes]);
const solutionMatrix = useMemo(() => {
const solutionNames = Array.from(solutions.keys());
// Create a cache for symmetric calculations
- const cache = new Map();
+ const cache = new Map();
const matrix: Array<{
solution1: string;
solution2: string;
- sharedComponents: { Name: string; SchemaName: string; ComponentType: SolutionComponentTypeEnum }[];
+ sharedComponents: SolutionComponentDataType[];
count: number;
}> = [];
@@ -113,7 +154,7 @@ const InsightsSolutionView = ({ }: InsightsSolutionViewProps) => {
// 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)
+ components2.some(c2 => c2.ObjectId === c1.ObjectId && c2.ComponentType === c1.ComponentType)
);
result = {
@@ -158,6 +199,67 @@ const InsightsSolutionView = ({ }: InsightsSolutionViewProps) => {
};
}, [solutions]);
+ // Collapsed state for component groups in summary panel
+ const [collapsedGroups, setCollapsedGroups] = useState>(new Set());
+
+ // Group components by type for summary panel tree view
+ const groupedComponents = useMemo(() => {
+ if (!selectedSolution) return null;
+
+ const grouped: Record = {};
+ selectedSolution.Components.forEach(comp => {
+ const label = getComponentTypeLabel(comp.ComponentType);
+ if (!grouped[label]) grouped[label] = [];
+ grouped[label].push(comp);
+ });
+
+ // Sort each group by related table (if applicable) then by name
+ Object.keys(grouped).forEach(key => {
+ grouped[key].sort((a, b) => getComponentSortKey(a).localeCompare(getComponentSortKey(b)));
+ });
+
+ return grouped;
+ }, [selectedSolution]);
+
+ // Reset collapsed state when selecting a new cell, with smart expand logic
+ React.useEffect(() => {
+ if (selectedSolution && groupedComponents) {
+ const totalComponents = selectedSolution.Components.length;
+ if (totalComponents <= 10) {
+ // Expand all if small number of components
+ setCollapsedGroups(new Set());
+ } else {
+ // Collapse all by default for larger sets
+ setCollapsedGroups(new Set(Object.keys(groupedComponents)));
+ }
+ }
+ }, [selectedSolution?.Solution1, selectedSolution?.Solution2]);
+
+ // Toggle individual group collapse state
+ const handleToggleGroup = (label: string) => {
+ setCollapsedGroups(prev => {
+ const newSet = new Set(prev);
+ if (newSet.has(label)) {
+ newSet.delete(label);
+ } else {
+ newSet.add(label);
+ }
+ return newSet;
+ });
+ };
+
+ // Expand all groups
+ const handleExpandAllGroups = () => {
+ setCollapsedGroups(new Set());
+ };
+
+ // Collapse all groups
+ const handleCollapseAllGroups = () => {
+ if (groupedComponents) {
+ setCollapsedGroups(new Set(Object.keys(groupedComponents)));
+ }
+ };
+
const onCellSelect = (cellData: HeatMapCell) => {
const solution1 = cellData.serieId as string;
const solution2 = cellData.data.x as string;
@@ -180,6 +282,123 @@ const InsightsSolutionView = ({ }: InsightsSolutionViewProps) => {
}
}
+ // Get all available component types from the data, and identify unmapped ones
+ const { availableTypes, unmappedTypes } = useMemo(() => {
+ const types = new Set();
+ solutionComponents.forEach(collection => {
+ collection.Components.forEach(comp => {
+ types.add(comp.ComponentType);
+ });
+ });
+
+ // Find types that exist in data but aren't in any category
+ const categorizedTypes = getAllCategorizedTypes();
+ const unmapped: SolutionComponentTypeEnum[] = [];
+ types.forEach(t => {
+ if (!categorizedTypes.has(t)) {
+ unmapped.push(t);
+ }
+ });
+ // Sort unmapped by numeric value for consistent display
+ unmapped.sort((a, b) => a - b);
+
+ return { availableTypes: types, unmappedTypes: unmapped };
+ }, [solutionComponents]);
+
+ // ===== TYPES TO SOLUTIONS OVERVIEW =====
+
+ // State for section expansion
+ const [typesOverviewExpanded, setTypesOverviewExpanded] = useState(true);
+
+ // State for collapsed types and components within types
+ const [collapsedTypes, setCollapsedTypes] = useState>(new Set());
+
+ // State for "shared only" filter - default to true (only show components in multiple solutions)
+ const [showSharedOnly, setShowSharedOnly] = useState(true);
+
+ // Build hierarchical data: Component Type → Specific Component → Solutions it appears in
+ const typesToComponents = useMemo(() => {
+ // Build map: componentType -> objectId -> { component, solutions[] }
+ const typeMap = new Map>();
+
+ solutionComponents.forEach(collection => {
+ collection.Components.forEach(comp => {
+ // Only include enabled types
+ if (!enabledComponentTypes.has(comp.ComponentType)) return;
+
+ if (!typeMap.has(comp.ComponentType)) {
+ typeMap.set(comp.ComponentType, new Map());
+ }
+ const componentMap = typeMap.get(comp.ComponentType)!;
+
+ if (!componentMap.has(comp.ObjectId)) {
+ componentMap.set(comp.ObjectId, { component: comp, solutions: [] });
+ }
+ componentMap.get(comp.ObjectId)!.solutions.push(collection.SolutionName);
+ });
+ });
+
+ // Convert to array and sort
+ const result = Array.from(typeMap.entries())
+ .map(([type, components]) => {
+ let componentsArray = Array.from(components.values());
+
+ // Apply "shared only" filter if enabled
+ if (showSharedOnly) {
+ componentsArray = componentsArray.filter(c => c.solutions.length > 1);
+ }
+
+ // Sort by related table (if applicable) then by component name
+ componentsArray.sort((a, b) => getComponentSortKey(a.component).localeCompare(getComponentSortKey(b.component)));
+
+ const sharedCount = componentsArray.filter(c => c.solutions.length > 1).length;
+
+ return {
+ componentType: type,
+ typeLabel: getComponentTypeLabel(type),
+ totalCount: componentsArray.length,
+ sharedCount: sharedCount,
+ components: componentsArray
+ };
+ })
+ // Filter out types with no components (when showSharedOnly and no shared components)
+ .filter(t => t.components.length > 0)
+ // Sort alphabetically by type label
+ .sort((a, b) => a.typeLabel.localeCompare(b.typeLabel));
+
+ return result;
+ }, [solutionComponents, enabledComponentTypes, showSharedOnly]);
+
+ // Collapse all types when data/filters change
+ React.useEffect(() => {
+ const allTypes = new Set(typesToComponents.map(t => t.componentType));
+ setCollapsedTypes(allTypes);
+ }, [typesToComponents]);
+
+ // Toggle type collapse
+ const handleToggleTypeCollapse = (type: SolutionComponentTypeEnum) => {
+ setCollapsedTypes(prev => {
+ const newSet = new Set(prev);
+ if (newSet.has(type)) {
+ newSet.delete(type);
+ } else {
+ newSet.add(type);
+ }
+ return newSet;
+ });
+ };
+
+ // Expand all types
+ const handleExpandAllTypesOverview = () => {
+ setCollapsedTypes(new Set());
+ };
+
+ // Collapse all types
+ const handleCollapseAllTypesOverview = () => {
+ const allTypes = new Set(typesToComponents.map(t => t.componentType));
+ setCollapsedTypes(allTypes);
+ };
+
return (
@@ -200,6 +419,101 @@ const InsightsSolutionView = ({ }: InsightsSolutionViewProps) => {
+
+ {/* Filter Panel */}
+
+
+
+
+
+ Component Type Filters
+
+
+ ({enabledComponentTypes.size} selected)
+
+
+
+
+
+ setFiltersExpanded(!filtersExpanded)}
+ size="small"
+ >
+ {filtersExpanded ? : }
+
+
+
+
+
+
+
+ {Object.entries(ComponentTypeCategories).map(([category, types]) => {
+ // Only show categories that have available types
+ const availableInCategory = types.filter(t => availableTypes.has(t));
+ if (availableInCategory.length === 0) return null;
+
+ return (
+
+
+ {category}
+
+
+ {availableInCategory.map(type => (
+ handleToggleType(type, e.target.checked)}
+ />
+ }
+ label={
+
+ {ComponentTypeLabels[type]}
+
+ }
+ sx={{ marginY: -0.5 }}
+ />
+ ))}
+
+
+ );
+ })}
+ {/* Show unmapped/unknown component types in "Other" category */}
+ {unmappedTypes.length > 0 && (
+
+
+ Other
+
+
+ {unmappedTypes.map(type => (
+ handleToggleType(type, e.target.checked)}
+ />
+ }
+ label={
+
+ {getComponentTypeLabel(type)}
+
+ }
+ sx={{ marginY: -0.5 }}
+ />
+ ))}
+
+
+ )}
+
+
+
+
+
+
@@ -216,91 +530,131 @@ const InsightsSolutionView = ({ }: InsightsSolutionViewProps) => {
Click on any cell to see the shared components between two solutions.
-
- 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
+ {solutionMatrix.solutionNames.length === 0 ? (
+
+
+ No solution data available. Run the Generator to extract solution components.
+
+
+ ) : (
+
+
-
+ ]}
+ onClick={(cell: HeatMapCell) => onCellSelect(cell)}
+ hoverTarget="cell"
+ tooltip={({ cell }: { cell: HeatMapCell }) => {
+ // Get the shared components for this cell to show type breakdown
+ const solution1 = cell.serieId;
+ const solution2 = cell.data.x;
+ const i = solutionMatrix.solutionNames.indexOf(solution1);
+ const j = solutionMatrix.solutionNames.indexOf(solution2);
+
+ const typeBreakdown: Record = {};
+ if (solution1 !== solution2 && i !== -1 && j !== -1) {
+ const matrixIndex = i * solutionMatrix.solutionNames.length + j;
+ const sharedComponents = solutionMatrix.matrix[matrixIndex].sharedComponents;
+
+ // Group by component type
+ sharedComponents.forEach(comp => {
+ const label = getComponentTypeLabel(comp.ComponentType);
+ typeBreakdown[label] = (typeBreakdown[label] || 0) + 1;
+ });
+ }
+
+ const sortedTypes = Object.entries(typeBreakdown).sort((a, b) => b[1] - a[1]);
+
+ return (
+
+
+ {cell.serieId} × {cell.data.x}
+
+
+ {cell.serieId === cell.data.x ? 'Same solution' : `${cell.value} shared components`}
+
+ {sortedTypes.length > 0 && (
+
+ {sortedTypes.map(([label, count]) => (
+
+ {label}: {count}
+
+ ))}
+
+ )}
+
+ );
+ }}
+ theme={{
+ text: {
+ fill: theme.palette.text.primary
+ },
+ tooltip: {
+ container: {
+ background: theme.palette.background.paper,
+ color: theme.palette.text.primary
+ }
+ }
+ }}
+ />
+
+ )}
@@ -311,29 +665,70 @@ const InsightsSolutionView = ({ }: InsightsSolutionViewProps) => {
{selectedSolution ? (
-
- {selectedSolution.Components.length > 0 ? (
+
+ {selectedSolution.Solution1} ∩ {selectedSolution.Solution2}
+
+
+ {selectedSolution.Components.length > 0 && groupedComponents ? (
-
- Shared Components: ({selectedSolution.Components.length})
-
-
- {selectedSolution.Components.map(component => (
- -
-
- {component.Name} ({
- component.ComponentType === SolutionComponentTypeEnum.Entity
- ? 'Table'
- : component.ComponentType === SolutionComponentTypeEnum.Attribute
- ? 'Column'
- : component.ComponentType === SolutionComponentTypeEnum.Relationship
- ? 'Relationship'
- : 'Unknown'
- })
+
+
+ {Object.keys(groupedComponents).length} types, {selectedSolution.Components.length} components
+
+
+
+
+
+
+ {Object.entries(groupedComponents)
+ .sort((a, b) => a[1].length - b[1].length)
+ .map(([typeLabel, comps]) => (
+
+ handleToggleGroup(typeLabel)}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ cursor: 'pointer',
+ py: 0.5,
+ px: 0.5,
+ borderRadius: 1,
+ '&:hover': {
+ backgroundColor: 'action.hover'
+ }
+ }}
+ >
+ {collapsedGroups.has(typeLabel) ? (
+
+ ) : (
+
+ )}
+
+ {typeLabel} ({comps.length})
-
- ))}
-
+
+
+
+ {comps.map(comp => (
+
+
+ {hasRelatedTable(comp) && (
+
+ {comp.RelatedTable}:
+
+ )}
+ {comp.Name}
+
+
+ ))}
+
+
+
+ ))}
) : (
@@ -349,6 +744,143 @@ const InsightsSolutionView = ({ }: InsightsSolutionViewProps) => {
)}
+
+ {/* Component Types Overview Section */}
+
+
+
+
+
+ Component Types Overview
+
+
+
+ {InfoIcon}
+
+
+
+
+
+ setShowSharedOnly(e.target.checked)}
+ />
+ }
+ label={
+
+ Shared only
+
+ }
+ sx={{ mr: 1 }}
+ />
+
+
+
+ setTypesOverviewExpanded(!typesOverviewExpanded)}
+ size="small"
+ >
+ {typesOverviewExpanded ? : }
+
+
+
+
+
+ {typesToComponents.length === 0 ? (
+
+
+ {enabledComponentTypes.size === 0
+ ? 'Select component types in the filter panel above to see the overview.'
+ : 'No components match the selected filters.'}
+
+
+ ) : (
+
+ {typesToComponents.map(typeData => (
+
+ {/* Type header - clickable */}
+ handleToggleTypeCollapse(typeData.componentType)}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ cursor: 'pointer',
+ py: 1,
+ px: 1,
+ borderRadius: 1,
+ backgroundColor: 'action.hover',
+ '&:hover': {
+ backgroundColor: 'action.selected'
+ }
+ }}
+ >
+ {collapsedTypes.has(typeData.componentType) ? (
+
+ ) : (
+
+ )}
+
+ {typeData.typeLabel}
+
+
+ ({typeData.totalCount} {typeData.totalCount === 1 ? 'component' : 'components'}{typeData.sharedCount > 0 && `, ${typeData.sharedCount} shared`})
+
+
+
+ {/* Components under this type */}
+
+
+ {typeData.components.map(({ component, solutions }) => (
+
+
+
+ {hasRelatedTable(component) && (
+
+ {component.RelatedTable}:
+
+ )}
+ {component.Name}
+
+
+ →
+
+ {solutions.map((sol) => (
+
+ {sol}
+
+ ))}
+
+
+ ))}
+
+
+
+ ))}
+
+ )}
+
+
+
)
}
diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx
index a495391..c663609 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, RelationshipType, SolutionWarningType } from "@/lib/Types";
+import { AttributeType, EntityType, GroupType, RelationshipType, SolutionWarningType, SolutionComponentCollectionType } from "@/lib/Types";
import { useSearchParams } from "next/navigation";
import { SearchScope } from "@/components/datamodelview/TimeSlicedSearch";
@@ -14,6 +14,7 @@ interface DatamodelDataState extends DataModelAction {
entityMap?: Map;
warnings: SolutionWarningType[];
solutionCount: number;
+ solutionComponents: SolutionComponentCollectionType[];
search: string;
searchScope: SearchScope;
filtered: Array<
@@ -28,6 +29,7 @@ const initialState: DatamodelDataState = {
groups: [],
warnings: [],
solutionCount: 0,
+ solutionComponents: [],
search: "",
searchScope: {
columnNames: true,
@@ -54,6 +56,8 @@ const datamodelDataReducer = (state: DatamodelDataState, action: any): Datamodel
return { ...state, warnings: action.payload };
case "SET_SOLUTION_COUNT":
return { ...state, solutionCount: action.payload };
+ case "SET_SOLUTION_COMPONENTS":
+ return { ...state, solutionComponents: action.payload };
case "SET_SEARCH":
return { ...state, search: action.payload };
case "SET_SEARCH_SCOPE":
@@ -83,6 +87,7 @@ export const DatamodelDataProvider = ({ children }: { children: ReactNode }) =>
dispatch({ type: "SET_ENTITIES", payload: e.data.entityMap || new Map() });
dispatch({ type: "SET_WARNINGS", payload: e.data.warnings || [] });
dispatch({ type: "SET_SOLUTION_COUNT", payload: e.data.solutionCount || 0 });
+ dispatch({ type: "SET_SOLUTION_COMPONENTS", payload: e.data.solutionComponents || [] });
worker.terminate();
};
worker.postMessage({});
diff --git a/Website/lib/Types.ts b/Website/lib/Types.ts
index bc1ffba..07952a4 100644
--- a/Website/lib/Types.ts
+++ b/Website/lib/Types.ts
@@ -49,12 +49,173 @@ export type EntityType = {
}
+/// Solution component types matching Dataverse codes
+/// See: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/reference/entities/solutioncomponent
export const enum SolutionComponentTypeEnum {
Entity = 1,
Attribute = 2,
- Relationship = 3,
+ OptionSet = 9,
+ Relationship = 10,
+ EntityKey = 14,
+ SecurityRole = 20,
+ SavedQuery = 26,
+ Workflow = 29,
+ RibbonCustomization = 50,
+ SavedQueryVisualization = 59,
+ SystemForm = 60,
+ WebResource = 61,
+ SiteMap = 62,
+ ConnectionRole = 63,
+ HierarchyRule = 65,
+ CustomControl = 66,
+ FieldSecurityProfile = 70,
+ ModelDrivenApp = 80,
+ PluginAssembly = 91,
+ SDKMessageProcessingStep = 92,
+ CanvasApp = 300,
+ ConnectionReference = 372,
+ EnvironmentVariableDefinition = 380,
+ EnvironmentVariableValue = 381,
+ Dataflow = 418,
+ ConnectionRoleObjectTypeCode = 3233,
+ CustomAPI = 10240,
+ CustomAPIRequestParameter = 10241,
+ CustomAPIResponseProperty = 10242,
+ RequirementResourcePreference = 10019,
+ RequirementStatus = 10020,
+ SchedulingParameter = 10025,
+ PluginPackage = 10639,
+ OrganizationSetting = 10563,
+ AppAction = 10645,
+ AppActionRule = 10948,
+ FxExpression = 11492,
+ DVFileSearch = 11723,
+ DVFileSearchAttribute = 11724,
+ DVFileSearchEntity = 11725,
+ AISkillConfig = 12075,
}
+/// Solution component data for insights view
+export type SolutionComponentDataType = {
+ Name: string;
+ SchemaName: string;
+ ComponentType: SolutionComponentTypeEnum;
+ ObjectId: string;
+ IsExplicit: boolean;
+ RelatedTable?: string | null;
+}
+
+/// Collection of solution components grouped by solution
+export type SolutionComponentCollectionType = {
+ SolutionId: string;
+ SolutionName: string;
+ Components: SolutionComponentDataType[];
+}
+
+/// Component type categories for UI grouping
+export const ComponentTypeCategories: Record = {
+ 'Data Model': [
+ SolutionComponentTypeEnum.Entity,
+ SolutionComponentTypeEnum.Attribute,
+ SolutionComponentTypeEnum.Relationship,
+ SolutionComponentTypeEnum.OptionSet,
+ SolutionComponentTypeEnum.EntityKey,
+ SolutionComponentTypeEnum.HierarchyRule,
+ SolutionComponentTypeEnum.Dataflow,
+ SolutionComponentTypeEnum.DVFileSearch,
+ SolutionComponentTypeEnum.DVFileSearchAttribute,
+ SolutionComponentTypeEnum.DVFileSearchEntity,
+ ],
+ 'User Interface': [
+ SolutionComponentTypeEnum.SystemForm,
+ SolutionComponentTypeEnum.SavedQuery,
+ SolutionComponentTypeEnum.SavedQueryVisualization,
+ SolutionComponentTypeEnum.SiteMap,
+ SolutionComponentTypeEnum.CustomControl,
+ SolutionComponentTypeEnum.RibbonCustomization,
+ ],
+ 'Apps': [
+ SolutionComponentTypeEnum.ModelDrivenApp,
+ SolutionComponentTypeEnum.CanvasApp,
+ SolutionComponentTypeEnum.AppAction,
+ SolutionComponentTypeEnum.AppActionRule,
+ ],
+ 'Code': [
+ SolutionComponentTypeEnum.Workflow,
+ SolutionComponentTypeEnum.PluginAssembly,
+ SolutionComponentTypeEnum.SDKMessageProcessingStep,
+ SolutionComponentTypeEnum.WebResource,
+ SolutionComponentTypeEnum.CustomAPI,
+ SolutionComponentTypeEnum.CustomAPIRequestParameter,
+ SolutionComponentTypeEnum.CustomAPIResponseProperty,
+ SolutionComponentTypeEnum.PluginPackage,
+ SolutionComponentTypeEnum.FxExpression,
+ ],
+ 'Security': [
+ SolutionComponentTypeEnum.SecurityRole,
+ SolutionComponentTypeEnum.FieldSecurityProfile,
+ ],
+ 'Configuration': [
+ SolutionComponentTypeEnum.EnvironmentVariableDefinition,
+ SolutionComponentTypeEnum.EnvironmentVariableValue,
+ SolutionComponentTypeEnum.ConnectionReference,
+ SolutionComponentTypeEnum.ConnectionRole,
+ SolutionComponentTypeEnum.ConnectionRoleObjectTypeCode,
+ SolutionComponentTypeEnum.OrganizationSetting,
+ SolutionComponentTypeEnum.AISkillConfig,
+ ],
+ 'Scheduling': [
+ SolutionComponentTypeEnum.RequirementResourcePreference,
+ SolutionComponentTypeEnum.RequirementStatus,
+ SolutionComponentTypeEnum.SchedulingParameter,
+ ],
+};
+
+/// Human-readable labels for component types
+export const ComponentTypeLabels: Record = {
+ [SolutionComponentTypeEnum.Entity]: 'Table',
+ [SolutionComponentTypeEnum.Attribute]: 'Column',
+ [SolutionComponentTypeEnum.OptionSet]: 'Choice',
+ [SolutionComponentTypeEnum.Relationship]: 'Relationship',
+ [SolutionComponentTypeEnum.EntityKey]: 'Key',
+ [SolutionComponentTypeEnum.SecurityRole]: 'Security Role',
+ [SolutionComponentTypeEnum.SavedQuery]: 'View',
+ [SolutionComponentTypeEnum.Workflow]: 'Cloud Flow',
+ [SolutionComponentTypeEnum.RibbonCustomization]: 'Ribbon',
+ [SolutionComponentTypeEnum.SavedQueryVisualization]: 'Chart',
+ [SolutionComponentTypeEnum.SystemForm]: 'Form',
+ [SolutionComponentTypeEnum.WebResource]: 'Web Resource',
+ [SolutionComponentTypeEnum.SiteMap]: 'Site Map',
+ [SolutionComponentTypeEnum.ConnectionRole]: 'Connection Role',
+ [SolutionComponentTypeEnum.HierarchyRule]: 'Hierarchy Rule',
+ [SolutionComponentTypeEnum.CustomControl]: 'Custom Control',
+ [SolutionComponentTypeEnum.FieldSecurityProfile]: 'Field Security',
+ [SolutionComponentTypeEnum.ModelDrivenApp]: 'Model-driven App',
+ [SolutionComponentTypeEnum.PluginAssembly]: 'Plugin Assembly',
+ [SolutionComponentTypeEnum.SDKMessageProcessingStep]: 'Plugin Step',
+ [SolutionComponentTypeEnum.CanvasApp]: 'Canvas App',
+ [SolutionComponentTypeEnum.ConnectionReference]: 'Connection Reference',
+ [SolutionComponentTypeEnum.EnvironmentVariableDefinition]: 'Environment Variable',
+ [SolutionComponentTypeEnum.EnvironmentVariableValue]: 'Env Variable Value',
+ [SolutionComponentTypeEnum.Dataflow]: 'Dataflow',
+ [SolutionComponentTypeEnum.ConnectionRoleObjectTypeCode]: 'Connection Role Type',
+ [SolutionComponentTypeEnum.CustomAPI]: 'Custom API',
+ [SolutionComponentTypeEnum.CustomAPIRequestParameter]: 'Custom API Parameter',
+ [SolutionComponentTypeEnum.CustomAPIResponseProperty]: 'Custom API Response',
+ [SolutionComponentTypeEnum.PluginPackage]: 'Plugin Package',
+ [SolutionComponentTypeEnum.OrganizationSetting]: 'Org Setting',
+ [SolutionComponentTypeEnum.AppAction]: 'App Action',
+ [SolutionComponentTypeEnum.AppActionRule]: 'App Action Rule',
+ [SolutionComponentTypeEnum.FxExpression]: 'Fx Expression',
+ [SolutionComponentTypeEnum.DVFileSearch]: 'DV File Search',
+ [SolutionComponentTypeEnum.DVFileSearchAttribute]: 'DV File Search Attr',
+ [SolutionComponentTypeEnum.DVFileSearchEntity]: 'DV File Search Entity',
+ [SolutionComponentTypeEnum.AISkillConfig]: 'AI Skill Config',
+ [SolutionComponentTypeEnum.RequirementResourcePreference]: 'Resource Preference',
+ [SolutionComponentTypeEnum.RequirementStatus]: 'Requirement Status',
+ [SolutionComponentTypeEnum.SchedulingParameter]: 'Scheduling Parameter',
+};
+
export const enum RequiredLevel {
None = 0,
SystemRequired = 1,
diff --git a/Website/stubs/Data.ts b/Website/stubs/Data.ts
index 367957e..cf66eb0 100644
--- a/Website/stubs/Data.ts
+++ b/Website/stubs/Data.ts
@@ -1,7 +1,7 @@
/// Used in github workflow to generate stubs for data
/// This file is a stub and should not be modified directly.
-import { GroupType, SolutionWarningType } from "@/lib/Types";
+import { GroupType, SolutionWarningType, SolutionComponentCollectionType } from "@/lib/Types";
export const LastSynched: Date = new Date();
export const Logo: string | null = null;
@@ -113,4 +113,6 @@ export let Groups: GroupType[] = [
}
];
-export let SolutionWarnings: SolutionWarningType[] = [];
\ No newline at end of file
+export let SolutionWarnings: SolutionWarningType[] = [];
+
+export let SolutionComponents: SolutionComponentCollectionType[] = [];
\ No newline at end of file