Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Generator/DTO/Attributes/Attribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> PluginTypeNames { get; set; } = new HashSet<string>();
public bool HasPluginStep => PluginTypeNames.Count > 0;
public string DisplayName { get; }
public string SchemaName { get; }
public string Description { get; }
Expand Down
116 changes: 110 additions & 6 deletions Generator/DataverseService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public async Task<IEnumerable<Record>> 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);

Expand Down Expand Up @@ -79,7 +80,11 @@ public async Task<IEnumerable<Record>> 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
Expand All @@ -98,8 +103,6 @@ public async Task<IEnumerable<Record>> 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 =>
Expand All @@ -117,6 +120,7 @@ public async Task<IEnumerable<Record>> GetFilteredMetadata()
securityRoles ?? [],
keys ?? [],
entityIconMap,
pluginStepAttributeMap,
configuration);
});
}
Expand All @@ -131,13 +135,16 @@ private static Record MakeRecord(
List<SecurityRole> securityRoles,
List<Key> keys,
Dictionary<string, string> entityIconMap,
Dictionary<string, Dictionary<string, HashSet<string>>> 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<string>();
var attr = GetAttribute(metadata, entity, logicalToSchema, pluginTypeNames, logger);
attr.IsStandardFieldModified = MetadataExtensions.StandardFieldHasChanged(metadata, entity.DisplayName.UserLocalizedLabel?.Label ?? string.Empty);
return attr;
})
Expand Down Expand Up @@ -213,9 +220,9 @@ private static Record MakeRecord(
iconBase64);
}

private static Attribute GetAttribute(AttributeMetadata metadata, EntityMetadata entity, Dictionary<string, ExtendedEntityInformation> logicalToSchema, ILogger<DataverseService> logger)
private static Attribute GetAttribute(AttributeMetadata metadata, EntityMetadata entity, Dictionary<string, ExtendedEntityInformation> logicalToSchema, HashSet<string> pluginTypeNames, ILogger<DataverseService> logger)
{
return metadata switch
Attribute attr = metadata switch
{
PicklistAttributeMetadata picklist => new ChoiceAttribute(picklist),
MultiSelectPicklistAttributeMetadata multiSelect => new ChoiceAttribute(multiSelect),
Expand All @@ -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<string, string> tableGroups)
Expand Down Expand Up @@ -346,7 +355,7 @@ await Parallel.ForEachAsync(
{
Conditions =
{
new ConditionExpression("componenttype", ConditionOperator.In, new List<int>() { 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<int>() { 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)
}
}
Expand Down Expand Up @@ -538,6 +547,101 @@ private static string GetCoreUrl(string url)
return $"{uri.Scheme}://{uri.Host}";
}

private async Task<Dictionary<string, Dictionary<string, HashSet<string>>>> GetPluginStepAttributes(HashSet<string> relevantLogicalNames, List<Guid> pluginStepsInSolution)
{
logger.LogInformation("Retrieving plugin step attributes...");

var pluginStepAttributeMap = new Dictionary<string, Dictionary<string, HashSet<string>>>();

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<string>("filteringattributes");
var entityLogicalName = step.GetAttributeValue<AliasedValue>("filter.primaryobjecttypecode")?.Value as string;
var pluginTypeName = step.GetAttributeValue<AliasedValue>("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<string, HashSet<string>>();

// 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<string>();

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<AccessToken> FetchAccessToken(TokenCredential credential, string scope, ILogger logger)
{
var tokenRequestContext = new TokenRequestContext(new[] { scope });
Expand Down
9 changes: 8 additions & 1 deletion Website/components/entity/AttributeDetails.tsx
Original file line number Diff line number Diff line change
@@ -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 }) {
Expand Down Expand Up @@ -34,6 +34,13 @@ export function AttributeDetails({ attribute }: { attribute: AttributeType }) {
details.push({ icon: <Lock className="h-4 w-4" />, tooltip: "Field Security" });
}

if (attribute.HasPluginStep) {
const pluginTypesTooltip = attribute.PluginTypeNames.length > 0
? `Plugin Steps: ${attribute.PluginTypeNames.join(', ')}`
: "Plugin Step";
details.push({ icon: <Zap className="h-4 w-4" />, tooltip: pluginTypesTooltip });
}

return (
<div className="flex flex-row gap-1">
{details.map((detail, index) => (
Expand Down
2 changes: 2 additions & 0 deletions Website/lib/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export type BaseAttribute = {
IsPrimaryId: boolean;
IsCustomAttribute: boolean;
IsStandardFieldModified: boolean;
HasPluginStep: boolean;
PluginTypeNames: string[];
DisplayName: string,
SchemaName: string,
Description: string | null,
Expand Down
76 changes: 74 additions & 2 deletions Website/stubs/Data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
];
Loading