Skip to content

Commit 0836827

Browse files
authored
Merge pull request #66 from delegateas/features/insights-page
2.1.0 - Initial insights page with simple stats
2 parents a7c52b9 + 9b4afaa commit 0836827

File tree

29 files changed

+1540
-46
lines changed

29 files changed

+1540
-46
lines changed

Generator/DTO/Record.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ internal record Record(
1010
string? Description,
1111
bool IsAuditEnabled,
1212
bool IsActivity,
13+
bool IsCustom,
1314
OwnershipTypes Ownership,
1415
bool IsNotesEnabled,
1516
List<Attribute> Attributes,

Generator/DTO/Solution.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
namespace Generator.DTO;
2+
3+
public record Solution(
4+
string Name,
5+
IEnumerable<SolutionComponent> Components);

Generator/DTO/SolutionComponent.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace Generator.DTO;
2+
3+
public enum SolutionComponentType
4+
{
5+
Entity = 1,
6+
Attribute = 2,
7+
Relationship = 3,
8+
}
9+
10+
public record SolutionComponent(
11+
string Name,
12+
string SchemaName,
13+
string Description,
14+
SolutionComponentType ComponentType);

Generator/DataverseService.cs

Lines changed: 160 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,24 @@ public DataverseService(IConfiguration configuration, ILogger<DataverseService>
5454
webResourceAnalyzer = new WebResourceAnalyzer(client, configuration);
5555
}
5656

57-
public async Task<(IEnumerable<Record>, IEnumerable<SolutionWarning>)> GetFilteredMetadata()
57+
public async Task<(IEnumerable<Record>, IEnumerable<SolutionWarning>, IEnumerable<Solution>)> GetFilteredMetadata()
5858
{
5959
var warnings = new List<SolutionWarning>(); // used to collect warnings for the insights dashboard
60-
var (publisherPrefix, solutionIds) = await GetSolutionIds();
61-
var solutionComponents = await GetSolutionComponents(solutionIds); // (id, type, rootcomponentbehavior)
62-
63-
var entitiesInSolution = solutionComponents.Where(x => x.ComponentType == 1).Select(x => x.ObjectId).ToList();
64-
var entityRootBehaviour = solutionComponents.Where(x => x.ComponentType == 1).ToDictionary(x => x.ObjectId, x => x.RootComponentBehavior);
60+
var (publisherPrefix, solutionIds, solutionEntities) = await GetSolutionIds();
61+
var solutionComponents = await GetSolutionComponents(solutionIds); // (id, type, rootcomponentbehavior, solutionid)
62+
63+
var entitiesInSolution = solutionComponents.Where(x => x.ComponentType == 1).Select(x => x.ObjectId).Distinct().ToList();
64+
var entityRootBehaviour = solutionComponents
65+
.Where(x => x.ComponentType == 1)
66+
.GroupBy(x => x.ObjectId)
67+
.ToDictionary(g => g.Key, g =>
68+
{
69+
// If any solution includes all attributes (0), use that, otherwise use the first occurrence
70+
var behaviors = g.Select(x => x.RootComponentBehavior).ToList();
71+
return behaviors.Contains(0) ? 0 : behaviors.First();
72+
});
6573
var attributesInSolution = solutionComponents.Where(x => x.ComponentType == 2).Select(x => x.ObjectId).ToHashSet();
66-
var rolesInSolution = solutionComponents.Where(x => x.ComponentType == 20).Select(x => x.ObjectId).ToList();
74+
var rolesInSolution = solutionComponents.Where(x => x.ComponentType == 20).Select(x => x.ObjectId).Distinct().ToList();
6775

6876
var entitiesInSolutionMetadata = await GetEntityMetadata(entitiesInSolution);
6977

@@ -154,6 +162,8 @@ public DataverseService(IConfiguration configuration, ILogger<DataverseService>
154162
.Select(usage =>
155163
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.")))));
156164

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

158168
return (records
159169
.Select(x =>
@@ -173,7 +183,142 @@ public DataverseService(IConfiguration configuration, ILogger<DataverseService>
173183
entityIconMap,
174184
attributeUsages,
175185
configuration);
176-
}), warnings);
186+
}),
187+
warnings,
188+
solutions);
189+
}
190+
191+
private Task<IEnumerable<Solution>> CreateSolutions(
192+
List<Entity> solutionEntities,
193+
IEnumerable<(Guid ObjectId, int ComponentType, int RootComponentBehavior, EntityReference SolutionId)> solutionComponents,
194+
List<EntityMetadata> allEntityMetadata)
195+
{
196+
var solutions = new List<Solution>();
197+
198+
// Create lookup dictionaries for faster access
199+
var entityLookup = allEntityMetadata.ToDictionary(e => e.MetadataId ?? Guid.Empty, e => e);
200+
201+
// Group components by solution
202+
var componentsBySolution = solutionComponents.GroupBy(c => c.SolutionId);
203+
204+
foreach (var solutionGroup in componentsBySolution)
205+
{
206+
var solutionId = solutionGroup.Key;
207+
var solutionEntity = solutionEntities.FirstOrDefault(s => s.GetAttributeValue<Guid>("solutionid") == solutionId.Id);
208+
209+
if (solutionEntity == null) continue;
210+
211+
var solutionName = solutionEntity.GetAttributeValue<string>("friendlyname") ??
212+
solutionEntity.GetAttributeValue<string>("uniquename") ??
213+
"Unknown Solution";
214+
215+
var components = new List<SolutionComponent>();
216+
217+
foreach (var component in solutionGroup)
218+
{
219+
var solutionComponent = CreateSolutionComponent(component, entityLookup, allEntityMetadata);
220+
if (solutionComponent != null)
221+
{
222+
components.Add(solutionComponent);
223+
}
224+
}
225+
226+
solutions.Add(new Solution(solutionName, components));
227+
}
228+
229+
return Task.FromResult(solutions.AsEnumerable());
230+
}
231+
232+
private SolutionComponent? CreateSolutionComponent(
233+
(Guid ObjectId, int ComponentType, int RootComponentBehavior, EntityReference SolutionId) component,
234+
Dictionary<Guid, EntityMetadata> entityLookup,
235+
List<EntityMetadata> allEntityMetadata)
236+
{
237+
try
238+
{
239+
switch (component.ComponentType)
240+
{
241+
case 1: // Entity
242+
// Try to find entity by MetadataId first, then by searching all entities
243+
if (entityLookup.TryGetValue(component.ObjectId, out var entityMetadata))
244+
{
245+
return new SolutionComponent(
246+
entityMetadata.DisplayName?.UserLocalizedLabel?.Label ?? entityMetadata.SchemaName,
247+
entityMetadata.SchemaName,
248+
entityMetadata.Description?.UserLocalizedLabel?.Label ?? string.Empty,
249+
SolutionComponentType.Entity);
250+
}
251+
252+
// Entity lookup by ObjectId is complex in Dataverse, so we'll skip the fallback for now
253+
// The primary lookup by MetadataId should handle most cases
254+
break;
255+
256+
case 2: // Attribute
257+
// Search for attribute across all entities
258+
foreach (var entity in allEntityMetadata)
259+
{
260+
var attribute = entity.Attributes?.FirstOrDefault(a => a.MetadataId == component.ObjectId);
261+
if (attribute != null)
262+
{
263+
return new SolutionComponent(
264+
attribute.DisplayName?.UserLocalizedLabel?.Label ?? attribute.SchemaName,
265+
attribute.SchemaName,
266+
attribute.Description?.UserLocalizedLabel?.Label ?? string.Empty,
267+
SolutionComponentType.Attribute);
268+
}
269+
}
270+
break;
271+
272+
case 3: // Relationship (if you want to add this to the enum later)
273+
// Search for relationships across all entities
274+
foreach (var entity in allEntityMetadata)
275+
{
276+
// Check one-to-many relationships
277+
var oneToMany = entity.OneToManyRelationships?.FirstOrDefault(r => r.MetadataId == component.ObjectId);
278+
if (oneToMany != null)
279+
{
280+
return new SolutionComponent(
281+
oneToMany.SchemaName,
282+
oneToMany.SchemaName,
283+
$"One-to-Many: {entity.SchemaName} -> {oneToMany.ReferencingEntity}",
284+
SolutionComponentType.Relationship);
285+
}
286+
287+
// Check many-to-one relationships
288+
var manyToOne = entity.ManyToOneRelationships?.FirstOrDefault(r => r.MetadataId == component.ObjectId);
289+
if (manyToOne != null)
290+
{
291+
return new SolutionComponent(
292+
manyToOne.SchemaName,
293+
manyToOne.SchemaName,
294+
$"Many-to-One: {entity.SchemaName} -> {manyToOne.ReferencedEntity}",
295+
SolutionComponentType.Relationship);
296+
}
297+
298+
// Check many-to-many relationships
299+
var manyToMany = entity.ManyToManyRelationships?.FirstOrDefault(r => r.MetadataId == component.ObjectId);
300+
if (manyToMany != null)
301+
{
302+
return new SolutionComponent(
303+
manyToMany.SchemaName,
304+
manyToMany.SchemaName,
305+
$"Many-to-Many: {manyToMany.Entity1LogicalName} <-> {manyToMany.Entity2LogicalName}",
306+
SolutionComponentType.Relationship);
307+
}
308+
}
309+
break;
310+
311+
case 20: // Security Role - skip for now as not in enum
312+
case 92: // SDK Message Processing Step (Plugin) - skip for now as not in enum
313+
break;
314+
}
315+
}
316+
catch (Exception ex)
317+
{
318+
logger.LogWarning($"Failed to create solution component for ObjectId {component.ObjectId}, ComponentType {component.ComponentType}: {ex.Message}");
319+
}
320+
321+
return null;
177322
}
178323

179324
private static Record MakeRecord(
@@ -268,6 +413,7 @@ private static Record MakeRecord(
268413
description?.PrettyDescription(),
269414
entity.IsAuditEnabled.Value,
270415
entity.IsActivity ?? false,
416+
entity.IsCustomEntity ?? false,
271417
entity.OwnershipType ?? OwnershipTypes.UserOwned,
272418
entity.HasNotes ?? false,
273419
attributes,
@@ -376,7 +522,7 @@ await Parallel.ForEachAsync(
376522
return metadata;
377523
}
378524

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

388534
var resp = await client.RetrieveMultipleAsync(new QueryExpression("solution")
389535
{
390-
ColumnSet = new ColumnSet("publisherid"),
536+
ColumnSet = new ColumnSet("publisherid", "friendlyname", "uniquename", "solutionid"),
391537
Criteria = new FilterExpression(LogicalOperator.And)
392538
{
393539
Conditions =
@@ -406,14 +552,14 @@ await Parallel.ForEachAsync(
406552

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

409-
return (publisher.GetAttributeValue<string>("customizationprefix"), resp.Entities.Select(e => e.GetAttributeValue<Guid>("solutionid")).ToList());
555+
return (publisher.GetAttributeValue<string>("customizationprefix"), resp.Entities.Select(e => e.GetAttributeValue<Guid>("solutionid")).ToList(), resp.Entities.ToList());
410556
}
411557

412-
public async Task<IEnumerable<(Guid ObjectId, int ComponentType, int RootComponentBehavior)>> GetSolutionComponents(List<Guid> solutionIds)
558+
public async Task<IEnumerable<(Guid ObjectId, int ComponentType, int RootComponentBehavior, EntityReference SolutionId)>> GetSolutionComponents(List<Guid> solutionIds)
413559
{
414560
var entityQuery = new QueryExpression("solutioncomponent")
415561
{
416-
ColumnSet = new ColumnSet("objectid", "componenttype", "rootcomponentbehavior"),
562+
ColumnSet = new ColumnSet("objectid", "componenttype", "rootcomponentbehavior", "solutionid"),
417563
Criteria = new FilterExpression(LogicalOperator.And)
418564
{
419565
Conditions =
@@ -427,7 +573,7 @@ await Parallel.ForEachAsync(
427573
return
428574
(await client.RetrieveMultipleAsync(entityQuery))
429575
.Entities
430-
.Select(e => (e.GetAttributeValue<Guid>("objectid"), e.GetAttributeValue<OptionSetValue>("componenttype").Value, e.Contains("rootcomponentbehavior") ? e.GetAttributeValue<OptionSetValue>("rootcomponentbehavior").Value : -1))
576+
.Select(e => (e.GetAttributeValue<Guid>("objectid"), e.GetAttributeValue<OptionSetValue>("componenttype").Value, e.Contains("rootcomponentbehavior") ? e.GetAttributeValue<OptionSetValue>("rootcomponentbehavior").Value : -1, e.GetAttributeValue<EntityReference>("solutionid")))
431577
.ToList();
432578
}
433579

Generator/Program.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
var logger = loggerFactory.CreateLogger<DataverseService>();
1919

2020
var dataverseService = new DataverseService(configuration, logger);
21-
var (entities, warnings) = await dataverseService.GetFilteredMetadata();
21+
var (entities, warnings, solutions) = await dataverseService.GetFilteredMetadata();
2222

23-
var websiteBuilder = new WebsiteBuilder(configuration, entities, warnings);
23+
var websiteBuilder = new WebsiteBuilder(configuration, entities, warnings, solutions);
2424
websiteBuilder.AddData();
2525

Generator/WebsiteBuilder.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ internal class WebsiteBuilder
1111
private readonly IConfiguration configuration;
1212
private readonly IEnumerable<Record> records;
1313
private readonly IEnumerable<SolutionWarning> warnings;
14+
private readonly IEnumerable<Solution> solutions;
1415
private readonly string OutputFolder;
1516

16-
public WebsiteBuilder(IConfiguration configuration, IEnumerable<Record> records, IEnumerable<SolutionWarning> warnings)
17+
public WebsiteBuilder(IConfiguration configuration, IEnumerable<Record> records, IEnumerable<SolutionWarning> warnings, IEnumerable<Solution> components)
1718
{
1819
this.configuration = configuration;
1920
this.records = records;
2021
this.warnings = warnings;
22+
this.solutions = components;
2123

2224
// Assuming execution in bin/xxx/net8.0
2325
OutputFolder = configuration["OutputFolder"] ?? Path.Combine(System.Reflection.Assembly.GetExecutingAssembly().Location, "../../../../../Website/generated");
@@ -26,7 +28,7 @@ public WebsiteBuilder(IConfiguration configuration, IEnumerable<Record> records,
2628
internal void AddData()
2729
{
2830
var sb = new StringBuilder();
29-
sb.AppendLine("import { GroupType, SolutionWarningType } from \"@/lib/Types\";");
31+
sb.AppendLine("import { GroupType, SolutionWarningType, SolutionType } from \"@/lib/Types\";");
3032
sb.AppendLine("");
3133
sb.AppendLine($"export const LastSynched: Date = new Date('{DateTimeOffset.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ}');");
3234
var logoUrl = configuration.GetValue<string?>("Logo", defaultValue: null);
@@ -62,7 +64,15 @@ internal void AddData()
6264
{
6365
sb.AppendLine($" {JsonConvert.SerializeObject(warning)},");
6466
}
67+
sb.AppendLine("]");
6568

69+
// SOLUTION COMPONENTS
70+
sb.AppendLine("");
71+
sb.AppendLine("export let Solutions: SolutionType[] = [");
72+
foreach (var solution in solutions)
73+
{
74+
sb.AppendLine($" {JsonConvert.SerializeObject(solution)},");
75+
}
6676
sb.AppendLine("]");
6777

6878
File.WriteAllText(Path.Combine(OutputFolder, "Data.ts"), sb.ToString());
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Layout from "@/components/shared/Layout";
2+
import InsightsView from "@/components/insightsview/InsightsView";
3+
import { Suspense } from "react";
4+
5+
export default function InsightsCompliance() {
6+
return (
7+
<Suspense>
8+
<Layout>
9+
<InsightsView />
10+
</Layout>
11+
</Suspense>
12+
)
13+
}

Website/app/insights/page.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use client'
2+
3+
import { useEffect } from "react";
4+
import { useRouter, useSearchParams } from "next/navigation";
5+
import Layout from "@/components/shared/Layout";
6+
import InsightsView from "@/components/insightsview/InsightsView";
7+
import { Suspense } from "react";
8+
import { DatamodelDataProvider } from "@/contexts/DatamodelDataContext";
9+
10+
export default function Insights() {
11+
return (
12+
<Suspense>
13+
<DatamodelDataProvider>
14+
<InsightsRedirect />
15+
</DatamodelDataProvider>
16+
</Suspense>
17+
)
18+
}
19+
20+
function InsightsRedirect() {
21+
const router = useRouter();
22+
const searchParams = useSearchParams();
23+
24+
useEffect(() => {
25+
const view = searchParams.get('view');
26+
if (!view) {
27+
// Default to overview view
28+
router.replace('/insights?view=overview');
29+
}
30+
}, [router, searchParams]);
31+
32+
return (
33+
<Layout>
34+
<InsightsView />
35+
</Layout>
36+
);
37+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Layout from "@/components/shared/Layout";
2+
import InsightsView from "@/components/insightsview/InsightsView";
3+
import { Suspense } from "react";
4+
5+
export default function InsightsSolutions() {
6+
return (
7+
<Suspense>
8+
<Layout>
9+
<InsightsView />
10+
</Layout>
11+
</Suspense>
12+
)
13+
}

0 commit comments

Comments
 (0)