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