From edf94151d68997fd4bec7f9b261dc098d6c44960 Mon Sep 17 00:00:00 2001 From: Lucki2g Date: Tue, 13 Jan 2026 19:23:19 +0100 Subject: [PATCH] feat: global choice indication --- Generator/DTO/Attributes/ChoiceAttribute.cs | 5 ++ Generator/DTO/GlobalOptionSetUsage.cs | 12 ++++ Generator/DataverseService.cs | 46 ++++++++++++- Generator/Program.cs | 4 +- Generator/WebsiteBuilder.cs | 13 +++- .../attributes/ChoiceAttribute.tsx | 4 +- .../attributes/OptionSetScopeIndicator.tsx | 65 +++++++++++++++++++ .../datamodelview/dataLoaderWorker.ts | 4 +- Website/contexts/DatamodelDataContext.tsx | 16 +++++ Website/lib/Types.ts | 1 + Website/stubs/Data.ts | 4 +- 11 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 Generator/DTO/GlobalOptionSetUsage.cs create mode 100644 Website/components/datamodelview/attributes/OptionSetScopeIndicator.tsx diff --git a/Generator/DTO/Attributes/ChoiceAttribute.cs b/Generator/DTO/Attributes/ChoiceAttribute.cs index f3d2ed8..91a13f1 100644 --- a/Generator/DTO/Attributes/ChoiceAttribute.cs +++ b/Generator/DTO/Attributes/ChoiceAttribute.cs @@ -11,6 +11,8 @@ public class ChoiceAttribute : Attribute public int? DefaultValue { get; } + public string? GlobalOptionSetName { get; set; } + public ChoiceAttribute(PicklistAttributeMetadata metadata) : base(metadata) { Options = metadata.OptionSet.Options.Select(x => new Option( @@ -20,6 +22,7 @@ public ChoiceAttribute(PicklistAttributeMetadata metadata) : base(metadata) x.Description.ToLabelString().PrettyDescription())); Type = "Single"; DefaultValue = metadata.DefaultFormValue; + GlobalOptionSetName = metadata.OptionSet.IsGlobal == true ? metadata.OptionSet.Name : null; } public ChoiceAttribute(StateAttributeMetadata metadata) : base(metadata) @@ -31,6 +34,7 @@ public ChoiceAttribute(StateAttributeMetadata metadata) : base(metadata) x.Description.ToLabelString().PrettyDescription())); Type = "Single"; DefaultValue = metadata.DefaultFormValue; + GlobalOptionSetName = null; // State attributes are always local } public ChoiceAttribute(MultiSelectPicklistAttributeMetadata metadata) : base(metadata) @@ -42,5 +46,6 @@ public ChoiceAttribute(MultiSelectPicklistAttributeMetadata metadata) : base(met x.Description.ToLabelString().PrettyDescription())); Type = "Multi"; DefaultValue = metadata.DefaultFormValue; + GlobalOptionSetName = metadata.OptionSet.IsGlobal == true ? metadata.OptionSet.Name : null; } } diff --git a/Generator/DTO/GlobalOptionSetUsage.cs b/Generator/DTO/GlobalOptionSetUsage.cs new file mode 100644 index 0000000..7d211c3 --- /dev/null +++ b/Generator/DTO/GlobalOptionSetUsage.cs @@ -0,0 +1,12 @@ +namespace Generator.DTO; + +internal record GlobalOptionSetUsageReference( + string EntitySchemaName, + string EntityDisplayName, + string AttributeSchemaName, + string AttributeDisplayName); + +internal record GlobalOptionSetUsage( + string Name, + string DisplayName, + List Usages); diff --git a/Generator/DataverseService.cs b/Generator/DataverseService.cs index fed21bd..630e8f6 100644 --- a/Generator/DataverseService.cs +++ b/Generator/DataverseService.cs @@ -69,7 +69,7 @@ public DataverseService( this.solutionComponentService = solutionComponentService; } - public async Task<(IEnumerable, IEnumerable)> GetFilteredMetadata() + public async Task<(IEnumerable, IEnumerable, Dictionary)> GetFilteredMetadata() { // used to collect warnings for the insights dashboard var warnings = new List(); @@ -246,6 +246,46 @@ public DataverseService( workflowDependencies = new Dictionary>(); } + /// BUILD GLOBAL OPTION SET USAGE MAP + var globalOptionSetUsages = new Dictionary(); + foreach (var entMeta in entitiesInSolutionMetadata) + { + var relevantAttributes = entMeta.Attributes.Where(attr => attributesInSolution.Contains(attr.MetadataId!.Value)); + foreach (var attr in relevantAttributes) + { + string? globalOptionSetName = null; + string? globalOptionSetDisplayName = null; + + if (attr is PicklistAttributeMetadata picklist && picklist.OptionSet?.IsGlobal == true) + { + globalOptionSetName = picklist.OptionSet.Name; + globalOptionSetDisplayName = picklist.OptionSet.DisplayName.ToLabelString(); + } + else if (attr is MultiSelectPicklistAttributeMetadata multiSelect && multiSelect.OptionSet?.IsGlobal == true) + { + globalOptionSetName = multiSelect.OptionSet.Name; + globalOptionSetDisplayName = multiSelect.OptionSet.DisplayName.ToLabelString(); + } + + if (globalOptionSetName != null) + { + if (!globalOptionSetUsages.ContainsKey(globalOptionSetName)) + { + globalOptionSetUsages[globalOptionSetName] = new GlobalOptionSetUsage( + globalOptionSetName, + globalOptionSetDisplayName ?? globalOptionSetName, + new List()); + } + + globalOptionSetUsages[globalOptionSetName].Usages.Add(new GlobalOptionSetUsageReference( + entMeta.SchemaName, + entMeta.DisplayName.ToLabelString(), + attr.SchemaName, + attr.DisplayName.ToLabelString())); + } + } + } + var records = entitiesInSolutionMetadata .Select(entMeta => @@ -275,8 +315,8 @@ public DataverseService( }) .ToList(); - logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] GetFilteredMetadata completed - returning empty results"); - return (records, warnings); + logger.LogInformation($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] GetFilteredMetadata completed - returning {records.Count} records with {globalOptionSetUsages.Count} global option sets"); + return (records, warnings, globalOptionSetUsages); } } diff --git a/Generator/Program.cs b/Generator/Program.cs index 1672cf7..eb62f06 100644 --- a/Generator/Program.cs +++ b/Generator/Program.cs @@ -65,9 +65,9 @@ // Resolve and use DataverseService var dataverseService = serviceProvider.GetRequiredService(); -var (entities, warnings) = await dataverseService.GetFilteredMetadata(); +var (entities, warnings, globalOptionSetUsages) = await dataverseService.GetFilteredMetadata(); -var websiteBuilder = new WebsiteBuilder(configuration, entities, warnings); +var websiteBuilder = new WebsiteBuilder(configuration, entities, warnings, globalOptionSetUsages); websiteBuilder.AddData(); // Token provider function diff --git a/Generator/WebsiteBuilder.cs b/Generator/WebsiteBuilder.cs index 0661ebd..3f9b924 100644 --- a/Generator/WebsiteBuilder.cs +++ b/Generator/WebsiteBuilder.cs @@ -12,13 +12,15 @@ internal class WebsiteBuilder private readonly IEnumerable records; private readonly IEnumerable warnings; private readonly IEnumerable solutions; + private readonly Dictionary globalOptionSetUsages; private readonly string OutputFolder; - public WebsiteBuilder(IConfiguration configuration, IEnumerable records, IEnumerable warnings) + public WebsiteBuilder(IConfiguration configuration, IEnumerable records, IEnumerable warnings, Dictionary globalOptionSetUsages) { this.configuration = configuration; this.records = records; this.warnings = warnings; + this.globalOptionSetUsages = globalOptionSetUsages; // Assuming execution in bin/xxx/net8.0 OutputFolder = configuration["OutputFolder"] ?? Path.Combine(System.Reflection.Assembly.GetExecutingAssembly().Location, "../../../../../Website/generated"); @@ -66,6 +68,15 @@ internal void AddData() } sb.AppendLine("]"); + // GLOBAL OPTION SETS + sb.AppendLine(""); + sb.AppendLine("export const GlobalOptionSets: Record = {"); + foreach (var (key, usage) in globalOptionSetUsages) + { + sb.AppendLine($" \"{key}\": {JsonConvert.SerializeObject(usage)},"); + } + sb.AppendLine("};"); + File.WriteAllText(Path.Combine(OutputFolder, "Data.ts"), sb.ToString()); } } diff --git a/Website/components/datamodelview/attributes/ChoiceAttribute.tsx b/Website/components/datamodelview/attributes/ChoiceAttribute.tsx index 4c8c47a..16abb49 100644 --- a/Website/components/datamodelview/attributes/ChoiceAttribute.tsx +++ b/Website/components/datamodelview/attributes/ChoiceAttribute.tsx @@ -3,6 +3,7 @@ import { ChoiceAttributeType } from "@/lib/Types" import { formatNumberSeperator } from "@/lib/utils" import { Box, Typography, Chip } from "@mui/material" import { CheckBoxOutlineBlankRounded, CheckBoxRounded, CheckRounded, RadioButtonCheckedRounded, RadioButtonUncheckedRounded } from "@mui/icons-material" +import OptionSetScopeIndicator from "./OptionSetScopeIndicator" export default function ChoiceAttribute({ attribute, highlightMatch, highlightTerm }: { attribute: ChoiceAttributeType, highlightMatch: (text: string, term: string) => string | React.JSX.Element, highlightTerm: string }) { @@ -11,9 +12,10 @@ export default function ChoiceAttribute({ attribute, highlightMatch, highlightTe return ( + {attribute.Type}-select {attribute.DefaultValue !== null && attribute.DefaultValue !== -1 && !isMobile && ( - } label={`Default: ${attribute.Options.find(o => o.Value === attribute.DefaultValue)?.Name}`} size="small" diff --git a/Website/components/datamodelview/attributes/OptionSetScopeIndicator.tsx b/Website/components/datamodelview/attributes/OptionSetScopeIndicator.tsx new file mode 100644 index 0000000..572ef20 --- /dev/null +++ b/Website/components/datamodelview/attributes/OptionSetScopeIndicator.tsx @@ -0,0 +1,65 @@ +import { Box, Tooltip, Typography } from "@mui/material"; +import { PublicRounded, HomeRounded } from "@mui/icons-material"; +import { useDatamodelData } from "@/contexts/DatamodelDataContext"; + +interface OptionSetScopeIndicatorProps { + globalOptionSetName: string | null; +} + +export default function OptionSetScopeIndicator({ globalOptionSetName }: OptionSetScopeIndicatorProps) { + const { globalOptionSets } = useDatamodelData(); + + if (!globalOptionSetName) { + // Local option set + return ( + + + + ); + } + + // Global option set - show usages in tooltip + const usage = globalOptionSets[globalOptionSetName]; + + if (!usage) { + // Fallback if usage data not found + return ( + + + + ); + } + + const tooltipContent = ( + + + Global choice: {usage.DisplayName} + + + Used by {usage.Usages.length} field{usage.Usages.length !== 1 ? 's' : ''}: + + + {usage.Usages.map((u, idx) => ( + + • {u.EntityDisplayName} - {u.AttributeDisplayName} + + ))} + + + ); + + return ( + + + + ); +} diff --git a/Website/components/datamodelview/dataLoaderWorker.ts b/Website/components/datamodelview/dataLoaderWorker.ts index b4fc209..07f8cb4 100644 --- a/Website/components/datamodelview/dataLoaderWorker.ts +++ b/Website/components/datamodelview/dataLoaderWorker.ts @@ -1,5 +1,5 @@ import { EntityType } from '@/lib/Types'; -import { Groups, SolutionWarnings, SolutionCount } from '../../generated/Data'; +import { Groups, SolutionWarnings, SolutionCount, GlobalOptionSets } from '../../generated/Data'; self.onmessage = function () { const entityMap = new Map(); @@ -8,5 +8,5 @@ self.onmessage = function () { entityMap.set(entity.SchemaName, entity); }); }); - self.postMessage({ groups: Groups, entityMap: entityMap, warnings: SolutionWarnings, solutionCount: SolutionCount }); + self.postMessage({ groups: Groups, entityMap: entityMap, warnings: SolutionWarnings, solutionCount: SolutionCount, globalOptionSets: GlobalOptionSets }); }; \ No newline at end of file diff --git a/Website/contexts/DatamodelDataContext.tsx b/Website/contexts/DatamodelDataContext.tsx index a495391..4a3af1a 100644 --- a/Website/contexts/DatamodelDataContext.tsx +++ b/Website/contexts/DatamodelDataContext.tsx @@ -9,11 +9,23 @@ interface DataModelAction { getEntityDataBySchemaName: (schemaName: string) => EntityType | undefined; } +export interface GlobalOptionSetUsage { + Name: string; + DisplayName: string; + Usages: { + EntitySchemaName: string; + EntityDisplayName: string; + AttributeSchemaName: string; + AttributeDisplayName: string; + }[]; +} + interface DatamodelDataState extends DataModelAction { groups: GroupType[]; entityMap?: Map; warnings: SolutionWarningType[]; solutionCount: number; + globalOptionSets: Record; search: string; searchScope: SearchScope; filtered: Array< @@ -28,6 +40,7 @@ const initialState: DatamodelDataState = { groups: [], warnings: [], solutionCount: 0, + globalOptionSets: {}, search: "", searchScope: { columnNames: true, @@ -54,6 +67,8 @@ const datamodelDataReducer = (state: DatamodelDataState, action: any): Datamodel return { ...state, warnings: action.payload }; case "SET_SOLUTION_COUNT": return { ...state, solutionCount: action.payload }; + case "SET_GLOBAL_OPTION_SETS": + return { ...state, globalOptionSets: action.payload }; case "SET_SEARCH": return { ...state, search: action.payload }; case "SET_SEARCH_SCOPE": @@ -83,6 +98,7 @@ export const DatamodelDataProvider = ({ children }: { children: ReactNode }) => dispatch({ type: "SET_ENTITIES", payload: e.data.entityMap || new Map() }); dispatch({ type: "SET_WARNINGS", payload: e.data.warnings || [] }); dispatch({ type: "SET_SOLUTION_COUNT", payload: e.data.solutionCount || 0 }); + dispatch({ type: "SET_GLOBAL_OPTION_SETS", payload: e.data.globalOptionSets || {} }); worker.terminate(); }; worker.postMessage({}); diff --git a/Website/lib/Types.ts b/Website/lib/Types.ts index bc1ffba..b522216 100644 --- a/Website/lib/Types.ts +++ b/Website/lib/Types.ts @@ -117,6 +117,7 @@ export type ChoiceAttributeType = BaseAttribute & { AttributeType: "ChoiceAttribute", Type: "Single" | "Multi", DefaultValue: number | null, + GlobalOptionSetName: string | null, Options: { Name: string, Value: number, diff --git a/Website/stubs/Data.ts b/Website/stubs/Data.ts index 367957e..ba109fd 100644 --- a/Website/stubs/Data.ts +++ b/Website/stubs/Data.ts @@ -113,4 +113,6 @@ export let Groups: GroupType[] = [ } ]; -export let SolutionWarnings: SolutionWarningType[] = []; \ No newline at end of file +export let SolutionWarnings: SolutionWarningType[] = []; + +export const GlobalOptionSets: Record = {}; \ No newline at end of file