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
1 change: 1 addition & 0 deletions Generator/DTO/Record.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ internal record Record(
string? Description,
bool IsAuditEnabled,
bool IsActivity,
bool IsCustom,
OwnershipTypes Ownership,
bool IsNotesEnabled,
List<Attribute> Attributes,
Expand Down
5 changes: 5 additions & 0 deletions Generator/DTO/Solution.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace Generator.DTO;

public record Solution(
string Name,
IEnumerable<SolutionComponent> Components);
14 changes: 14 additions & 0 deletions Generator/DTO/SolutionComponent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace Generator.DTO;

public enum SolutionComponentType
{
Entity = 1,
Attribute = 2,
Relationship = 3,
}

public record SolutionComponent(
string Name,
string SchemaName,
string Description,
SolutionComponentType ComponentType);
174 changes: 160 additions & 14 deletions Generator/DataverseService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,24 @@ public DataverseService(IConfiguration configuration, ILogger<DataverseService>
webResourceAnalyzer = new WebResourceAnalyzer(client, configuration);
}

public async Task<(IEnumerable<Record>, IEnumerable<SolutionWarning>)> GetFilteredMetadata()
public async Task<(IEnumerable<Record>, IEnumerable<SolutionWarning>, IEnumerable<Solution>)> GetFilteredMetadata()
{
var warnings = new List<SolutionWarning>(); // used to collect warnings for the insights dashboard
var (publisherPrefix, solutionIds) = await GetSolutionIds();
var solutionComponents = await GetSolutionComponents(solutionIds); // (id, type, rootcomponentbehavior)

var entitiesInSolution = solutionComponents.Where(x => x.ComponentType == 1).Select(x => x.ObjectId).ToList();
var entityRootBehaviour = solutionComponents.Where(x => x.ComponentType == 1).ToDictionary(x => x.ObjectId, x => x.RootComponentBehavior);
var (publisherPrefix, solutionIds, solutionEntities) = await GetSolutionIds();
var solutionComponents = await GetSolutionComponents(solutionIds); // (id, type, rootcomponentbehavior, solutionid)

var entitiesInSolution = solutionComponents.Where(x => x.ComponentType == 1).Select(x => x.ObjectId).Distinct().ToList();
var entityRootBehaviour = solutionComponents
.Where(x => x.ComponentType == 1)
.GroupBy(x => x.ObjectId)
.ToDictionary(g => g.Key, g =>
{
// If any solution includes all attributes (0), use that, otherwise use the first occurrence
var behaviors = g.Select(x => x.RootComponentBehavior).ToList();
return behaviors.Contains(0) ? 0 : behaviors.First();
});
var attributesInSolution = solutionComponents.Where(x => x.ComponentType == 2).Select(x => x.ObjectId).ToHashSet();
var rolesInSolution = solutionComponents.Where(x => x.ComponentType == 20).Select(x => x.ObjectId).ToList();
var rolesInSolution = solutionComponents.Where(x => x.ComponentType == 20).Select(x => x.ObjectId).Distinct().ToList();

var entitiesInSolutionMetadata = await GetEntityMetadata(entitiesInSolution);

Expand Down Expand Up @@ -154,6 +162,8 @@ public DataverseService(IConfiguration configuration, ILogger<DataverseService>
.Select(usage =>
new AttributeWarning($"{attributeDict.Key} was used inside a {usage.ComponentType} component [{usage.Name}]. However, the entity {entityKey} could not be resolved in the provided solutions.")))));

// Create solutions with their components
var solutions = await CreateSolutions(solutionEntities, solutionComponents, allEntityMetadata);

return (records
.Select(x =>
Expand All @@ -173,7 +183,142 @@ public DataverseService(IConfiguration configuration, ILogger<DataverseService>
entityIconMap,
attributeUsages,
configuration);
}), warnings);
}),
warnings,
solutions);
}

private Task<IEnumerable<Solution>> CreateSolutions(
List<Entity> solutionEntities,
IEnumerable<(Guid ObjectId, int ComponentType, int RootComponentBehavior, EntityReference SolutionId)> solutionComponents,
List<EntityMetadata> allEntityMetadata)
{
var solutions = new List<Solution>();

// Create lookup dictionaries for faster access
var entityLookup = allEntityMetadata.ToDictionary(e => e.MetadataId ?? Guid.Empty, e => e);

// Group components by solution
var componentsBySolution = solutionComponents.GroupBy(c => c.SolutionId);

foreach (var solutionGroup in componentsBySolution)
{
var solutionId = solutionGroup.Key;
var solutionEntity = solutionEntities.FirstOrDefault(s => s.GetAttributeValue<Guid>("solutionid") == solutionId.Id);

if (solutionEntity == null) continue;

var solutionName = solutionEntity.GetAttributeValue<string>("friendlyname") ??
solutionEntity.GetAttributeValue<string>("uniquename") ??
"Unknown Solution";

var components = new List<SolutionComponent>();

foreach (var component in solutionGroup)
{
var solutionComponent = CreateSolutionComponent(component, entityLookup, allEntityMetadata);
if (solutionComponent != null)
{
components.Add(solutionComponent);
}
}

solutions.Add(new Solution(solutionName, components));
}

return Task.FromResult(solutions.AsEnumerable());
}

private SolutionComponent? CreateSolutionComponent(
(Guid ObjectId, int ComponentType, int RootComponentBehavior, EntityReference SolutionId) component,
Dictionary<Guid, EntityMetadata> entityLookup,
List<EntityMetadata> allEntityMetadata)
{
try
{
switch (component.ComponentType)
{
case 1: // Entity
// Try to find entity by MetadataId first, then by searching all entities
if (entityLookup.TryGetValue(component.ObjectId, out var entityMetadata))
{
return new SolutionComponent(
entityMetadata.DisplayName?.UserLocalizedLabel?.Label ?? entityMetadata.SchemaName,
entityMetadata.SchemaName,
entityMetadata.Description?.UserLocalizedLabel?.Label ?? string.Empty,
SolutionComponentType.Entity);
}

// Entity lookup by ObjectId is complex in Dataverse, so we'll skip the fallback for now
// The primary lookup by MetadataId should handle most cases
break;

case 2: // Attribute
// Search for attribute across all entities
foreach (var entity in allEntityMetadata)
{
var attribute = entity.Attributes?.FirstOrDefault(a => a.MetadataId == component.ObjectId);
if (attribute != null)
{
return new SolutionComponent(
attribute.DisplayName?.UserLocalizedLabel?.Label ?? attribute.SchemaName,
attribute.SchemaName,
attribute.Description?.UserLocalizedLabel?.Label ?? string.Empty,
SolutionComponentType.Attribute);
}
}
break;

case 3: // Relationship (if you want to add this to the enum later)
// Search for relationships across all entities
foreach (var entity in allEntityMetadata)
{
// Check one-to-many relationships
var oneToMany = entity.OneToManyRelationships?.FirstOrDefault(r => r.MetadataId == component.ObjectId);
if (oneToMany != null)
{
return new SolutionComponent(
oneToMany.SchemaName,
oneToMany.SchemaName,
$"One-to-Many: {entity.SchemaName} -> {oneToMany.ReferencingEntity}",
SolutionComponentType.Relationship);
}

// Check many-to-one relationships
var manyToOne = entity.ManyToOneRelationships?.FirstOrDefault(r => r.MetadataId == component.ObjectId);
if (manyToOne != null)
{
return new SolutionComponent(
manyToOne.SchemaName,
manyToOne.SchemaName,
$"Many-to-One: {entity.SchemaName} -> {manyToOne.ReferencedEntity}",
SolutionComponentType.Relationship);
}

// Check many-to-many relationships
var manyToMany = entity.ManyToManyRelationships?.FirstOrDefault(r => r.MetadataId == component.ObjectId);
if (manyToMany != null)
{
return new SolutionComponent(
manyToMany.SchemaName,
manyToMany.SchemaName,
$"Many-to-Many: {manyToMany.Entity1LogicalName} <-> {manyToMany.Entity2LogicalName}",
SolutionComponentType.Relationship);
}
}
break;

case 20: // Security Role - skip for now as not in enum
case 92: // SDK Message Processing Step (Plugin) - skip for now as not in enum
break;
}
}
catch (Exception ex)
{
logger.LogWarning($"Failed to create solution component for ObjectId {component.ObjectId}, ComponentType {component.ComponentType}: {ex.Message}");
}

return null;
}

private static Record MakeRecord(
Expand Down Expand Up @@ -268,6 +413,7 @@ private static Record MakeRecord(
description?.PrettyDescription(),
entity.IsAuditEnabled.Value,
entity.IsActivity ?? false,
entity.IsCustomEntity ?? false,
entity.OwnershipType ?? OwnershipTypes.UserOwned,
entity.HasNotes ?? false,
attributes,
Expand Down Expand Up @@ -376,7 +522,7 @@ await Parallel.ForEachAsync(
return metadata;
}

private async Task<(string PublisherPrefix, List<Guid> SolutionIds)> GetSolutionIds()
private async Task<(string PublisherPrefix, List<Guid> SolutionIds, List<Entity> SolutionEntities)> GetSolutionIds()
{
var solutionNameArg = configuration["DataverseSolutionNames"];
if (solutionNameArg == null)
Expand All @@ -387,7 +533,7 @@ await Parallel.ForEachAsync(

var resp = await client.RetrieveMultipleAsync(new QueryExpression("solution")
{
ColumnSet = new ColumnSet("publisherid"),
ColumnSet = new ColumnSet("publisherid", "friendlyname", "uniquename", "solutionid"),
Criteria = new FilterExpression(LogicalOperator.And)
{
Conditions =
Expand All @@ -406,14 +552,14 @@ await Parallel.ForEachAsync(

var publisher = await client.RetrieveAsync("publisher", publisherIds[0], new ColumnSet("customizationprefix"));

return (publisher.GetAttributeValue<string>("customizationprefix"), resp.Entities.Select(e => e.GetAttributeValue<Guid>("solutionid")).ToList());
return (publisher.GetAttributeValue<string>("customizationprefix"), resp.Entities.Select(e => e.GetAttributeValue<Guid>("solutionid")).ToList(), resp.Entities.ToList());
}

public async Task<IEnumerable<(Guid ObjectId, int ComponentType, int RootComponentBehavior)>> GetSolutionComponents(List<Guid> solutionIds)
public async Task<IEnumerable<(Guid ObjectId, int ComponentType, int RootComponentBehavior, EntityReference SolutionId)>> GetSolutionComponents(List<Guid> solutionIds)
{
var entityQuery = new QueryExpression("solutioncomponent")
{
ColumnSet = new ColumnSet("objectid", "componenttype", "rootcomponentbehavior"),
ColumnSet = new ColumnSet("objectid", "componenttype", "rootcomponentbehavior", "solutionid"),
Criteria = new FilterExpression(LogicalOperator.And)
{
Conditions =
Expand All @@ -427,7 +573,7 @@ await Parallel.ForEachAsync(
return
(await client.RetrieveMultipleAsync(entityQuery))
.Entities
.Select(e => (e.GetAttributeValue<Guid>("objectid"), e.GetAttributeValue<OptionSetValue>("componenttype").Value, e.Contains("rootcomponentbehavior") ? e.GetAttributeValue<OptionSetValue>("rootcomponentbehavior").Value : -1))
.Select(e => (e.GetAttributeValue<Guid>("objectid"), e.GetAttributeValue<OptionSetValue>("componenttype").Value, e.Contains("rootcomponentbehavior") ? e.GetAttributeValue<OptionSetValue>("rootcomponentbehavior").Value : -1, e.GetAttributeValue<EntityReference>("solutionid")))
.ToList();
}

Expand Down
4 changes: 2 additions & 2 deletions Generator/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
var logger = loggerFactory.CreateLogger<DataverseService>();

var dataverseService = new DataverseService(configuration, logger);
var (entities, warnings) = await dataverseService.GetFilteredMetadata();
var (entities, warnings, solutions) = await dataverseService.GetFilteredMetadata();

var websiteBuilder = new WebsiteBuilder(configuration, entities, warnings);
var websiteBuilder = new WebsiteBuilder(configuration, entities, warnings, solutions);
websiteBuilder.AddData();

14 changes: 12 additions & 2 deletions Generator/WebsiteBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ internal class WebsiteBuilder
private readonly IConfiguration configuration;
private readonly IEnumerable<Record> records;
private readonly IEnumerable<SolutionWarning> warnings;
private readonly IEnumerable<Solution> solutions;
private readonly string OutputFolder;

public WebsiteBuilder(IConfiguration configuration, IEnumerable<Record> records, IEnumerable<SolutionWarning> warnings)
public WebsiteBuilder(IConfiguration configuration, IEnumerable<Record> records, IEnumerable<SolutionWarning> warnings, IEnumerable<Solution> components)
{
this.configuration = configuration;
this.records = records;
this.warnings = warnings;
this.solutions = components;

// Assuming execution in bin/xxx/net8.0
OutputFolder = configuration["OutputFolder"] ?? Path.Combine(System.Reflection.Assembly.GetExecutingAssembly().Location, "../../../../../Website/generated");
Expand All @@ -26,7 +28,7 @@ public WebsiteBuilder(IConfiguration configuration, IEnumerable<Record> records,
internal void AddData()
{
var sb = new StringBuilder();
sb.AppendLine("import { GroupType, SolutionWarningType } from \"@/lib/Types\";");
sb.AppendLine("import { GroupType, SolutionWarningType, SolutionType } 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<string?>("Logo", defaultValue: null);
Expand Down Expand Up @@ -62,7 +64,15 @@ internal void AddData()
{
sb.AppendLine($" {JsonConvert.SerializeObject(warning)},");
}
sb.AppendLine("]");

// SOLUTION COMPONENTS
sb.AppendLine("");
sb.AppendLine("export let Solutions: SolutionType[] = [");
foreach (var solution in solutions)
{
sb.AppendLine($" {JsonConvert.SerializeObject(solution)},");
}
sb.AppendLine("]");

File.WriteAllText(Path.Combine(OutputFolder, "Data.ts"), sb.ToString());
Expand Down
13 changes: 13 additions & 0 deletions Website/app/insights/compliance/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Layout from "@/components/shared/Layout";
import InsightsView from "@/components/insightsview/InsightsView";
import { Suspense } from "react";

export default function InsightsCompliance() {
return (
<Suspense>
<Layout>
<InsightsView />
</Layout>
</Suspense>
)
}
37 changes: 37 additions & 0 deletions Website/app/insights/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use client'

import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Layout from "@/components/shared/Layout";
import InsightsView from "@/components/insightsview/InsightsView";
import { Suspense } from "react";
import { DatamodelDataProvider } from "@/contexts/DatamodelDataContext";

export default function Insights() {
return (
<Suspense>
<DatamodelDataProvider>
<InsightsRedirect />
</DatamodelDataProvider>
</Suspense>
)
}

function InsightsRedirect() {
const router = useRouter();
const searchParams = useSearchParams();

useEffect(() => {
const view = searchParams.get('view');
if (!view) {
// Default to overview view
router.replace('/insights?view=overview');
}
}, [router, searchParams]);

return (
<Layout>
<InsightsView />
</Layout>
);
}
13 changes: 13 additions & 0 deletions Website/app/insights/solutions/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Layout from "@/components/shared/Layout";
import InsightsView from "@/components/insightsview/InsightsView";
import { Suspense } from "react";

export default function InsightsSolutions() {
return (
<Suspense>
<Layout>
<InsightsView />
</Layout>
</Suspense>
)
}
Loading
Loading