diff --git a/Generator/DTO/Warnings/AttributeWarning.cs b/Generator/DTO/Warnings/AttributeWarning.cs new file mode 100644 index 0000000..3df0e95 --- /dev/null +++ b/Generator/DTO/Warnings/AttributeWarning.cs @@ -0,0 +1,6 @@ +namespace Generator.DTO.Warnings; + +public record AttributeWarning(string Message) : SolutionWarning( + SolutionWarningType.Attribute, + Message + ); diff --git a/Generator/DTO/Warnings/SolutionWarning.cs b/Generator/DTO/Warnings/SolutionWarning.cs new file mode 100644 index 0000000..25871b8 --- /dev/null +++ b/Generator/DTO/Warnings/SolutionWarning.cs @@ -0,0 +1,11 @@ +namespace Generator.DTO.Warnings; + +public enum SolutionWarningType +{ + Attribute, +} + +public record SolutionWarning( + SolutionWarningType Type, + string Message + ); diff --git a/Generator/DTO/WebResource.cs b/Generator/DTO/WebResource.cs new file mode 100644 index 0000000..b69c714 --- /dev/null +++ b/Generator/DTO/WebResource.cs @@ -0,0 +1,10 @@ +using Microsoft.Xrm.Sdk; + +namespace Generator.DTO; + +public record WebResource( + string Id, + string Name, + string Content, + OptionSetValue WebResourceType, + string? Description = null) : Analyzeable(); diff --git a/Generator/DataverseService.cs b/Generator/DataverseService.cs index 259974c..3bf58bd 100644 --- a/Generator/DataverseService.cs +++ b/Generator/DataverseService.cs @@ -2,9 +2,11 @@ using Azure.Identity; using Generator.DTO; using Generator.DTO.Attributes; +using Generator.DTO.Warnings; using Generator.Queries; using Generator.Services; using Generator.Services.Plugins; +using Generator.Services.WebResources; using Microsoft.Crm.Sdk.Messages; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; @@ -14,6 +16,7 @@ using Microsoft.Xrm.Sdk.Metadata; using Microsoft.Xrm.Sdk.Query; using System.Collections.Concurrent; +using System.Diagnostics; using System.Reflection; using Attribute = Generator.DTO.Attributes.Attribute; @@ -27,6 +30,7 @@ internal class DataverseService private readonly PluginAnalyzer pluginAnalyzer; private readonly PowerAutomateFlowAnalyzer flowAnalyzer; + private readonly WebResourceAnalyzer webResourceAnalyzer; public DataverseService(IConfiguration configuration, ILogger logger) { @@ -47,10 +51,12 @@ public DataverseService(IConfiguration configuration, ILogger pluginAnalyzer = new PluginAnalyzer(client); flowAnalyzer = new PowerAutomateFlowAnalyzer(client); + webResourceAnalyzer = new WebResourceAnalyzer(client, configuration); } - public async Task> GetFilteredMetadata() + public async Task<(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) @@ -96,15 +102,32 @@ public async Task> GetFilteredMetadata() // Processes analysis var attributeUsages = new Dictionary>>(); // Plugins + var pluginStopWatch = new Stopwatch(); + pluginStopWatch.Start(); var pluginCollection = await client.GetSDKMessageProcessingStepsAsync(solutionIds); logger.LogInformation($"There are {pluginCollection.Count()} plugin sdk steps in the environment."); foreach (var plugin in pluginCollection) await pluginAnalyzer.AnalyzeComponentAsync(plugin, attributeUsages); + pluginStopWatch.Stop(); + logger.LogInformation($"Plugin analysis took {pluginStopWatch.ElapsedMilliseconds} ms."); // Flows + var flowStopWatch = new Stopwatch(); + flowStopWatch.Start(); var flowCollection = await client.GetPowerAutomateFlowsAsync(solutionIds); logger.LogInformation($"There are {flowCollection.Count()} Power Automate flows in the environment."); foreach (var flow in flowCollection) await flowAnalyzer.AnalyzeComponentAsync(flow, attributeUsages); + flowStopWatch.Stop(); + logger.LogInformation($"Power Automate flow analysis took {flowStopWatch.ElapsedMilliseconds} ms."); + // WebResources + var resourceStopWatch = new Stopwatch(); + resourceStopWatch.Start(); + var webresourceCollection = await client.GetWebResourcesAsync(solutionIds); + logger.LogInformation($"There are {webresourceCollection.Count()} WebResources in the environment."); + foreach (var resource in webresourceCollection) + await webResourceAnalyzer.AnalyzeComponentAsync(resource, attributeUsages); + resourceStopWatch.Stop(); + logger.LogInformation($"WebResource analysis took {resourceStopWatch.ElapsedMilliseconds} ms."); var records = entitiesInSolutionMetadata @@ -123,8 +146,17 @@ public async Task> GetFilteredMetadata() .Where(x => x.EntityMetadata.DisplayName.UserLocalizedLabel?.Label != null) .ToList(); + // Warn about attributes that were used in processes, but the entity could not be resolved from e.g. JavaScript file name or similar + var hash = entitiesInSolutionMetadata.SelectMany(r => [r.LogicalCollectionName?.ToLower() ?? "", r.LogicalName.ToLower()]).ToHashSet(); + warnings.AddRange(attributeUsages.Keys + .Where(k => !hash.Contains(k.ToLower())) + .SelectMany(entityKey => attributeUsages.GetValueOrDefault(entityKey)! + .SelectMany(attributeDict => attributeDict.Value + .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."))))); - return records + + return (records .Select(x => { logicalNameToSecurityRoles.TryGetValue(x.EntityMetadata.LogicalName, out var securityRoles); @@ -142,7 +174,7 @@ public async Task> GetFilteredMetadata() entityIconMap, attributeUsages, configuration); - }); + }), warnings); } private static Record MakeRecord( diff --git a/Generator/Generator.csproj b/Generator/Generator.csproj index e575853..73b95c5 100644 --- a/Generator/Generator.csproj +++ b/Generator/Generator.csproj @@ -13,6 +13,7 @@ + diff --git a/Generator/Program.cs b/Generator/Program.cs index 59b8316..255f86d 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 = (await dataverseService.GetFilteredMetadata()).ToList(); +var (entities, warnings) = await dataverseService.GetFilteredMetadata(); -var websiteBuilder = new WebsiteBuilder(configuration, entities); +var websiteBuilder = new WebsiteBuilder(configuration, entities, warnings); websiteBuilder.AddData(); diff --git a/Generator/Queries/WebResourceQueries.cs b/Generator/Queries/WebResourceQueries.cs new file mode 100644 index 0000000..a30aa4e --- /dev/null +++ b/Generator/Queries/WebResourceQueries.cs @@ -0,0 +1,84 @@ +using Generator.DTO; +using Microsoft.PowerPlatform.Dataverse.Client; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace Generator.Queries; + +public static class WebResourceQueries +{ + + public static async Task> GetWebResourcesAsync(this ServiceClient service, List? solutionIds = null) + { + var query = new QueryExpression("solutioncomponent") + { + ColumnSet = new ColumnSet("objectid"), + Criteria = new FilterExpression(LogicalOperator.And) + { + Conditions = + { + new ConditionExpression("solutionid", ConditionOperator.In, solutionIds), + new ConditionExpression("componenttype", ConditionOperator.Equal, 61) // 61 = Web Resource + } + }, + LinkEntities = + { + new LinkEntity( + "solutioncomponent", + "webresource", + "objectid", + "webresourceid", + JoinOperator.Inner) + { + Columns = new ColumnSet("webresourceid", "name", "content", "webresourcetype", "description"), + EntityAlias = "webresource", + LinkCriteria = new FilterExpression + { + Conditions = + { + new ConditionExpression("webresourcetype", ConditionOperator.Equal, 3) // JS Resources + } + } + } + } + }; + + var results = (await service.RetrieveMultipleAsync(query)).Entities; + + var webResources = results.Select(e => + { + var content = ""; + var contentValue = e.GetAttributeValue("webresource.content")?.Value; + var webresourceId = e.GetAttributeValue("webresource.webresourceid").Value?.ToString() ?? ""; + var webresourceName = e.GetAttributeValue("webresource.name").Value?.ToString(); + if (contentValue != null) + { + // Content is base64 encoded, decode it + var base64Content = contentValue.ToString(); + if (!string.IsNullOrEmpty(base64Content)) + { + try + { + var bytes = Convert.FromBase64String(base64Content); + content = System.Text.Encoding.UTF8.GetString(bytes); + } + catch + { + // If decoding fails, keep the base64 content + content = base64Content; + } + } + } + + return new WebResource( + webresourceId, + webresourceName, + content, + (OptionSetValue)e.GetAttributeValue("webresource.webresourcetype").Value, + e.GetAttributeValue("webresource.description")?.Value?.ToString() + ); + }); + + return webResources; + } +} diff --git a/Generator/Services/ComponentAnalyzerBase.cs b/Generator/Services/ComponentAnalyzerBase.cs index fd6d260..6975537 100644 --- a/Generator/Services/ComponentAnalyzerBase.cs +++ b/Generator/Services/ComponentAnalyzerBase.cs @@ -11,6 +11,22 @@ public abstract class BaseComponentAnalyzer(ServiceClient service) : ICompone public abstract ComponentType SupportedType { get; } public abstract Task AnalyzeComponentAsync(T component, Dictionary>> attributeUsages); + protected void AddAttributeUsage(Dictionary>> attributeUsages, + string entityName, string attributeName, AttributeUsage usage) + { + if (!attributeUsages.ContainsKey(entityName)) + { + attributeUsages[entityName] = new Dictionary>(); + } + + if (!attributeUsages[entityName].ContainsKey(attributeName)) + { + attributeUsages[entityName][attributeName] = new List(); + } + + attributeUsages[entityName][attributeName].Add(usage); + } + protected List ExtractFieldsFromODataFilter(string filter) { var fields = new List(); diff --git a/Generator/Services/Plugins/PluginAnalyzer.cs b/Generator/Services/Plugins/PluginAnalyzer.cs index 745b4d6..5db7e71 100644 --- a/Generator/Services/Plugins/PluginAnalyzer.cs +++ b/Generator/Services/Plugins/PluginAnalyzer.cs @@ -23,17 +23,8 @@ public override async Task AnalyzeComponentAsync(SDKStep sdkStep, Dictionary>(); + AddAttributeUsage(attributeUsages, logicalTableName, attribute, new AttributeUsage(pluginName, $"Used in filterattributes", OperationType.Other, SupportedType)); - if (!attributeUsages[logicalTableName].ContainsKey(attribute)) - attributeUsages[logicalTableName][attribute] = new List(); - - // Add the usage information (assuming AttributeUsage is a defined class) - - attributeUsages[logicalTableName][attribute].Add(new AttributeUsage(pluginName, $"Used in filterattributes", OperationType.Other, SupportedType)); - } } catch (Exception ex) { diff --git a/Generator/Services/Power Automate/PowerAutomateFlowAnalyzer.cs b/Generator/Services/Power Automate/PowerAutomateFlowAnalyzer.cs index b295d1a..de995fc 100644 --- a/Generator/Services/Power Automate/PowerAutomateFlowAnalyzer.cs +++ b/Generator/Services/Power Automate/PowerAutomateFlowAnalyzer.cs @@ -448,20 +448,4 @@ private OperationType DetermineOperationTypeFromAction(string actionName) // Default to Other for unknown actions return OperationType.Other; } - - private void AddAttributeUsage(Dictionary>> attributeUsages, - string entityName, string attributeName, AttributeUsage usage) - { - if (!attributeUsages.ContainsKey(entityName)) - { - attributeUsages[entityName] = new Dictionary>(); - } - - if (!attributeUsages[entityName].ContainsKey(attributeName)) - { - attributeUsages[entityName][attributeName] = new List(); - } - - attributeUsages[entityName][attributeName].Add(usage); - } } diff --git a/Generator/Services/WebResources/WebResourceAnalyzer.cs b/Generator/Services/WebResources/WebResourceAnalyzer.cs new file mode 100644 index 0000000..f1e4b6e --- /dev/null +++ b/Generator/Services/WebResources/WebResourceAnalyzer.cs @@ -0,0 +1,102 @@ +using Generator.DTO; +using Microsoft.Extensions.Configuration; +using Microsoft.PowerPlatform.Dataverse.Client; +using System.Linq.Dynamic.Core; +using System.Text.RegularExpressions; + +namespace Generator.Services.WebResources; + +public class WebResourceAnalyzer : BaseComponentAnalyzer +{ + private readonly Func webresourceNamingFunc; + public WebResourceAnalyzer(ServiceClient service, IConfiguration configuration) : base(service) + { + var lambda = configuration.GetValue("WebResourceNameFunc") ?? "name.Split('.').First()"; + webresourceNamingFunc = DynamicExpressionParser.ParseLambda( + new ParsingConfig { ResolveTypesBySimpleName = true }, + false, + "name => " + lambda + ).Compile(); + } + + public override ComponentType SupportedType => ComponentType.WebResource; + + public override async Task AnalyzeComponentAsync(WebResource webResource, Dictionary>> attributeUsages) + { + try + { + if (string.IsNullOrEmpty(webResource.Content)) + return; + + // Analyze JavaScript content for onChange event handlers and getAttribute calls + AnalyzeOnChangeHandlers(webResource, attributeUsages); + } + catch (Exception ex) + { + Console.WriteLine($"Error analyzing web resource {webResource.Name}: {ex.Message}"); + } + + await Task.CompletedTask; + } + + private void AnalyzeOnChangeHandlers(WebResource webResource, Dictionary>> attributeUsages) + { + var content = webResource.Content; + + var attributeNames = ExtractGetAttributeCalls(content); + + foreach (var attributeName in attributeNames) + { + string entityName = null; + try + { + entityName = webresourceNamingFunc(webResource.Name); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Naming function failed for web resource '{webResource.Name}': {ex.Message}"); + continue; + } + if (string.IsNullOrWhiteSpace(entityName)) + { + Console.WriteLine($"Warning: Naming function returned an invalid value for web resource '{webResource.Name}'. Skipping attribute usage."); + continue; + } + AddAttributeUsage(attributeUsages, entityName.ToLower(), attributeName, new AttributeUsage( + webResource.Name, + $"getAttribute call", + OperationType.Read, + SupportedType + )); + } + } + + // TODO get attributes used in XrmApi or XrmQuery calls + + // TODO get attributes from getControl + + private List ExtractGetAttributeCalls(string code) + { + var attributes = new List(); + + if (string.IsNullOrEmpty(code)) + return attributes; + + // Examples: + // formContext.getAttribute("firstname") + // Xrm.Page.getAttribute("lastname") + // executionContext.getFormContext().getAttribute("email") + // context.getAttribute("phonenumber") + // this.getAttribute("address1_city") + var getAttributePattern = @"(\w+(?:\.\w+)*\.getAttribute)\([""']([^""']+)[""']\)"; + var matches = Regex.Matches(code, getAttributePattern, RegexOptions.IgnoreCase); + + foreach (Match match in matches) + { + var attributeName = match.Groups[2].Value; + attributes.Add(attributeName); + } + + return attributes.Distinct().ToList(); + } +} diff --git a/Generator/UtilityExtensions.cs b/Generator/UtilityExtensions.cs new file mode 100644 index 0000000..0e17d69 --- /dev/null +++ b/Generator/UtilityExtensions.cs @@ -0,0 +1,6 @@ +namespace Generator; + +public static class UtilityExtensions +{ + public static string StripGuid(this string guid) => guid.Replace("{", "").Replace("}", "").ToLower(); +} diff --git a/Generator/WebsiteBuilder.cs b/Generator/WebsiteBuilder.cs index 6a612b5..66198da 100644 --- a/Generator/WebsiteBuilder.cs +++ b/Generator/WebsiteBuilder.cs @@ -1,4 +1,5 @@ using Generator.DTO; +using Generator.DTO.Warnings; using Microsoft.Extensions.Configuration; using Newtonsoft.Json; using System.Text; @@ -8,13 +9,15 @@ namespace Generator; internal class WebsiteBuilder { private readonly IConfiguration configuration; - private readonly List records; + private readonly IEnumerable records; + private readonly IEnumerable warnings; private readonly string OutputFolder; - public WebsiteBuilder(IConfiguration configuration, List records) + public WebsiteBuilder(IConfiguration configuration, IEnumerable records, IEnumerable warnings) { this.configuration = configuration; this.records = records; + this.warnings = warnings; // Assuming execution in bin/xxx/net8.0 OutputFolder = configuration["OutputFolder"] ?? Path.Combine(System.Reflection.Assembly.GetExecutingAssembly().Location, "../../../../../Website/generated"); @@ -23,13 +26,15 @@ public WebsiteBuilder(IConfiguration configuration, List records) internal void AddData() { var sb = new StringBuilder(); - sb.AppendLine("import { GroupType } from \"@/lib/Types\";"); + sb.AppendLine("import { GroupType, SolutionWarningType } 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); var jsValue = logoUrl != null ? $"\"{logoUrl}\"" : "null"; sb.AppendLine($"export const Logo: string | null = {jsValue};"); sb.AppendLine(""); + + // ENTITIES sb.AppendLine("export let Groups: GroupType[] = ["); var groups = records.GroupBy(x => x.Group).OrderBy(x => x.Key); foreach (var group in groups) @@ -50,6 +55,16 @@ internal void AddData() sb.AppendLine("]"); + // WARNINGS + sb.AppendLine(""); + sb.AppendLine("export let SolutionWarnings: SolutionWarningType[] = ["); + foreach (var warning in warnings) + { + sb.AppendLine($" {JsonConvert.SerializeObject(warning)},"); + } + + sb.AppendLine("]"); + File.WriteAllText(Path.Combine(OutputFolder, "Data.ts"), sb.ToString()); } } diff --git a/README.md b/README.md index dbcb4b1..dfdad3b 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,6 @@ Afterwards go into the "Website"-folder from VS Code and open the terminal (of t # Settings in pipeline The pipeline expects a variable group called `DataModel`. It must have the following variables. The app user only requires the `Environment Maker` security role. -* AdoWikiName: Name of your wiki found under "Overview -> Wiki" in ADO. (will be encoded so dont worry about space) -* AdoWikiPagePath: Path to the introduction page you wish to show in DMV. (will also be encoded so dont worry about spaces) * AzureClientId: Client id for an Azure App Registration with access to the Dataverse Environment. * AzureClientSecret: Client Secret for the above. Remember to set its variable type to "Secret"! * AzureTenantId: Azure Tenant ID (where your App Regustration is placed and resource group will be placed). @@ -91,7 +89,10 @@ The pipeline expects a variable group called `DataModel`. It must have the follo * WebsiteName: Used for the url of the web app presenting the data model to the user. The full URL will be in the format "https://wa-{WebsiteName}.azurewebsites.net/" and must be globally unique. * WebsitePassword: Password used by DMV users to login to the generated site. * WebsiteSessionSecret: Key to encrypt the session token with (You can set it to whatever you like, but recommended 32 random characters). -* TableGroups: Enter a semi-colon separated list of group names and for each group a comma-separated list of table schema names within that group. Then this configuration will be used to order the tables in groups in the DMV side-menu. Example: `Org. tables: team, systemuser, businessunit; Sales: opportunity, lead` +* (Optional) TableGroups: Enter a semi-colon separated list of group names and for each group a comma-separated list of table schema names within that group. Then this configuration will be used to order the tables in groups in the DMV side-menu. Example: `Org. tables: team, systemuser, businessunit; Sales: opportunity, lead` +* (Optional) AdoWikiName: Name of your wiki found under "Overview -> Wiki" in ADO. (will be encoded so dont worry about space) +* (Optional) AdoWikiPagePath: Path to the introduction page you wish to show in DMV. (will also be encoded so dont worry about spaces) +* (Optional) WebResourceNameFunc: Function to fetch the entity logicalname from a webresource. The function must be a valid C# LINQ expression that works on the `name` input parameter. Default: `name.Split('.').First()` ## After deployment * Go to portal.azure.com diff --git a/Website/.github/instructions/copilot.instructions.md b/Website/.github/instructions/copilot.instructions.md index 6575040..00bc087 100644 --- a/Website/.github/instructions/copilot.instructions.md +++ b/Website/.github/instructions/copilot.instructions.md @@ -5,3 +5,6 @@ You are an expert in **TypeScript**, **Node.js**, **Next.js App Router**, **Reac # Design specification If you are doing any styling or creating new components, you MUST follow the design guidelines outlined in the [design instructions](.github/instructions/design/design.instructions.md). + +# New functionality +If you implement any new major functionality, you MUST create a news entry for the home page inside the `components/homeview/HomeView.tsx` file. Follow the existing format for news entries. diff --git a/Website/components/datamodelview/dataLoaderWorker.js b/Website/components/datamodelview/dataLoaderWorker.js index df2f774..0fb6036 100644 --- a/Website/components/datamodelview/dataLoaderWorker.js +++ b/Website/components/datamodelview/dataLoaderWorker.js @@ -1,5 +1,5 @@ -import { Groups } from '../../generated/Data'; +import { Groups, SolutionWarnings } from '../../generated/Data'; self.onmessage = function() { - self.postMessage(Groups); + self.postMessage({ groups: Groups, warnings: SolutionWarnings }); }; \ No newline at end of file diff --git a/Website/components/homeview/HomeView.tsx b/Website/components/homeview/HomeView.tsx index b31520d..459870d 100644 --- a/Website/components/homeview/HomeView.tsx +++ b/Website/components/homeview/HomeView.tsx @@ -25,9 +25,9 @@ export const HomeView = ({ }: IHomeViewProps) => { const carouselItems: CarouselItem[] = [ { image: '/processes.jpg', - title: 'Process Explorer!', - text: "Work has started on the process explorer! This will be a place to figure out what processes are touching your fields. Everything from server- to client side.", - type: '(v2.0.0) Alpha Feature', + title: 'Webresource support!', + text: "View your attributes used inside your JS webresources in the Processes Explorer. Now supports the getAttribute method with more to come soon.", + type: '(v2.0.1) Feature update', actionlabel: 'Try it out', action: () => router.push('/processes') }, diff --git a/Website/components/processesview/ProcessesView.tsx b/Website/components/processesview/ProcessesView.tsx index ecf100f..e84a3f5 100644 --- a/Website/components/processesview/ProcessesView.tsx +++ b/Website/components/processesview/ProcessesView.tsx @@ -3,11 +3,12 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react' import { useSidebar } from '@/contexts/SidebarContext' import { useDatamodelData } from '@/contexts/DatamodelDataContext' -import { Box, Typography, Paper, TextField, InputAdornment, Grid, List, ListItem, ListItemButton, ListItemText, Chip, IconButton, Table, TableHead, TableBody, TableRow, TableCell, useTheme, Alert } from '@mui/material' -import { AccountTreeRounded, CloseRounded, ExtensionRounded, SearchRounded } from '@mui/icons-material' -import { AttributeType, EntityType, ComponentType, OperationType } from '@/lib/Types' +import { Box, Typography, Paper, TextField, InputAdornment, Grid, List, ListItem, ListItemButton, ListItemText, Chip, IconButton, Table, TableHead, TableBody, TableRow, TableCell, useTheme, Alert, Divider, Accordion, AccordionSummary, AccordionDetails } from '@mui/material' +import { AccountTreeRounded, CloseRounded, ExtensionRounded, JavascriptRounded, SearchRounded, WarningRounded, ExpandMoreRounded } from '@mui/icons-material' +import { AttributeType, EntityType, ComponentType, OperationType, WarningType } from '@/lib/Types' import LoadingOverlay from '@/components/shared/LoadingOverlay' import NotchedBox from '../shared/elements/NotchedBox' +import { StatCard } from '../shared/elements/StatCard' import { ResponsivePie } from '@nivo/pie' interface IProcessesViewProps { } @@ -20,7 +21,7 @@ interface AttributeSearchResult { export const ProcessesView = ({ }: IProcessesViewProps) => { const { setElement, close } = useSidebar() - const { groups } = useDatamodelData() + const { groups, warnings } = useDatamodelData() const theme = useTheme() const [searchTerm, setSearchTerm] = useState('') const [isSearching, setIsSearching] = useState(false) @@ -31,6 +32,20 @@ export const ProcessesView = ({ }: IProcessesViewProps) => { close(); }, [setElement, close]) + const typeDistribution = useMemo(() => { + return groups.reduce((acc, group) => { + group.Entities.forEach(entity => { + entity.Attributes.forEach(attribute => { + attribute.AttributeUsages.forEach(au => { + const componentTypeName = au.ComponentType; + acc[componentTypeName] = (acc[componentTypeName] || 0) + 1; + }); + }); + }); + return acc; + }, { } as Record); + }, [groups]) + const chartData = useMemo(() => { if (!selectedAttribute) return []; @@ -129,377 +144,462 @@ export const ProcessesView = ({ }: IProcessesViewProps) => { const getProcessChip = (componentType: ComponentType) => { switch (componentType) { case ComponentType.Plugin: - return } />; + return } />; case ComponentType.PowerAutomateFlow: - return } />; + return } />; + case ComponentType.WebResource: + return } />; } } return ( <> - - - - - {/* Page Title */} - - Processes - + + + + - {/* Search Bar */} - - setSearchTerm(e.target.value)} - slotProps={{ - input: { - startAdornment: ( - - - - ), - } - }} - sx={{ - '& .MuiOutlinedInput-root': { - backgroundColor: 'background.paper', - '& fieldset': { - borderColor: 'divider', - }, - '&:hover fieldset': { - borderColor: 'primary.main', - }, - '&.Mui-focused fieldset': { - borderColor: 'primary.main', - }, - }, - '& .MuiInputBase-input': { - fontSize: '1.1rem', - padding: '14px 16px', - }, - }} - /> - + {!selectedAttribute && ( + + Welcome to the Processes Explorer. Please search and select an attribute to see related processes. + + )} - {/* Search Results */} - {searchTerm.trim() && searchTerm.length >= 2 && !isSearching && ( - - - Attribute Search Results ({searchResults.length}) - - - {searchResults.length > 0 ? ( - - - {searchResults.map((result, index) => ( - - handleAttributeSelect(result)} - selected={selectedAttribute?.attribute.SchemaName === result.attribute.SchemaName && - selectedAttribute?.entity.SchemaName === result.entity.SchemaName} + + + + + + + + + + + + + {/* Search Bar */} + + setSearchTerm(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + } + }} + sx={{ + '& .MuiOutlinedInput-root': { + backgroundColor: 'background.paper', + borderRadius: '8px', + '& fieldset': { + borderColor: 'divider', + }, + '&:hover fieldset': { + borderColor: 'primary.main', + }, + '&.Mui-focused fieldset': { + borderColor: 'primary.main', + }, + }, + '& .MuiInputBase-input': { + fontSize: '1.1rem', + padding: '14px 16px', + }, + }} + /> + + + {/* Search Results */} + {searchTerm.trim() && searchTerm.length >= 2 && !isSearching && ( + + + Attribute Search Results ({searchResults.length}) + + + {searchResults.length > 0 ? ( + - - - {result.attribute.DisplayName} - - + {searchResults.map((result, index) => ( + + handleAttributeSelect(result)} + selected={selectedAttribute?.attribute.SchemaName === result.attribute.SchemaName && + selectedAttribute?.entity.SchemaName === result.entity.SchemaName} + > + + + {result.attribute.DisplayName} + + + {result.attribute.IsCustomAttribute && ( + + )} + + } + secondary={ + + + {result.entity.DisplayName} • {result.group} + + + {result.attribute.SchemaName} + + + } /> - {result.attribute.IsCustomAttribute && ( - + + ))} + + + ) : ( + + No attributes found matching "{searchTerm}" + + )} + + )} + + {searchTerm.trim() && searchTerm.length < 2 && ( + + Enter at least 2 characters to search attributes + + )} + + {/* GRID WITH SELECTED ATTRIBUTE */} + {selectedAttribute && ( + + + setSelectedAttribute(null)}>} + className='flex flex-col items-center justify-center h-full w-full' + > + + + Selected Attribute + + + [{selectedAttribute.group} ({selectedAttribute.entity.DisplayName})]: {selectedAttribute.attribute.DisplayName} + + + + {chartData.length > 0 ? ( + + ) : ( + + No usage data available + )} - } - secondary={ - - - {result.entity.DisplayName} • {result.group} - - - {result.attribute.SchemaName} - + + + + + + Processes - } - /> - - - ))} - - - ) : ( - - No attributes found matching "{searchTerm}" - - )} - - )} - - {searchTerm.trim() && searchTerm.length < 2 && ( - - Enter at least 2 characters to search attributes - - )} - - - - Currently only supports Plugin triggers and CDS Power Automate Actions - - + + {selectedAttribute?.attribute.AttributeUsages.length === 0 ? ( + + No process usage data available for this attribute + + ) : ( + + + + + + Process + + + Name + + + Type + + + Usage + + + + + {selectedAttribute?.attribute.AttributeUsages.map((usage, idx) => ( + + + {getProcessChip(usage.ComponentType)} + + + {usage.Name} + + + {OperationType[usage.OperationType]} + + + {usage.Usage} + + + ))} + +
+
+ )} +
+ + + )} - {!selectedAttribute && ( - - Welcome to the processes search. Please search and select an attribute to see related processes. - - )} + - {/* GRID WITH SELECTED ATTRIBUTE */} - {selectedAttribute && ( - - - setSelectedAttribute(null)}>} - className='flex flex-col items-center justify-center h-full w-full' + {/* Warnings */} + - - - Selected Attribute - - - [{selectedAttribute.group} ({selectedAttribute.entity.DisplayName})]: {selectedAttribute.attribute.DisplayName} - - - - {chartData.length > 0 ? ( - - ) : ( - - No usage data available + } + aria-controls="warnings-content" + id="warnings-header" + sx={{ + backgroundColor: 'background.paper', + color: 'text.secondary', + '&:hover': { + backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.04)' + } + }} + > + + + + Erroneous Attributes ({warnings.filter(warning => warning.Type === WarningType.Attribute).length}) - )} - - - - - - - Processes - - - {selectedAttribute?.attribute.AttributeUsages.length === 0 ? ( - - No process usage data available for this attribute + + + + {warnings.filter(warning => warning.Type === WarningType.Attribute).length > 0 ? ( + + {warnings.filter(warning => warning.Type === WarningType.Attribute).map((warning, index) => ( + + {warning.Message} + + ))} ) : ( - - - - - - Process - - - Name - - - Type - - - Usage - - - - - {selectedAttribute?.attribute.AttributeUsages.map((usage, idx) => ( - - - {getProcessChip(usage.ComponentType)} - - - {usage.Name} - - - {OperationType[usage.OperationType]} - - - {usage.Usage} - - - ))} - -
-
+ + No attribute errors found. + )} -
-
-
-
)} + + +
+
-
-
) } \ No newline at end of file diff --git a/Website/components/shared/elements/StatCard.tsx b/Website/components/shared/elements/StatCard.tsx new file mode 100644 index 0000000..6a65292 --- /dev/null +++ b/Website/components/shared/elements/StatCard.tsx @@ -0,0 +1,76 @@ +import React, { useState, useEffect } from 'react' +import { Box, Typography, Paper, Tooltip } from '@mui/material' +import { WarningRounded } from '@mui/icons-material' + +interface IStatCardProps { + title: string + value: number | string + highlightedWord: string + tooltipTitle: string + tooltipWarning: string + imageSrc: string + imageAlt?: string +} + +export const StatCard = ({ + title, + value, + highlightedWord, + tooltipTitle, + tooltipWarning, + imageSrc, + imageAlt = "Icon" +}: IStatCardProps) => { + const [animatedValue, setAnimatedValue] = useState(0) + const targetValue = typeof value === 'number' ? value : parseInt(value.toString()) || 0 + + useEffect(() => { + if (targetValue === 0) { + setAnimatedValue(0) + return + } + + const duration = 1000 + const steps = 60 + const increment = targetValue / steps + const stepDuration = duration / steps + + let currentStep = 0 + const timer = setInterval(() => { + currentStep++ + const newValue = Math.min(Math.round(increment * currentStep), targetValue) + setAnimatedValue(newValue) + + if (currentStep >= steps || newValue >= targetValue) { + setAnimatedValue(targetValue) + clearInterval(timer) + } + }, stepDuration) + + return () => clearInterval(timer) + }, [targetValue]) + + return ( + + + + {highlightedWord} {title} + + + {animatedValue} + + + + + + See {tooltipWarning} + + + + + + + + + ) +} \ No newline at end of file diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx index 744788d..62203f3 100644 --- a/Website/contexts/DatamodelDataContext.tsx +++ b/Website/contexts/DatamodelDataContext.tsx @@ -1,17 +1,19 @@ 'use client' import React, { createContext, useContext, useReducer, ReactNode } from "react"; -import { GroupType } from "@/lib/Types"; +import { GroupType, SolutionWarningType } from "@/lib/Types"; import { useSearchParams } from "next/navigation"; interface DatamodelDataState { groups: GroupType[]; + warnings: SolutionWarningType[]; search: string; filtered: any[]; } const initialState: DatamodelDataState = { groups: [], + warnings: [], search: "", filtered: [] }; @@ -23,6 +25,8 @@ const datamodelDataReducer = (state: DatamodelDataState, action: any): Datamodel switch (action.type) { case "SET_GROUPS": return { ...state, groups: action.payload }; + case "SET_WARNINGS": + return { ...state, warnings: action.payload }; case "SET_SEARCH": return { ...state, search: action.payload }; case "SET_FILTERED": @@ -46,7 +50,8 @@ export const DatamodelDataProvider = ({ children }: { children: ReactNode }) => const worker = new Worker(new URL("../components/datamodelview/dataLoaderWorker.js", import.meta.url)); worker.onmessage = (e) => { - dispatch({ type: "SET_GROUPS", payload: e.data }); + dispatch({ type: "SET_GROUPS", payload: e.data.groups || [] }); + dispatch({ type: "SET_WARNINGS", payload: e.data.warnings || [] }); worker.terminate(); }; worker.postMessage({}); diff --git a/Website/lib/Types.ts b/Website/lib/Types.ts index 2eae3e2..4beb467 100644 --- a/Website/lib/Types.ts +++ b/Website/lib/Types.ts @@ -1,3 +1,12 @@ +export type SolutionWarningType = { + Type: WarningType, + Message: string, +} + +export enum WarningType { + Attribute +} + export type GroupType = { Name: string, Entities: EntityType[] diff --git a/Website/public/plugin.svg b/Website/public/plugin.svg new file mode 100644 index 0000000..c023aa9 --- /dev/null +++ b/Website/public/plugin.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Website/public/powerautomate.svg b/Website/public/powerautomate.svg new file mode 100644 index 0000000..ad5cdb0 --- /dev/null +++ b/Website/public/powerautomate.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Website/public/webresource.svg b/Website/public/webresource.svg new file mode 100644 index 0000000..9650ca7 --- /dev/null +++ b/Website/public/webresource.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file