diff --git a/Generator/DTO/Record.cs b/Generator/DTO/Record.cs index fa4ceee..c6386c6 100644 --- a/Generator/DTO/Record.cs +++ b/Generator/DTO/Record.cs @@ -10,6 +10,7 @@ internal record Record( string? Description, bool IsAuditEnabled, bool IsActivity, + bool IsCustom, OwnershipTypes Ownership, bool IsNotesEnabled, List Attributes, diff --git a/Generator/DTO/Solution.cs b/Generator/DTO/Solution.cs new file mode 100644 index 0000000..a5f2d46 --- /dev/null +++ b/Generator/DTO/Solution.cs @@ -0,0 +1,5 @@ +namespace Generator.DTO; + +public record Solution( + string Name, + IEnumerable Components); diff --git a/Generator/DTO/SolutionComponent.cs b/Generator/DTO/SolutionComponent.cs new file mode 100644 index 0000000..babfd4f --- /dev/null +++ b/Generator/DTO/SolutionComponent.cs @@ -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); diff --git a/Generator/DataverseService.cs b/Generator/DataverseService.cs index 7b95dd3..4886709 100644 --- a/Generator/DataverseService.cs +++ b/Generator/DataverseService.cs @@ -54,16 +54,24 @@ public DataverseService(IConfiguration configuration, ILogger webResourceAnalyzer = new WebResourceAnalyzer(client, configuration); } - public async Task<(IEnumerable, IEnumerable)> GetFilteredMetadata() + public async Task<(IEnumerable, IEnumerable, IEnumerable)> GetFilteredMetadata() { var warnings = new List(); // 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); @@ -154,6 +162,8 @@ public DataverseService(IConfiguration configuration, ILogger .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 => @@ -173,7 +183,142 @@ public DataverseService(IConfiguration configuration, ILogger entityIconMap, attributeUsages, configuration); - }), warnings); + }), + warnings, + solutions); + } + + private Task> CreateSolutions( + List solutionEntities, + IEnumerable<(Guid ObjectId, int ComponentType, int RootComponentBehavior, EntityReference SolutionId)> solutionComponents, + List allEntityMetadata) + { + var solutions = new List(); + + // 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("solutionid") == solutionId.Id); + + if (solutionEntity == null) continue; + + var solutionName = solutionEntity.GetAttributeValue("friendlyname") ?? + solutionEntity.GetAttributeValue("uniquename") ?? + "Unknown Solution"; + + var components = new List(); + + 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 entityLookup, + List 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( @@ -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, @@ -376,7 +522,7 @@ await Parallel.ForEachAsync( return metadata; } - private async Task<(string PublisherPrefix, List SolutionIds)> GetSolutionIds() + private async Task<(string PublisherPrefix, List SolutionIds, List SolutionEntities)> GetSolutionIds() { var solutionNameArg = configuration["DataverseSolutionNames"]; if (solutionNameArg == null) @@ -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 = @@ -406,14 +552,14 @@ await Parallel.ForEachAsync( var publisher = await client.RetrieveAsync("publisher", publisherIds[0], new ColumnSet("customizationprefix")); - return (publisher.GetAttributeValue("customizationprefix"), resp.Entities.Select(e => e.GetAttributeValue("solutionid")).ToList()); + return (publisher.GetAttributeValue("customizationprefix"), resp.Entities.Select(e => e.GetAttributeValue("solutionid")).ToList(), resp.Entities.ToList()); } - public async Task> GetSolutionComponents(List solutionIds) + public async Task> GetSolutionComponents(List 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 = @@ -427,7 +573,7 @@ await Parallel.ForEachAsync( return (await client.RetrieveMultipleAsync(entityQuery)) .Entities - .Select(e => (e.GetAttributeValue("objectid"), e.GetAttributeValue("componenttype").Value, e.Contains("rootcomponentbehavior") ? e.GetAttributeValue("rootcomponentbehavior").Value : -1)) + .Select(e => (e.GetAttributeValue("objectid"), e.GetAttributeValue("componenttype").Value, e.Contains("rootcomponentbehavior") ? e.GetAttributeValue("rootcomponentbehavior").Value : -1, e.GetAttributeValue("solutionid"))) .ToList(); } diff --git a/Generator/Program.cs b/Generator/Program.cs index 255f86d..11c451b 100644 --- a/Generator/Program.cs +++ b/Generator/Program.cs @@ -18,8 +18,8 @@ var logger = loggerFactory.CreateLogger(); 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(); diff --git a/Generator/WebsiteBuilder.cs b/Generator/WebsiteBuilder.cs index 66198da..e4ade62 100644 --- a/Generator/WebsiteBuilder.cs +++ b/Generator/WebsiteBuilder.cs @@ -11,13 +11,15 @@ internal class WebsiteBuilder private readonly IConfiguration configuration; private readonly IEnumerable records; private readonly IEnumerable warnings; + private readonly IEnumerable solutions; private readonly string OutputFolder; - public WebsiteBuilder(IConfiguration configuration, IEnumerable records, IEnumerable warnings) + public WebsiteBuilder(IConfiguration configuration, IEnumerable records, IEnumerable warnings, IEnumerable 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"); @@ -26,7 +28,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, 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("Logo", defaultValue: null); @@ -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()); diff --git a/Website/app/insights/compliance/page.tsx b/Website/app/insights/compliance/page.tsx new file mode 100644 index 0000000..8234dca --- /dev/null +++ b/Website/app/insights/compliance/page.tsx @@ -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 ( + + + + + + ) +} \ No newline at end of file diff --git a/Website/app/insights/page.tsx b/Website/app/insights/page.tsx new file mode 100644 index 0000000..0c0abb1 --- /dev/null +++ b/Website/app/insights/page.tsx @@ -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 ( + + + + + + ) +} + +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 ( + + + + ); +} \ No newline at end of file diff --git a/Website/app/insights/solutions/page.tsx b/Website/app/insights/solutions/page.tsx new file mode 100644 index 0000000..a1971bd --- /dev/null +++ b/Website/app/insights/solutions/page.tsx @@ -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 ( + + + + + + ) +} \ No newline at end of file diff --git a/Website/components/datamodelview/List.tsx b/Website/components/datamodelview/List.tsx index 68d1da7..d12ee41 100644 --- a/Website/components/datamodelview/List.tsx +++ b/Website/components/datamodelview/List.tsx @@ -234,10 +234,10 @@ export const List = ({ setCurrentIndex }: IListProps) => { {/* Virtualized list */}
{ // Carousel data const carouselItems: CarouselItem[] = [ + { + image: '/insights.jpg', + title: 'Insights are here!', + text: "Get insights into your solutions, entities and attributes with the new Insights feature. Analyze your solutions' relationships and shared components to optimize your environment. See bad practices and get recommendations to improve your data model.", + type: '(v2.1.0) Feature Release', + actionlabel: 'Go to Insights', + action: () => router.push('/insights') + }, { image: '/processes.jpg', title: 'Webresource support!', diff --git a/Website/components/insightsview/InsightsView.tsx b/Website/components/insightsview/InsightsView.tsx new file mode 100644 index 0000000..a978ec6 --- /dev/null +++ b/Website/components/insightsview/InsightsView.tsx @@ -0,0 +1,67 @@ +'use client' + +import React, { useEffect } from 'react' +import { Box, Typography } from '@mui/material' +import { useSidebar } from '@/contexts/SidebarContext' +import { useSearchParams } from 'next/navigation' +import SidebarInsightsView from './SidebarInsightsView' +import InsightsSolutionView from './solutions/InsightsSolutionView' +import InsightsOverviewView from './overview/InsightsOverviewView' + +interface InsightsViewProps { + +} + +const InsightsView = ({ }: InsightsViewProps) => { + const { setElement, expand } = useSidebar(); + const searchParams = useSearchParams(); + const currentView = searchParams.get('view') || 'overview'; + + useEffect(() => { + setElement(); + expand(); + }, [setElement, expand]); + + const renderContent = () => { + switch (currentView) { + case 'overview': + return ( + + ); + case 'solutions': + return ( + + ); + case 'compliance': + return ( + + + Compliance + + + Review compliance and governance insights for your data model. + + + ); + default: + return ( + + + Insights + + + Select a category from the sidebar to view insights. + + + ); + } + }; + + return ( + + {renderContent()} + + ) +} + +export default InsightsView \ No newline at end of file diff --git a/Website/components/insightsview/SidebarInsightsView.tsx b/Website/components/insightsview/SidebarInsightsView.tsx new file mode 100644 index 0000000..856bd7f --- /dev/null +++ b/Website/components/insightsview/SidebarInsightsView.tsx @@ -0,0 +1,129 @@ +'use client' + +import React from 'react'; +import { Box, Typography, List, ListItem, ListItemButton, ListItemText, ListItemIcon } from '@mui/material'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useSidebar } from '@/contexts/SidebarContext'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { OverviewIcon, SolutionIcon, ComplianceIcon } from '@/lib/icons'; + +interface SidebarInsightsViewProps { + +} + +interface InsightsSubMenuItem { + label: string; + value: string; + icon: React.ReactNode; + disabled?: boolean; +} + +const SidebarInsightsView = ({ }: SidebarInsightsViewProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const { close: closeSidebar } = useSidebar(); + const isMobile = useIsMobile(); + + const currentView = searchParams.get('view') || 'solutions'; + + const menuItems: InsightsSubMenuItem[] = [ + { + label: 'Overview', + value: 'overview', + icon: OverviewIcon, + }, + { + label: 'Solutions', + value: 'solutions', + icon: SolutionIcon, + }, + { + label: 'Compliance', + value: 'compliance', + icon: ComplianceIcon, + disabled: true, + }, + ]; + + const handleMenuItemClick = (value: string, disabled?: boolean) => { + if (disabled) return; + + const newUrl = `/insights?view=${value}`; + router.push(newUrl); + + if (isMobile) { + closeSidebar(); + } + }; + + return ( + + + Insights + + + + {menuItems.map((item) => ( + + handleMenuItemClick(item.value, item.disabled)} + disabled={item.disabled} + selected={currentView === item.value} + className="rounded-lg" + sx={{ + borderRadius: '8px', + '&.Mui-selected': { + backgroundColor: (theme) => theme.palette.primary.main + '1F', + '&:hover': { + backgroundColor: (theme) => theme.palette.primary.main + '29', + }, + }, + '&.Mui-disabled': { + opacity: 0.5, + }, + }} + > + + {item.icon} + + + {item.disabled && ( + + Coming soon + + )} + + + ))} + + + ); +}; + +export default SidebarInsightsView; \ No newline at end of file diff --git a/Website/components/insightsview/overview/InsightsOverviewView.tsx b/Website/components/insightsview/overview/InsightsOverviewView.tsx new file mode 100644 index 0000000..2a33af7 --- /dev/null +++ b/Website/components/insightsview/overview/InsightsOverviewView.tsx @@ -0,0 +1,322 @@ +import { InfoCard } from "@/components/shared/elements/InfoCard"; +import { useDatamodelData } from "@/contexts/DatamodelDataContext"; +import { ComponentIcon, InfoIcon, ProcessesIcon, SolutionIcon, WarningIcon } from "@/lib/icons"; +import { generateLiquidCheeseSVG } from "@/lib/svgart"; +import { Box, Grid, Paper, Stack, Tooltip, Typography, useTheme } from "@mui/material"; +import { ResponsiveBar } from "@nivo/bar"; +import { useMemo } from "react"; + +interface InsightsOverviewViewProps { + +} + +const InsightsOverviewView = ({ }: InsightsOverviewViewProps) => { + const theme = useTheme(); + + const { groups, solutions } = useDatamodelData(); + + const totalAttributeUsageCount = useMemo(() => { + return groups.reduce((acc, group) => acc + group.Entities.reduce((acc, entity) => acc + entity.Attributes.reduce((acc, attr) => acc + attr.AttributeUsages.length, 0), 0), 0); + }, [groups]) + + const missingIconEntities = useMemo(() => { + const iconsMissing = groups.flatMap(group => group.Entities.filter(entity => !entity.IconBase64)); + return iconsMissing; + }, [groups]); + + const ungroupedEntities = useMemo(() => { + const ungrouped = groups.find(g => g.Name.toLowerCase() === "ungrouped"); + return ungrouped ? ungrouped.Entities : []; + }, [groups]); + + const barChartData = useMemo(() => { + // Get all entities from all groups + const allEntities = groups.flatMap(group => group.Entities); + + // Count entities + const standardEntities = allEntities.filter(entity => !entity.IsCustom); + const customEntities = allEntities.filter(entity => entity.IsCustom); + + // Count attributes + const allAttributes = allEntities.flatMap(entity => entity.Attributes); + const standardAttributes = allAttributes.filter(attr => !attr.IsCustomAttribute); + const customAttributes = allAttributes.filter(attr => attr.IsCustomAttribute); + + // Count relationships + const allRelationships = allEntities.flatMap(entity => entity.Relationships); + const standardRelationships = allRelationships.filter(rel => !rel.IsCustom); + const customRelationships = allRelationships.filter(rel => rel.IsCustom); + + return [ + { + category: 'Entities', + standard: standardEntities.length, + custom: customEntities.length, + }, + { + category: 'Attributes', + standard: standardAttributes.length, + custom: customAttributes.length, + }, + { + category: 'Relationships', + standard: standardRelationships.length, + custom: customRelationships.length, + } + ]; + }, [groups]); + + return ( + + + + + + Insights + + + All your insights in one place. Keep track of your data model's health and status.
Stay informed about any potential issues or areas for improvement. +
+
+
+
+ + + + + {/* Ungrouped Entities */} + entity.SchemaName).join(", ")}> + + {WarningIcon} + {ungroupedEntities.length} + Entities ungrouped + + + + {/* No Icon Entities */} + entity.SchemaName).join(", ")}> + + {WarningIcon} + {missingIconEntities.length} + Entities without icons + + + + {/* No Icon Entities */} + + + {InfoIcon} + 0 + More coming soon + + + + + + + + + + + + acc + solution.Components.length, 0)} + iconSrc={ComponentIcon} + /> + + + + + + + + + + Data Model Distribution: Standard vs Custom + + + `${e.id}: ${e.formattedValue} in category: ${e.indexValue}`} + theme={{ + background: 'transparent', + text: { + fontSize: 12, + fill: theme.palette.text.primary, + outlineWidth: 0, + outlineColor: 'transparent' + }, + axis: { + domain: { + line: { + stroke: theme.palette.divider, + strokeWidth: 1 + } + }, + legend: { + text: { + fontSize: 12, + fill: theme.palette.text.primary + } + }, + ticks: { + line: { + stroke: theme.palette.divider, + strokeWidth: 1 + }, + text: { + fontSize: 11, + fill: theme.palette.text.primary + } + } + }, + grid: { + line: { + stroke: theme.palette.divider, + strokeWidth: 1, + strokeDasharray: '4 4' + } + }, + legends: { + title: { + text: { + fontSize: 11, + fill: theme.palette.text.primary + } + }, + text: { + fontSize: 11, + fill: theme.palette.text.primary + }, + ticks: { + line: {}, + text: { + fontSize: 10, + fill: theme.palette.text.primary + } + } + }, + annotations: { + text: { + fontSize: 13, + fill: theme.palette.text.primary, + outlineWidth: 2, + outlineColor: theme.palette.background.default, + outlineOpacity: 1 + }, + link: { + stroke: theme.palette.text.primary, + strokeWidth: 1, + outlineWidth: 2, + outlineColor: theme.palette.background.default, + outlineOpacity: 1 + }, + outline: { + stroke: theme.palette.text.primary, + strokeWidth: 2, + outlineWidth: 2, + outlineColor: theme.palette.background.default, + outlineOpacity: 1 + }, + symbol: { + fill: theme.palette.text.primary, + outlineWidth: 2, + outlineColor: theme.palette.background.default, + outlineOpacity: 1 + } + }, + tooltip: { + container: { + background: theme.palette.background.paper, + color: theme.palette.text.primary, + } + } + }} + /> + + + +
+ ) +} + +export default InsightsOverviewView; \ No newline at end of file diff --git a/Website/components/insightsview/solutions/InsightsSolutionView.tsx b/Website/components/insightsview/solutions/InsightsSolutionView.tsx new file mode 100644 index 0000000..bf6df3d --- /dev/null +++ b/Website/components/insightsview/solutions/InsightsSolutionView.tsx @@ -0,0 +1,296 @@ +import { useDatamodelData } from '@/contexts/DatamodelDataContext' +import { Paper, Typography, Box, Grid, useTheme } from '@mui/material' +import React, { useMemo, useState } from 'react' +import { ResponsiveChord, RibbonDatum } from '@nivo/chord' +import { SolutionComponentType, SolutionComponentTypeEnum } from '@/lib/Types' +import { generateEnvelopeSVG } from '@/lib/svgart' + +interface InsightsSolutionViewProps { + +} + +const InsightsSolutionView = ({ }: InsightsSolutionViewProps) => { + const { solutions } = useDatamodelData(); + const theme = useTheme(); + + const [selectedSolution, setSelectedSolution] = useState<{ sourceSolution: { name: string; color: string }; targetSolution: { name: string; color: string }; sharedComponents: SolutionComponentType[] } | undefined>(undefined); + + const chordData = useMemo(() => { + if (!solutions || solutions.length === 0) { + return { matrix: [], keys: [] }; + } + + // Create a mapping of components shared between solutions + const componentMap = new Map>(); + const solutionNames = solutions.map(sol => sol.Name); + + // Track which solutions contain each component + solutions.forEach(solution => { + solution.Components.forEach(component => { + if (!componentMap.has(component.SchemaName)) { + componentMap.set(component.SchemaName, new Set()); + } + componentMap.get(component.SchemaName)!.add(solution.Name); + }); + }); + + // Create matrix showing relationships between solutions based on shared components + const matrix = solutionNames.map(solutionA => + solutionNames.map(solutionB => { + const solutionAComponents = solutions.find(s => s.Name === solutionA)?.Components || []; + const solutionBComponents = solutions.find(s => s.Name === solutionB)?.Components || []; + + if (solutionA === solutionB) { + // For self-reference, return the total number of components in the solution + return solutionAComponents.length; + } + + let sharedComponents = 0; + solutionAComponents.forEach(componentA => { + if (solutionBComponents.some(componentB => + componentB.SchemaName === componentA.SchemaName && + componentB.ComponentType === componentA.ComponentType + )) { + sharedComponents++; + } + }); + + return sharedComponents; + }) + ); + + return { + matrix, + keys: solutionNames + }; + }, [solutions]); + + const hasData = chordData.keys.length > 0 && + chordData.matrix.some(row => row.some(value => value > 0)); + + const onRibbonSelect = ({ source, target }: RibbonDatum) => { + if (source.id === target.id) return <>; + const sourceSolution = chordData.keys[source.index]; + const targetSolution = chordData.keys[target.index]; + + // Get the actual solutions data for more details + const sourceSolutionData = solutions?.find(s => s.Name === sourceSolution); + const targetSolutionData = solutions?.find(s => s.Name === targetSolution); + + // Calculate shared components for detailed info + const sourceComponents = sourceSolutionData?.Components || []; + const targetComponents = targetSolutionData?.Components || []; + + const sharedComponents = sourceComponents.filter(sourceComp => + targetComponents.some(targetComp => + targetComp.SchemaName === sourceComp.SchemaName && + targetComp.ComponentType === sourceComp.ComponentType + ) + ); + + setSelectedSolution({ sourceSolution: { name: sourceSolution, color: source.color }, targetSolution: { name: targetSolution, color: target.color }, sharedComponents }); + } + + const RibbonTooltip = ({ source, target }: RibbonDatum) => { + if (source.id === target.id) return <>; + + const sourceSolution = chordData.keys[source.index]; + const targetSolution = chordData.keys[target.index]; + const sharedCount = chordData.matrix[source.index][target.index]; + + // Get the actual solutions data for more details + const sourceSolutionData = solutions?.find(s => s.Name === sourceSolution); + const targetSolutionData = solutions?.find(s => s.Name === targetSolution); + + // Calculate shared components for detailed info + const sourceComponents = sourceSolutionData?.Components || []; + const targetComponents = targetSolutionData?.Components || []; + + const sharedComponents = sourceComponents.filter(sourceComp => + targetComponents.some(targetComp => + targetComp.SchemaName === sourceComp.SchemaName && + targetComp.ComponentType === sourceComp.ComponentType + ) + ); + + if (!solutions || solutions.length === 0) { + return ( + + + Solutions + + + No solution data available to analyze component relationships. + + + ); + } + + return ( + + + {sourceSolution} ↔ {targetSolution} + + + {sharedCount} shared component{sharedCount !== 1 ? 's' : ''} + + {sharedComponents.length > 0 && ( + + + Shared Components: + + {sharedComponents.slice(0, 5).map((component, index) => ( + + • {component.Name} ({component.ComponentType === SolutionComponentTypeEnum.Entity ? 'Entity' : + component.ComponentType === SolutionComponentTypeEnum.Attribute ? 'Attribute' : 'Relationship'}) + + ))} + {sharedComponents.length > 5 && ( + + ... and {sharedComponents.length - 5} more + + )} + + )} + + ); + }; + + return ( + + + + + Solution Insights + + + Explore your solutions and their interconnections. Identify shared components and understand how your solutions relate to each other. + + + + + + + Solution Relations + + + This chord diagram visualizes shared components between different solutions. + The thickness of connections indicates the number of components shared between solutions. + + + {hasData ? ( + + } + onRibbonClick={ribbon => onRibbonSelect(ribbon)} + enableLabel={true} + label="id" + labelOffset={12} + labelTextColor={{ from: 'color', modifiers: [['darker', 1]] }} + colors={{ scheme: 'category10' }} + isInteractive={true} + animate={true} + motionConfig="gentle" + theme={{ + text: { + color: "text.primary", + fontSize: 12, + fontWeight: 600 + }, + }} + /> + + ) : ( + + + No shared components found between solutions. + Each solution appears to have unique components. + + + )} + + + + + + + Solution Summary + + {selectedSolution ? ( + + + {selectedSolution.sourceSolution.name} -and- {selectedSolution.targetSolution.name} + + + {selectedSolution.sharedComponents.length > 0 ? ( + + + Shared Components: ({selectedSolution.sharedComponents.length}) + +
    + {selectedSolution.sharedComponents.map(component => ( +
  • + + {component.Name} ({ + component.ComponentType === SolutionComponentTypeEnum.Entity + ? 'Entity' + : component.ComponentType === SolutionComponentTypeEnum.Attribute + ? 'Attribute' + : component.ComponentType === SolutionComponentTypeEnum.Relationship + ? 'Relationship' + : 'Unknown' + }) + +
  • + ))} +
+
+ ) : ( + + No shared components found. + + )} +
+
+ ) : ( + + Select a connection in the chord diagram to see details about shared components between solutions. + + )} +
+
+
+ ) +} + +export default InsightsSolutionView; diff --git a/Website/components/shared/Header.tsx b/Website/components/shared/Header.tsx index 75c512e..5001ce9 100644 --- a/Website/components/shared/Header.tsx +++ b/Website/components/shared/Header.tsx @@ -6,6 +6,7 @@ import { AppBar, Toolbar, Box, LinearProgress, Button, Stack } from '@mui/materi import SettingsPane from './elements/SettingsPane'; import { useIsMobile } from '@/hooks/use-mobile'; import { useSidebar } from '@/contexts/SidebarContext'; +import { InfoIcon } from '@/lib/icons'; interface HeaderProps { @@ -98,10 +99,7 @@ const Header = ({ }: HeaderProps) => { className='flex flex-col items-center p-1.5 rounded-lg text-gray-400 min-w-0' href="/about" > - - - - + {InfoIcon}