diff --git a/Generator/DTO/Attributes/Attribute.cs b/Generator/DTO/Attributes/Attribute.cs index 3672574..47886c9 100644 --- a/Generator/DTO/Attributes/Attribute.cs +++ b/Generator/DTO/Attributes/Attribute.cs @@ -7,6 +7,8 @@ public abstract class Attribute public bool IsStandardFieldModified { get; set; } public bool IsCustomAttribute { get; set; } public bool IsPrimaryId { get; set; } + public HashSet PluginTypeNames { get; set; } = new HashSet(); + public bool HasPluginStep => PluginTypeNames.Count > 0; public string DisplayName { get; } public string SchemaName { get; } public string Description { get; } diff --git a/Generator/DataverseService.cs b/Generator/DataverseService.cs index b964a54..16d5da0 100644 --- a/Generator/DataverseService.cs +++ b/Generator/DataverseService.cs @@ -49,6 +49,7 @@ public async Task> GetFilteredMetadata() var entityRootBehaviour = solutionComponents.Where(x => x.ComponentType == 1).ToDictionary(x => x.ObjectId, x => x.RootComponentBehavior); var attributesInSolution = solutionComponents.Where(x => x.ComponentType == 2).Select(x => x.ObjectId).ToHashSet(); var rolesInSolution = solutionComponents.Where(x => x.ComponentType == 20).Select(x => x.ObjectId).ToList(); + var pluginStepsInSolution = solutionComponents.Where(x => x.ComponentType == 92).Select(x => x.ObjectId).ToList(); var entitiesInSolutionMetadata = await GetEntityMetadata(entitiesInSolution); @@ -79,7 +80,11 @@ public async Task> GetFilteredMetadata() var referencedEntityMetadata = await GetEntityMetadataByLogicalName(relatedEntityLogicalNames.ToList()); var allEntityMetadata = entitiesInSolutionMetadata.Concat(referencedEntityMetadata).ToList(); + var logicalToSchema = allEntityMetadata.ToDictionary(x => x.LogicalName, x => new ExtendedEntityInformation { Name = x.SchemaName, IsInSolution = entitiesInSolutionMetadata.Any(e => e.LogicalName == x.LogicalName) }); + var attributeLogicalToSchema = allEntityMetadata.ToDictionary(x => x.LogicalName, x => x.Attributes?.ToDictionary(attr => attr.LogicalName, attr => attr.DisplayName.UserLocalizedLabel?.Label ?? attr.SchemaName) ?? []); + var entityIconMap = await GetEntityIconMap(allEntityMetadata); + var pluginStepAttributeMap = await GetPluginStepAttributes(logicalToSchema.Keys.ToHashSet(), pluginStepsInSolution); var records = entitiesInSolutionMetadata @@ -98,8 +103,6 @@ public async Task> GetFilteredMetadata() .Where(x => x.EntityMetadata.DisplayName.UserLocalizedLabel?.Label != null) .ToList(); - var logicalToSchema = allEntityMetadata.ToDictionary(x => x.LogicalName, x => new ExtendedEntityInformation { Name = x.SchemaName, IsInSolution = entitiesInSolutionMetadata.Any(e => e.LogicalName == x.LogicalName) }); - var attributeLogicalToSchema = allEntityMetadata.ToDictionary(x => x.LogicalName, x => x.Attributes?.ToDictionary(attr => attr.LogicalName, attr => attr.DisplayName.UserLocalizedLabel?.Label ?? attr.SchemaName) ?? []); return records .Select(x => @@ -117,6 +120,7 @@ public async Task> GetFilteredMetadata() securityRoles ?? [], keys ?? [], entityIconMap, + pluginStepAttributeMap, configuration); }); } @@ -131,13 +135,16 @@ private static Record MakeRecord( List securityRoles, List keys, Dictionary entityIconMap, + Dictionary>> pluginStepAttributeMap, IConfiguration configuration) { var attributes = relevantAttributes .Select(metadata => { - var attr = GetAttribute(metadata, entity, logicalToSchema, logger); + pluginStepAttributeMap.TryGetValue(entity.LogicalName, out var entityPluginAttributes); + var pluginTypeNames = entityPluginAttributes?.GetValueOrDefault(metadata.LogicalName) ?? new HashSet(); + var attr = GetAttribute(metadata, entity, logicalToSchema, pluginTypeNames, logger); attr.IsStandardFieldModified = MetadataExtensions.StandardFieldHasChanged(metadata, entity.DisplayName.UserLocalizedLabel?.Label ?? string.Empty); return attr; }) @@ -213,9 +220,9 @@ private static Record MakeRecord( iconBase64); } - private static Attribute GetAttribute(AttributeMetadata metadata, EntityMetadata entity, Dictionary logicalToSchema, ILogger logger) + private static Attribute GetAttribute(AttributeMetadata metadata, EntityMetadata entity, Dictionary logicalToSchema, HashSet pluginTypeNames, ILogger logger) { - return metadata switch + Attribute attr = metadata switch { PicklistAttributeMetadata picklist => new ChoiceAttribute(picklist), MultiSelectPicklistAttributeMetadata multiSelect => new ChoiceAttribute(multiSelect), @@ -231,6 +238,8 @@ private static Attribute GetAttribute(AttributeMetadata metadata, EntityMetadata FileAttributeMetadata fileAttribute => new FileAttribute(fileAttribute), _ => new GenericAttribute(metadata) }; + attr.PluginTypeNames = pluginTypeNames; + return attr; } private static (string? Group, string? Description) GetGroupAndDescription(EntityMetadata entity, IDictionary tableGroups) @@ -346,7 +355,7 @@ await Parallel.ForEachAsync( { Conditions = { - new ConditionExpression("componenttype", ConditionOperator.In, new List() { 1, 2, 20 }), // entity, attribute, role (https://learn.microsoft.com/en-us/power-apps/developer/data-platform/reference/entities/solutioncomponent) + new ConditionExpression("componenttype", ConditionOperator.In, new List() { 1, 2, 20, 92 }), // entity, attribute, role, pluginstep (https://learn.microsoft.com/en-us/power-apps/developer/data-platform/reference/entities/solutioncomponent) new ConditionExpression("solutionid", ConditionOperator.In, solutionIds) } } @@ -538,6 +547,101 @@ private static string GetCoreUrl(string url) return $"{uri.Scheme}://{uri.Host}"; } + private async Task>>> GetPluginStepAttributes(HashSet relevantLogicalNames, List pluginStepsInSolution) + { + logger.LogInformation("Retrieving plugin step attributes..."); + + var pluginStepAttributeMap = new Dictionary>>(); + + try + { + // Query sdkmessageprocessingstep table for steps with filtering attributes + var stepQuery = new QueryExpression("sdkmessageprocessingstep") + { + ColumnSet = new ColumnSet("filteringattributes", "sdkmessagefilterid", "sdkmessageprocessingstepid"), + Criteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("filteringattributes", ConditionOperator.NotNull), + new ConditionExpression("filteringattributes", ConditionOperator.NotEqual, ""), + new ConditionExpression("statecode", ConditionOperator.Equal, 0) // Only active steps + } + }, + LinkEntities = + { + new LinkEntity + { + LinkFromEntityName = "sdkmessageprocessingstep", + LinkFromAttributeName = "sdkmessagefilterid", + LinkToEntityName = "sdkmessagefilter", + LinkToAttributeName = "sdkmessagefilterid", + Columns = new ColumnSet("primaryobjecttypecode"), + EntityAlias = "filter" + }, + new LinkEntity + { + LinkFromEntityName = "sdkmessageprocessingstep", + LinkFromAttributeName = "plugintypeid", + LinkToEntityName = "plugintype", + LinkToAttributeName = "plugintypeid", + Columns = new ColumnSet("name"), + EntityAlias = "plugintype" + } + } + }; + + // Add solution filtering if plugin steps in solution are specified + if (pluginStepsInSolution.Count > 0) + { + stepQuery.Criteria.Conditions.Add( + new ConditionExpression("sdkmessageprocessingstepid", ConditionOperator.In, pluginStepsInSolution)); + } + + var stepResults = await client.RetrieveMultipleAsync(stepQuery); + + foreach (var step in stepResults.Entities) + { + var filteringAttributes = step.GetAttributeValue("filteringattributes"); + var entityLogicalName = step.GetAttributeValue("filter.primaryobjecttypecode")?.Value as string; + var pluginTypeName = step.GetAttributeValue("plugintype.name")?.Value as string; + + if (string.IsNullOrEmpty(filteringAttributes) || string.IsNullOrEmpty(entityLogicalName) || string.IsNullOrEmpty(pluginTypeName)) + continue; + + // Get entity logical name from metadata mapping + if (!relevantLogicalNames.Contains(entityLogicalName)) + { + logger.LogDebug("Unknown entity type code: {TypeCode}", entityLogicalName); + continue; + } + + if (!pluginStepAttributeMap.ContainsKey(entityLogicalName)) + pluginStepAttributeMap[entityLogicalName] = new Dictionary>(); + + // Parse comma-separated attribute names + var attributeNames = filteringAttributes.Split(',', StringSplitOptions.RemoveEmptyEntries); + foreach (var attributeName in attributeNames) + { + var trimmedAttributeName = attributeName.Trim(); + if (!pluginStepAttributeMap[entityLogicalName].ContainsKey(trimmedAttributeName)) + pluginStepAttributeMap[entityLogicalName][trimmedAttributeName] = new HashSet(); + + var pluginTypeNameParts = pluginTypeName.Split('.'); + pluginStepAttributeMap[entityLogicalName][trimmedAttributeName].Add(pluginTypeNameParts[pluginTypeNameParts.Length - 1]); + } + } + + logger.LogInformation("Found {Count} entities with plugin step attributes.", pluginStepAttributeMap.Count); + } + catch (Exception ex) + { + logger.LogWarning("Failed to retrieve plugin step attributes: {Message}", ex.Message); + } + + return pluginStepAttributeMap; + } + private static async Task FetchAccessToken(TokenCredential credential, string scope, ILogger logger) { var tokenRequestContext = new TokenRequestContext(new[] { scope }); diff --git a/Website/components/entity/AttributeDetails.tsx b/Website/components/entity/AttributeDetails.tsx index d00af99..945d74f 100644 --- a/Website/components/entity/AttributeDetails.tsx +++ b/Website/components/entity/AttributeDetails.tsx @@ -1,7 +1,7 @@ 'use client' import { AttributeType, CalculationMethods, RequiredLevel } from "@/lib/Types"; -import { Calculator, CircleAlert, CirclePlus, Eye, Lock, Sigma } from "lucide-react"; +import { Calculator, CircleAlert, CirclePlus, Eye, Lock, Sigma, Zap } from "lucide-react"; import { HybridTooltip, HybridTooltipContent, HybridTooltipTrigger } from "../ui/hybridtooltop"; export function AttributeDetails({ attribute }: { attribute: AttributeType }) { @@ -34,6 +34,13 @@ export function AttributeDetails({ attribute }: { attribute: AttributeType }) { details.push({ icon: , tooltip: "Field Security" }); } + if (attribute.HasPluginStep) { + const pluginTypesTooltip = attribute.PluginTypeNames.length > 0 + ? `Plugin Steps: ${attribute.PluginTypeNames.join(', ')}` + : "Plugin Step"; + details.push({ icon: , tooltip: pluginTypesTooltip }); + } + return (
{details.map((detail, index) => ( diff --git a/Website/lib/Types.ts b/Website/lib/Types.ts index 6c63ea1..2b27869 100644 --- a/Website/lib/Types.ts +++ b/Website/lib/Types.ts @@ -45,6 +45,8 @@ export type BaseAttribute = { IsPrimaryId: boolean; IsCustomAttribute: boolean; IsStandardFieldModified: boolean; + HasPluginStep: boolean; + PluginTypeNames: string[]; DisplayName: string, SchemaName: string, Description: string | null, diff --git a/Website/stubs/Data.ts b/Website/stubs/Data.ts index 3b70258..23226f5 100644 --- a/Website/stubs/Data.ts +++ b/Website/stubs/Data.ts @@ -8,7 +8,79 @@ export const Logo: string | null = null; export let Groups: GroupType[] = [ { - "Name":"Untitled", - "Entities":[] + "Name": "Sample Data", + "Entities": [ + { + "DisplayName": "Account", + "SchemaName": "Account", + "Description": "Business organization or customer", + "Group": "Sample Data", + "IsAuditEnabled": true, + "IsActivity": false, + "IsNotesEnabled": true, + "Ownership": 1, + "Attributes": [ + { + "AttributeType": "StringAttribute", + "IsPrimaryId": false, + "IsCustomAttribute": true, + "IsStandardFieldModified": false, + "HasPluginStep": true, + "PluginTypeNames": ["Sample Plugin Type"], + "DisplayName": "Account Name", + "SchemaName": "name", + "Description": "The name of the account", + "RequiredLevel": 2, + "IsAuditEnabled": true, + "IsColumnSecured": false, + "CalculationMethod": null, + "Format": "Text", + "MaxLength": 160 + }, + { + "AttributeType": "StringAttribute", + "IsPrimaryId": false, + "IsCustomAttribute": true, + "IsStandardFieldModified": false, + "HasPluginStep": false, + "PluginTypeNames": [], + "DisplayName": "Phone", + "SchemaName": "telephone1", + "Description": "The main phone number for the account", + "RequiredLevel": 0, + "IsAuditEnabled": false, + "IsColumnSecured": false, + "CalculationMethod": null, + "Format": "Phone", + "MaxLength": 50 + }, + { + "AttributeType": "LookupAttribute", + "IsPrimaryId": false, + "IsCustomAttribute": true, + "IsStandardFieldModified": false, + "HasPluginStep": true, + "PluginTypeNames": ["Another Plugin Type"], + "DisplayName": "Primary Contact", + "SchemaName": "primarycontactid", + "Description": "The primary contact for the account", + "RequiredLevel": 0, + "IsAuditEnabled": true, + "IsColumnSecured": false, + "CalculationMethod": null, + "Targets": [ + { + "Name": "Contact", + "IsInSolution": true + } + ] + } + ], + "Relationships": [], + "SecurityRoles": [], + "Keys": [], + "IconBase64": null + } + ] } ]; \ No newline at end of file