@mohsinonxrm/pptb-fetchxml-studio 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +250 -0
  3. package/eslint.config.js +23 -0
  4. package/index.html +71 -0
  5. package/package.json +53 -0
  6. package/public/vite.svg +1 -0
  7. package/src/app/AppShell.tsx +1733 -0
  8. package/src/assets/20px-Microsoft_Office_Excel_(2025/342/200/223present).svg.png +0 -0
  9. package/src/assets/react.svg +1 -0
  10. package/src/features/fetchxml/api/dataverseMetadata.ts +192 -0
  11. package/src/features/fetchxml/api/excelExport.ts +318 -0
  12. package/src/features/fetchxml/api/formattedValues.ts +107 -0
  13. package/src/features/fetchxml/api/pptbClient.ts +2635 -0
  14. package/src/features/fetchxml/model/displaySettings.ts +18 -0
  15. package/src/features/fetchxml/model/fetchxml.ts +346 -0
  16. package/src/features/fetchxml/model/fetchxmlIntellisense.ts +583 -0
  17. package/src/features/fetchxml/model/fetchxmlParser.ts +708 -0
  18. package/src/features/fetchxml/model/layoutxml.ts +372 -0
  19. package/src/features/fetchxml/model/nodes.ts +255 -0
  20. package/src/features/fetchxml/model/operators.ts +624 -0
  21. package/src/features/fetchxml/model/treeUtils.ts +174 -0
  22. package/src/features/fetchxml/state/builderStore.tsx +1160 -0
  23. package/src/features/fetchxml/state/cache.ts +393 -0
  24. package/src/features/fetchxml/ui/Dialogs/BulkDeleteDialog.tsx +352 -0
  25. package/src/features/fetchxml/ui/Dialogs/DeleteConfirmDialog.tsx +396 -0
  26. package/src/features/fetchxml/ui/Dialogs/SaveViewDialog.tsx +612 -0
  27. package/src/features/fetchxml/ui/Dialogs/SolutionPicker.tsx +114 -0
  28. package/src/features/fetchxml/ui/Dialogs/WorkflowPickerDialog.tsx +437 -0
  29. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/AttributeEditor.tsx +214 -0
  30. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ConditionEditor.tsx +533 -0
  31. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FetchOptionsEditor.tsx +296 -0
  32. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FilterEditor.tsx +106 -0
  33. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/LinkEntityEditor.tsx +433 -0
  34. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/OrderEditor.tsx +161 -0
  35. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ValueOfPicker.tsx +194 -0
  36. package/src/features/fetchxml/ui/LeftPane/PropertiesPanel.tsx +225 -0
  37. package/src/features/fetchxml/ui/LeftPane/TreeView.tsx +604 -0
  38. package/src/features/fetchxml/ui/RightPane/AddColumnsPanel.tsx +800 -0
  39. package/src/features/fetchxml/ui/RightPane/ColumnConfigDialog.tsx +170 -0
  40. package/src/features/fetchxml/ui/RightPane/DataGridCellRenderers.tsx +356 -0
  41. package/src/features/fetchxml/ui/RightPane/EditColumnsPanel.tsx +372 -0
  42. package/src/features/fetchxml/ui/RightPane/FetchXmlEditor.tsx +399 -0
  43. package/src/features/fetchxml/ui/RightPane/FormattedValueUtils.ts +189 -0
  44. package/src/features/fetchxml/ui/RightPane/LayoutXmlViewer.tsx +171 -0
  45. package/src/features/fetchxml/ui/RightPane/PreviewTabs.tsx +471 -0
  46. package/src/features/fetchxml/ui/RightPane/ResultsCommandBar.tsx +430 -0
  47. package/src/features/fetchxml/ui/RightPane/ResultsGrid.tsx +983 -0
  48. package/src/features/fetchxml/ui/RightPane/XmlTextArea.tsx +65 -0
  49. package/src/features/fetchxml/ui/Settings/SettingsDrawer.tsx +175 -0
  50. package/src/features/fetchxml/ui/Toolbar/EntitySelector.tsx +788 -0
  51. package/src/features/fetchxml/ui/Toolbar/LoadViewPicker.tsx +274 -0
  52. package/src/features/fetchxml/ui/Toolbar/SaveViewButton.tsx +174 -0
  53. package/src/main.tsx +11 -0
  54. package/src/shared/components/AttributePicker.tsx +138 -0
  55. package/src/shared/components/BetweenInputPicker.tsx +120 -0
  56. package/src/shared/components/BooleanValuePicker.tsx +165 -0
  57. package/src/shared/components/DateTimeInputPicker.tsx +170 -0
  58. package/src/shared/components/DynamicValueList.tsx +128 -0
  59. package/src/shared/components/ExcelIcon.tsx +30 -0
  60. package/src/shared/components/MultiValuePicker.tsx +178 -0
  61. package/src/shared/components/NumericInputPicker.tsx +156 -0
  62. package/src/shared/components/OptionSetValuePicker.tsx +150 -0
  63. package/src/shared/components/RelationshipPicker.tsx +268 -0
  64. package/src/shared/contexts/ThemeContext.tsx +19 -0
  65. package/src/shared/hooks/useAccessMode.ts +77 -0
  66. package/src/shared/hooks/useLazyMetadata.ts +132 -0
  67. package/src/shared/hooks/usePptbContext.ts +170 -0
  68. package/src/shared/hooks/usePublisherFilter.ts +214 -0
  69. package/src/shared/hooks/useSolutionFilter.ts +162 -0
  70. package/src/shared/utils/debug.ts +87 -0
  71. package/tsconfig.app.json +28 -0
  72. package/tsconfig.json +7 -0
  73. package/tsconfig.node.json +26 -0
  74. package/vite.config.ts +7 -0
@@ -0,0 +1,983 @@
1
+ /**
2
+ * DataGrid for displaying FetchXML query results with virtualization
3
+ * Uses rich cell renderers based on attribute metadata for Power Apps-like experience
4
+ * Displays formatted values from OData annotations when available
5
+ * Uses @fluentui-contrib/react-data-grid-react-window for 1D virtualization with multi-select and resizable columns
6
+ * Sort state is derived from FetchXML orders - clicking headers triggers onSortChange callback
7
+ */
8
+
9
+ import { useMemo, useCallback, useEffect, useState, useRef } from "react";
10
+ import {
11
+ createTableColumn,
12
+ makeStyles,
13
+ tokens,
14
+ TableCellLayout,
15
+ Skeleton,
16
+ SkeletonItem,
17
+ } from "@fluentui/react-components";
18
+ import {
19
+ DataGrid,
20
+ DataGridHeader,
21
+ DataGridHeaderCell,
22
+ DataGridBody,
23
+ DataGridRow,
24
+ DataGridCell,
25
+ } from "@fluentui-contrib/react-data-grid-react-window";
26
+ import type { RowRenderer } from "@fluentui-contrib/react-data-grid-react-window";
27
+ import { useScrollbarWidth, useFluent } from "@fluentui/react-components";
28
+ import type { SortDirection } from "@fluentui/react-components";
29
+ import { ArrowSortUp16Regular, ArrowSortDown16Regular } from "@fluentui/react-icons";
30
+ import type { AttributeMetadata } from "../../api/pptbClient";
31
+ import { getCellRenderer } from "./DataGridCellRenderers";
32
+ import { getFormattedValue, filterDisplayableColumns } from "./FormattedValueUtils";
33
+ import type { FetchNode, AttributeNode, OrderNode } from "../../model/nodes";
34
+ import type { LayoutXmlConfig } from "../../model/layoutxml";
35
+ import type { DisplaySettings } from "../../model/displaySettings";
36
+
37
+ /** Sort change event data passed to parent */
38
+ export interface SortChangeData {
39
+ /** Column being sorted (may include entityname prefix for link-entity attributes) */
40
+ columnId: string;
41
+ /** New sort direction */
42
+ direction: SortDirection;
43
+ /** Whether Shift key was held (multi-column sort) */
44
+ isMultiSort: boolean;
45
+ /** The entityname for link-entity attributes, undefined for root entity */
46
+ entityName?: string;
47
+ }
48
+
49
+ const ROW_HEIGHT = 42;
50
+
51
+ const useStyles = makeStyles({
52
+ container: {
53
+ display: "flex",
54
+ flexDirection: "column",
55
+ height: "100%",
56
+ backgroundColor: tokens.colorNeutralBackground1,
57
+ position: "relative",
58
+ },
59
+ gridWrapper: {
60
+ flex: 1,
61
+ display: "flex",
62
+ flexDirection: "column",
63
+ overflow: "hidden",
64
+ backgroundColor: tokens.colorNeutralBackground1,
65
+ position: "relative",
66
+ minHeight: 0, // Important for flex child with overflow
67
+ },
68
+ gridContent: {
69
+ flex: 1,
70
+ display: "flex",
71
+ flexDirection: "column",
72
+ overflow: "hidden", // Hidden to prevent container scroll - DataGrid handles its own scrolling
73
+ width: "100%",
74
+ minHeight: 0, // Important for flex child with overflow
75
+ },
76
+ infoBar: {
77
+ display: "flex",
78
+ alignItems: "center",
79
+ justifyContent: "space-between",
80
+ padding: "8px 16px",
81
+ borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
82
+ backgroundColor: tokens.colorNeutralBackground1,
83
+ fontSize: tokens.fontSizeBase200,
84
+ color: tokens.colorNeutralForeground2,
85
+ minHeight: "32px",
86
+ zIndex: 1,
87
+ },
88
+ selectionWarning: {
89
+ color: tokens.colorPaletteYellowForeground1,
90
+ marginLeft: tokens.spacingHorizontalM,
91
+ },
92
+ emptyState: {
93
+ display: "flex",
94
+ alignItems: "center",
95
+ justifyContent: "center",
96
+ height: "100%",
97
+ color: tokens.colorNeutralForeground3,
98
+ fontSize: tokens.fontSizeBase300,
99
+ },
100
+ cellContent: {
101
+ padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
102
+ overflow: "hidden",
103
+ textOverflow: "ellipsis",
104
+ whiteSpace: "nowrap",
105
+ },
106
+ row: {
107
+ boxSizing: "border-box",
108
+ height: "44px",
109
+ },
110
+ body: {
111
+ scrollbarGutter: "stable",
112
+ },
113
+ headerCellContent: {
114
+ display: "flex",
115
+ alignItems: "center",
116
+ gap: tokens.spacingHorizontalXS,
117
+ },
118
+ sortIndicator: {
119
+ display: "flex",
120
+ alignItems: "center",
121
+ marginLeft: "auto",
122
+ },
123
+ // Hide Fluent's default sort indicator - we render our own in renderHeaderCell
124
+ headerCell: {
125
+ "& > button > span:last-child": {
126
+ display: "none",
127
+ },
128
+ },
129
+ });
130
+
131
+ export interface QueryResult {
132
+ columns: string[];
133
+ rows: Record<string, unknown>[];
134
+ totalRecordCount?: number;
135
+ moreRecords?: boolean;
136
+ pagingCookie?: string;
137
+ entityLogicalName?: string; // NEW: for loading attribute metadata
138
+ executionTimeMs?: number; // NEW: for execution timing display
139
+ }
140
+
141
+ interface ResultsGridProps {
142
+ result: QueryResult | null;
143
+ isLoading?: boolean;
144
+ /** Whether more pages are currently being loaded (for infinite scroll / Retrieve All) */
145
+ isLoadingMore?: boolean;
146
+ /** Multi-entity attribute metadata: Map<entityLogicalName, Map<attributeLogicalName, AttributeMetadata>> */
147
+ attributeMetadata?: Map<string, Map<string, AttributeMetadata>>;
148
+ fetchQuery?: FetchNode | null; // For extracting aliases and order state
149
+ onSelectedCountChange?: (count: number) => void;
150
+ /** Callback when selection changes, provides the selected record IDs (GUIDs) */
151
+ onSelectionChange?: (recordIds: string[]) => void;
152
+ /** Column layout configuration for order and widths */
153
+ columnConfig?: LayoutXmlConfig | null;
154
+ /** Callback when a column is resized */
155
+ onColumnResize?: (columnName: string, newWidth: number) => void;
156
+ /** Callback when user clicks a column header to sort */
157
+ onSortChange?: (data: SortChangeData) => void;
158
+ /** Callback when user scrolls near bottom and more records are available (infinite scroll) */
159
+ onLoadMore?: () => void;
160
+ /** Display settings (logical names, value format) */
161
+ displaySettings?: DisplaySettings;
162
+ }
163
+
164
+ export function ResultsGrid({
165
+ result,
166
+ isLoading,
167
+ isLoadingMore,
168
+ attributeMetadata,
169
+ fetchQuery,
170
+ onSelectedCountChange,
171
+ onSelectionChange,
172
+ columnConfig,
173
+ onColumnResize,
174
+ onSortChange,
175
+ onLoadMore,
176
+ displaySettings,
177
+ }: ResultsGridProps) {
178
+ const styles = useStyles();
179
+ const { targetDocument } = useFluent();
180
+ const scrollbarWidth = useScrollbarWidth({ targetDocument });
181
+ const [selectedItems, setSelectedItems] = useState<Set<string | number>>(new Set());
182
+ const containerRef = useRef<HTMLDivElement>(null);
183
+ const headerRef = useRef<HTMLDivElement>(null);
184
+ const [gridDimensions, setGridDimensions] = useState({ width: 0, height: 0 });
185
+ const [headerHeight, setHeaderHeight] = useState(0);
186
+
187
+ // Measure container dimensions for virtualization
188
+ useEffect(() => {
189
+ const container = containerRef.current;
190
+ if (!container) {
191
+ console.log("[ResultsGrid] ResizeObserver: container ref is null");
192
+ return;
193
+ }
194
+
195
+ console.log("[ResultsGrid] ResizeObserver: setting up observer on container", container);
196
+
197
+ const observer = new ResizeObserver((entries) => {
198
+ for (const entry of entries) {
199
+ const { width, height } = entry.contentRect;
200
+ console.log("[ResultsGrid] ResizeObserver: dimensions updated", { width, height });
201
+ setGridDimensions({ width, height });
202
+ }
203
+ });
204
+
205
+ observer.observe(container);
206
+ return () => observer.disconnect();
207
+ }, [result]); // Re-run when result changes to ensure container is mounted
208
+
209
+ // Measure header height for proper body height calculation
210
+ useEffect(() => {
211
+ const header = headerRef.current;
212
+ if (!header) return;
213
+
214
+ const observer = new ResizeObserver(() => {
215
+ const height = header.offsetHeight;
216
+ console.log("[ResultsGrid] Header height:", height);
217
+ setHeaderHeight(height);
218
+ });
219
+
220
+ observer.observe(header);
221
+ return () => observer.disconnect();
222
+ }, [result]);
223
+
224
+ // Clear selection when results change
225
+ useEffect(() => {
226
+ setSelectedItems(new Set());
227
+ }, [result]);
228
+
229
+ // Derive sort state from FetchXML orders (entity + link-entity orders)
230
+ // This builds a map from column name to sort direction
231
+ const sortStateMap = useMemo(() => {
232
+ const map = new Map<string, { direction: SortDirection; entityName?: string }>();
233
+ if (!fetchQuery?.entity) return map;
234
+
235
+ // Build a reverse map from attribute name to alias for root entity
236
+ const attrToAliasMap = new Map<string, string>();
237
+ fetchQuery.entity.attributes?.forEach((attr) => {
238
+ if (attr.alias) {
239
+ attrToAliasMap.set(attr.name, attr.alias);
240
+ }
241
+ });
242
+
243
+ // Process root entity orders
244
+ // Orders with entityname refer to link-entity attributes
245
+ fetchQuery.entity.orders?.forEach((order: OrderNode) => {
246
+ const direction: SortDirection = order.descending ? "descending" : "ascending";
247
+ if (order.entityname) {
248
+ // This order references a link-entity attribute
249
+ // The column in results might be: alias.attributeName or _attributeName_value for lookups
250
+ map.set(`${order.entityname}.${order.attribute}`, {
251
+ direction,
252
+ entityName: order.entityname,
253
+ });
254
+ // Also map just the attribute name in case it appears that way
255
+ map.set(order.attribute, { direction, entityName: order.entityname });
256
+ } else {
257
+ // Root entity attribute
258
+ map.set(order.attribute, { direction });
259
+ // Also check for lookup field naming convention
260
+ map.set(`_${order.attribute}_value`, { direction });
261
+ // Also map by alias if this attribute has one
262
+ const alias = attrToAliasMap.get(order.attribute);
263
+ if (alias) {
264
+ map.set(alias, { direction });
265
+ }
266
+ }
267
+ });
268
+
269
+ // Process link-entity orders (orders defined inside link-entity)
270
+ const processLinkOrders = (links: typeof fetchQuery.entity.links) => {
271
+ links?.forEach((link) => {
272
+ const alias = link.alias || link.name;
273
+ link.orders?.forEach((order: OrderNode) => {
274
+ const direction: SortDirection = order.descending ? "descending" : "ascending";
275
+ // Link-entity attribute columns appear as alias.attributeName or entityname.attributeName
276
+ map.set(`${alias}.${order.attribute}`, { direction, entityName: alias });
277
+ map.set(`${link.name}.${order.attribute}`, { direction, entityName: alias });
278
+ });
279
+ // Recurse into nested link-entities
280
+ if (link.links) {
281
+ processLinkOrders(link.links);
282
+ }
283
+ });
284
+ };
285
+ processLinkOrders(fetchQuery.entity.links);
286
+
287
+ return map;
288
+ }, [fetchQuery]);
289
+
290
+ // Get primary ID column name (typically {entity}id) from the FetchXML root entity
291
+ const primaryIdColumn = useMemo(() => {
292
+ // Use the root entity from FetchXML if available, otherwise fall back to result entity
293
+ const entityName = fetchQuery?.entity?.name || result?.entityLogicalName;
294
+ if (!entityName) return null;
295
+ return `${entityName}id`;
296
+ }, [fetchQuery, result]);
297
+
298
+ // Generate stable row IDs using primary key GUID
299
+ const getRowId = useCallback(
300
+ (item: Record<string, unknown>) => {
301
+ // CRITICAL: Use the GUID from the primary ID column for stable row identity
302
+ if (primaryIdColumn && item[primaryIdColumn]) {
303
+ const id = item[primaryIdColumn];
304
+ // Ensure it's a valid GUID string (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
305
+ if (typeof id === "string" && id.length > 0) {
306
+ return id;
307
+ }
308
+ }
309
+ // Fallback: this should NOT happen if FetchXML includes the primary ID
310
+ // Use index as last resort (will break selection if data changes)
311
+ console.warn("Row without primary ID found - selection may not work correctly");
312
+ return `row_${Math.random()}`;
313
+ },
314
+ [primaryIdColumn]
315
+ );
316
+
317
+ // Build set of requested attributes from FetchXML query
318
+ const requestedAttributes = useMemo(() => {
319
+ const attrs = new Set<string>();
320
+ if (!fetchQuery) return attrs;
321
+
322
+ // Collect from root entity attributes
323
+ const collectAttributes = (attributes: Array<AttributeNode>) => {
324
+ attributes.forEach((attr) => {
325
+ // Add both the original attribute name and alias (if exists)
326
+ attrs.add(attr.name);
327
+ if (attr.alias) {
328
+ attrs.add(attr.alias);
329
+ }
330
+ });
331
+ };
332
+
333
+ if (fetchQuery.entity.attributes) {
334
+ collectAttributes(fetchQuery.entity.attributes);
335
+ }
336
+
337
+ // Collect from link-entities recursively
338
+ const collectFromLinks = (links: Array<any>, linkAlias?: string) => {
339
+ links.forEach((link) => {
340
+ const currentAlias = link.alias || linkAlias;
341
+ if (link.attributes) {
342
+ link.attributes.forEach((attr: AttributeNode) => {
343
+ // For link-entity attributes, the column name in results may be:
344
+ // 1. attr.alias (if explicitly aliased)
345
+ // 2. linkAlias.attributeName (if link has alias)
346
+ // 3. entityname.attributeName (Dataverse default)
347
+ attrs.add(attr.name); // Base attribute name
348
+ if (attr.alias) {
349
+ attrs.add(attr.alias); // Explicit alias
350
+ }
351
+ if (currentAlias) {
352
+ attrs.add(`${currentAlias}.${attr.name}`); // link-entity prefixed
353
+ }
354
+ if (link.name) {
355
+ attrs.add(`${link.name}.${attr.name}`); // entity name prefixed
356
+ }
357
+ });
358
+ }
359
+ if (link.links) {
360
+ collectFromLinks(link.links, currentAlias);
361
+ }
362
+ });
363
+ };
364
+
365
+ if (fetchQuery.entity.links) {
366
+ collectFromLinks(fetchQuery.entity.links);
367
+ }
368
+
369
+ return attrs;
370
+ }, [fetchQuery]);
371
+
372
+ // Build comprehensive column metadata map from FetchXML query structure
373
+ // Maps column name (as it appears in results) to display info
374
+ interface ColumnDisplayInfo {
375
+ /** Original attribute logical name */
376
+ attributeName: string;
377
+ /** Entity logical name this attribute belongs to */
378
+ entityName: string;
379
+ /** Explicit alias provided in FetchXML (if any) */
380
+ alias?: string;
381
+ /** Link-entity alias (if from link-entity) */
382
+ linkEntityAlias?: string;
383
+ /** Whether this is a lookup column (N:1 navigation) */
384
+ isLookupColumn?: boolean;
385
+ /** The lookup attribute on parent entity that joins to this link-entity (for N:1 relationships) */
386
+ lookupAttribute?: string;
387
+ }
388
+
389
+ const columnDisplayMap = useMemo(() => {
390
+ const map = new Map<string, ColumnDisplayInfo>();
391
+ if (!fetchQuery?.entity?.name) return map;
392
+
393
+ const rootEntityName = fetchQuery.entity.name;
394
+
395
+ // Collect from root entity attributes
396
+ if (fetchQuery.entity.attributes) {
397
+ fetchQuery.entity.attributes.forEach((attr) => {
398
+ const columnKey = attr.alias || attr.name;
399
+ map.set(columnKey, {
400
+ attributeName: attr.name,
401
+ entityName: rootEntityName,
402
+ alias: attr.alias,
403
+ });
404
+ // Also map lookup column format (_attributename_value) for lookup fields
405
+ map.set(`_${attr.name}_value`, {
406
+ attributeName: attr.name,
407
+ entityName: rootEntityName,
408
+ isLookupColumn: true,
409
+ });
410
+ });
411
+ }
412
+
413
+ // Collect from link-entities recursively
414
+ const collectFromLinks = (
415
+ links: import("../../model/nodes").LinkEntityNode[],
416
+ parentLookupAttr?: string
417
+ ) => {
418
+ links.forEach((link) => {
419
+ const linkAlias = link.alias || link.name;
420
+ // For N:1 relationships, 'to' is the lookup attribute on the parent entity (e.g., primarycontactid)
421
+ // For 1:N relationships, 'to' is the PK on the parent entity
422
+ // We use 'to' as it represents the attribute on the parent that links to this entity
423
+ const lookupAttr = link.to || parentLookupAttr;
424
+
425
+ if (link.attributes) {
426
+ link.attributes.forEach((attr) => {
427
+ // For link-entity attributes, the column in results may be:
428
+ // 1. attr.alias (if explicitly aliased)
429
+ // 2. linkAlias.attributeName (standard format)
430
+ const columnKey = attr.alias || `${linkAlias}.${attr.name}`;
431
+ map.set(columnKey, {
432
+ attributeName: attr.name,
433
+ entityName: link.name,
434
+ alias: attr.alias,
435
+ linkEntityAlias: linkAlias,
436
+ lookupAttribute: lookupAttr,
437
+ });
438
+
439
+ // Also map entityname.attributeName variant
440
+ if (link.name !== linkAlias) {
441
+ map.set(`${link.name}.${attr.name}`, {
442
+ attributeName: attr.name,
443
+ entityName: link.name,
444
+ linkEntityAlias: linkAlias,
445
+ lookupAttribute: lookupAttr,
446
+ });
447
+ }
448
+ });
449
+ }
450
+
451
+ if (link.links) {
452
+ collectFromLinks(link.links, link.to);
453
+ }
454
+ });
455
+ };
456
+
457
+ if (fetchQuery.entity.links) {
458
+ collectFromLinks(fetchQuery.entity.links);
459
+ }
460
+
461
+ return map;
462
+ }, [fetchQuery]);
463
+
464
+ // Memoize column definitions with display names and formatted values
465
+ // Uses columnConfig for ordering and widths when available
466
+ const columns = useMemo(() => {
467
+ if (!result || result.columns.length === 0) return [];
468
+
469
+ // Filter out OData and CRM annotation columns
470
+ let displayableColumns = filterDisplayableColumns(result.columns);
471
+
472
+ // Further filter to ONLY columns requested in FetchXML (if fetchQuery is available)
473
+ if (fetchQuery && requestedAttributes.size > 0) {
474
+ displayableColumns = displayableColumns.filter((col) => {
475
+ // Check if this column or its base attribute (for lookups) is requested
476
+ // For lookup columns (_attributename_value), check the base attribute name
477
+ if (col.startsWith("_") && col.endsWith("_value")) {
478
+ const baseAttr = col.slice(1, -6); // Remove _ prefix and _value suffix
479
+ return requestedAttributes.has(baseAttr);
480
+ }
481
+ // For aliased or regular columns, check directly
482
+ return requestedAttributes.has(col);
483
+ });
484
+ }
485
+
486
+ // If columnConfig is provided, use it to order the columns
487
+ if (columnConfig && columnConfig.columns.length > 0) {
488
+ const configOrder = columnConfig.columns.map((c) => c.name);
489
+
490
+ // Build a map that handles lookup field naming convention
491
+ // FetchXML/config uses: primarycontactid, but Dataverse returns: _primarycontactid_value
492
+ const getConfigIndex = (col: string): number => {
493
+ // Direct match first
494
+ const directIndex = configOrder.indexOf(col);
495
+ if (directIndex >= 0) return directIndex;
496
+
497
+ // If it's a lookup column (_xxx_value), try matching the base attribute name
498
+ if (col.startsWith("_") && col.endsWith("_value")) {
499
+ const baseAttr = col.slice(1, -6);
500
+ const baseIndex = configOrder.indexOf(baseAttr);
501
+ if (baseIndex >= 0) return baseIndex;
502
+ }
503
+
504
+ return -1; // Not in config
505
+ };
506
+
507
+ // Sort displayable columns by config order, append any not in config at end
508
+ const inConfig = displayableColumns.filter((col) => getConfigIndex(col) >= 0);
509
+ const notInConfig = displayableColumns.filter((col) => getConfigIndex(col) < 0);
510
+
511
+ displayableColumns = [
512
+ ...inConfig.sort((a, b) => getConfigIndex(a) - getConfigIndex(b)),
513
+ ...notInConfig,
514
+ ];
515
+ }
516
+
517
+ return displayableColumns.flatMap((col) => {
518
+ // Get column display info from our comprehensive map
519
+ const displayInfo = columnDisplayMap.get(col);
520
+
521
+ // Helper to get attribute metadata from multi-entity map
522
+ const getAttributeMetadata = (
523
+ entityName: string,
524
+ attrName: string
525
+ ): AttributeMetadata | undefined => {
526
+ return attributeMetadata?.get(entityName)?.get(attrName);
527
+ };
528
+
529
+ // Resolve attribute metadata based on display info
530
+ let attribute: AttributeMetadata | undefined;
531
+
532
+ if (displayInfo) {
533
+ attribute = getAttributeMetadata(displayInfo.entityName, displayInfo.attributeName);
534
+ } else if (col.startsWith("_") && col.endsWith("_value")) {
535
+ // Fallback for lookup columns not in our map
536
+ const baseAttr = col.slice(1, -6);
537
+ const rootEntity = fetchQuery?.entity?.name;
538
+ if (rootEntity) {
539
+ attribute = getAttributeMetadata(rootEntity, baseAttr);
540
+ }
541
+ } else {
542
+ // Fallback: try root entity
543
+ const rootEntity = fetchQuery?.entity?.name;
544
+ if (rootEntity) {
545
+ attribute = getAttributeMetadata(rootEntity, col);
546
+ }
547
+ }
548
+
549
+ // Determine display name with comprehensive logic:
550
+ // 1. For aliased columns: Use the alias as display name
551
+ // 2. For lookup columns (_attr_value): Use attribute display name (e.g., "Primary Contact")
552
+ // 3. For link-entity columns: "{Attribute Display Name} ({Lookup Attribute Display Name})"
553
+ // 4. For root entity columns: Use attribute display name
554
+ // 5. Fallback: Clean up column name
555
+ // If displaySettings.useLogicalNames is true, show logical names instead
556
+ let displayName: string;
557
+
558
+ // Helper to get logical name (column key or attribute LogicalName)
559
+ const getLogicalName = (): string => {
560
+ if (displayInfo?.isLookupColumn) {
561
+ return displayInfo.attributeName || col.slice(1, -6);
562
+ }
563
+ if (displayInfo?.linkEntityAlias) {
564
+ return `${displayInfo.linkEntityAlias}.${displayInfo.attributeName || col}`;
565
+ }
566
+ return displayInfo?.attributeName || col;
567
+ };
568
+
569
+ if (displaySettings?.useLogicalNames) {
570
+ // Show logical name
571
+ displayName = getLogicalName();
572
+ } else if (displayInfo?.alias) {
573
+ // User-specified alias takes precedence
574
+ displayName = displayInfo.alias;
575
+ } else if (displayInfo?.isLookupColumn) {
576
+ // Lookup column: show attribute display name (represents the lookup field)
577
+ displayName =
578
+ attribute?.DisplayName?.UserLocalizedLabel?.Label ||
579
+ displayInfo.attributeName ||
580
+ col.slice(1, -6); // Remove _ prefix and _value suffix
581
+ } else if (displayInfo?.linkEntityAlias) {
582
+ // Link-entity column: "Attribute Display Name (Lookup Attribute Display Name)"
583
+ // e.g., "Email (Primary Contact)" where Email is from contact entity, Primary Contact is the lookup
584
+ const attrDisplayName =
585
+ attribute?.DisplayName?.UserLocalizedLabel?.Label || displayInfo.attributeName;
586
+
587
+ // Get the lookup attribute display name from root entity metadata
588
+ let lookupDisplayName = displayInfo.lookupAttribute || displayInfo.linkEntityAlias;
589
+ if (displayInfo.lookupAttribute) {
590
+ const rootEntityName = fetchQuery?.entity?.name;
591
+ if (rootEntityName) {
592
+ const lookupAttr = getAttributeMetadata(rootEntityName, displayInfo.lookupAttribute);
593
+ if (lookupAttr?.DisplayName?.UserLocalizedLabel?.Label) {
594
+ lookupDisplayName = lookupAttr.DisplayName.UserLocalizedLabel.Label;
595
+ }
596
+ }
597
+ }
598
+
599
+ displayName = `${attrDisplayName} (${lookupDisplayName})`;
600
+ } else if (attribute?.DisplayName?.UserLocalizedLabel?.Label) {
601
+ // Root entity attribute with metadata display name
602
+ displayName = attribute.DisplayName.UserLocalizedLabel.Label;
603
+ } else if (col.startsWith("_") && col.endsWith("_value")) {
604
+ // Fallback for lookup columns without metadata
605
+ displayName = col.slice(1, -6);
606
+ } else {
607
+ // Final fallback: use column name as-is
608
+ displayName = col;
609
+ }
610
+
611
+ // Check if this column is sorted (from FetchXML orders)
612
+ const sortInfo = sortStateMap.get(col);
613
+ const valueMode = displaySettings?.valueDisplayMode ?? "formatted";
614
+
615
+ // For "both" mode, check if this column has formatted values in the result set
616
+ // Only create a raw column if at least one record has a different formatted value
617
+ const hasFormattedValues =
618
+ valueMode === "both" &&
619
+ result?.rows?.some((record: Record<string, unknown>) => {
620
+ const rawValue = record[col];
621
+ const formattedValue = getFormattedValue(record, col);
622
+ return formattedValue !== undefined && formattedValue !== rawValue;
623
+ });
624
+
625
+ // For "both" mode with formatted values, create two columns
626
+ if (valueMode === "both" && hasFormattedValues) {
627
+ // Create formatted value column
628
+ const formattedColumn = createTableColumn<Record<string, unknown>>({
629
+ columnId: col,
630
+ compare: (a, b) => {
631
+ const aVal = String(getFormattedValue(a, col) ?? a[col] ?? "");
632
+ const bVal = String(getFormattedValue(b, col) ?? b[col] ?? "");
633
+ return aVal.localeCompare(bVal);
634
+ },
635
+ renderHeaderCell: () => (
636
+ <span className={styles.headerCellContent}>
637
+ <span style={{ fontWeight: 600 }}>{displayName}</span>
638
+ {/* Show sort indicator based on FetchXML order state */}
639
+ {sortInfo && (
640
+ <span className={styles.sortIndicator}>
641
+ {sortInfo.direction === "ascending" ? (
642
+ <ArrowSortUp16Regular />
643
+ ) : (
644
+ <ArrowSortDown16Regular />
645
+ )}
646
+ </span>
647
+ )}
648
+ </span>
649
+ ),
650
+ renderCell: (item) => {
651
+ const rawValue = item[col];
652
+ const formattedValue = getFormattedValue(item, col);
653
+ return (
654
+ <TableCellLayout>
655
+ {getCellRenderer(attribute?.AttributeType, rawValue, formattedValue, attribute)}
656
+ </TableCellLayout>
657
+ );
658
+ },
659
+ });
660
+
661
+ // Create raw value column
662
+ const rawColumn = createTableColumn<Record<string, unknown>>({
663
+ columnId: `${col}__raw`,
664
+ compare: (a, b) => {
665
+ const aVal = String(a[col] ?? "");
666
+ const bVal = String(b[col] ?? "");
667
+ return aVal.localeCompare(bVal);
668
+ },
669
+ renderHeaderCell: () => (
670
+ <span className={styles.headerCellContent}>
671
+ <span style={{ fontWeight: 600 }}>{displayName} (Raw)</span>
672
+ </span>
673
+ ),
674
+ renderCell: (item) => {
675
+ const rawValue = item[col];
676
+ return (
677
+ <TableCellLayout>
678
+ {rawValue === null || rawValue === undefined ? (
679
+ <span>—</span>
680
+ ) : (
681
+ <span>{String(rawValue)}</span>
682
+ )}
683
+ </TableCellLayout>
684
+ );
685
+ },
686
+ });
687
+
688
+ return [formattedColumn, rawColumn];
689
+ }
690
+
691
+ // Standard single column (formatted or raw mode)
692
+ return createTableColumn<Record<string, unknown>>({
693
+ columnId: col,
694
+ compare: (a, b) => {
695
+ const aVal = String(getFormattedValue(a, col) ?? a[col] ?? "");
696
+ const bVal = String(getFormattedValue(b, col) ?? b[col] ?? "");
697
+ return aVal.localeCompare(bVal);
698
+ },
699
+ renderHeaderCell: () => (
700
+ <span className={styles.headerCellContent}>
701
+ <span style={{ fontWeight: 600 }}>{displayName}</span>
702
+ {/* Show sort indicator based on FetchXML order state */}
703
+ {sortInfo && (
704
+ <span className={styles.sortIndicator}>
705
+ {sortInfo.direction === "ascending" ? (
706
+ <ArrowSortUp16Regular />
707
+ ) : (
708
+ <ArrowSortDown16Regular />
709
+ )}
710
+ </span>
711
+ )}
712
+ </span>
713
+ ),
714
+ renderCell: (item) => {
715
+ const rawValue = item[col];
716
+ const formattedValue = getFormattedValue(item, col);
717
+
718
+ // Determine what to display based on value display mode
719
+ if (valueMode === "raw") {
720
+ // Show raw value only (skip cell renderer formatting)
721
+ return (
722
+ <TableCellLayout>
723
+ {rawValue === null || rawValue === undefined ? (
724
+ <span>—</span>
725
+ ) : (
726
+ <span>{String(rawValue)}</span>
727
+ )}
728
+ </TableCellLayout>
729
+ );
730
+ } else {
731
+ // Default: formatted (use cell renderer)
732
+ return (
733
+ <TableCellLayout>
734
+ {getCellRenderer(attribute?.AttributeType, rawValue, formattedValue, attribute)}
735
+ </TableCellLayout>
736
+ );
737
+ }
738
+ },
739
+ });
740
+ });
741
+ }, [
742
+ result,
743
+ attributeMetadata,
744
+ columnDisplayMap,
745
+ fetchQuery,
746
+ requestedAttributes,
747
+ columnConfig,
748
+ styles,
749
+ sortStateMap,
750
+ displaySettings,
751
+ ]);
752
+
753
+ // Selection handlers
754
+ const handleSelectionChange = useCallback(
755
+ (_e: unknown, data: { selectedItems: Set<string | number> }) => {
756
+ setSelectedItems(data.selectedItems);
757
+ onSelectedCountChange?.(data.selectedItems.size);
758
+ // Convert Set to array of string IDs for the callback
759
+ const recordIds = Array.from(data.selectedItems).map(String);
760
+ onSelectionChange?.(recordIds);
761
+ },
762
+ [onSelectedCountChange, onSelectionChange]
763
+ );
764
+
765
+ // Sort change handler: notifies parent to update FetchXML orders
766
+ // Shift+click adds/toggles column in multi-sort, regular click replaces all
767
+ // We calculate direction ourselves based on FetchXML state, not Fluent's sortDirection
768
+ const handleSortChange = useCallback(
769
+ (
770
+ e: React.MouseEvent,
771
+ data: { sortColumn: string | number | undefined; sortDirection: SortDirection }
772
+ ) => {
773
+ if (!onSortChange) return;
774
+
775
+ const columnId = String(data.sortColumn);
776
+ const isMultiSort = e.shiftKey;
777
+
778
+ // Get current sort info for this column from FetchXML state
779
+ const sortInfo = sortStateMap.get(columnId);
780
+
781
+ // Calculate direction: toggle if already sorted, otherwise ascending
782
+ let direction: SortDirection;
783
+ if (sortInfo) {
784
+ // Column is already sorted - toggle direction
785
+ direction = sortInfo.direction === "ascending" ? "descending" : "ascending";
786
+ } else {
787
+ // Column not sorted yet - start with ascending
788
+ direction = "ascending";
789
+ }
790
+
791
+ onSortChange({
792
+ columnId,
793
+ direction,
794
+ isMultiSort,
795
+ entityName: sortInfo?.entityName,
796
+ });
797
+ },
798
+ [onSortChange, sortStateMap]
799
+ );
800
+
801
+ // Row renderer function for virtualization with scrolling indicator support
802
+ const renderRow: RowRenderer<Record<string, unknown>> = useCallback(
803
+ ({ item, rowId }, style, _index, isScrolling) => (
804
+ <DataGridRow<Record<string, unknown>> key={rowId} style={style} className={styles.row}>
805
+ {({ renderCell }) => (
806
+ <DataGridCell focusMode="group">
807
+ {isScrolling ? (
808
+ <Skeleton style={{ width: "100%" }}>
809
+ <SkeletonItem shape="rectangle" animation="pulse" appearance="translucent" />
810
+ </Skeleton>
811
+ ) : (
812
+ renderCell(item)
813
+ )}
814
+ </DataGridCell>
815
+ )}
816
+ </DataGridRow>
817
+ ),
818
+ [styles.row]
819
+ );
820
+
821
+ // Build column sizing options from columnConfig or defaults
822
+ const columnSizingOptions = useMemo(() => {
823
+ const options: Record<string, { minWidth: number; defaultWidth: number; idealWidth?: number }> =
824
+ {};
825
+
826
+ // Create a map of column widths from config
827
+ // Also map lookup field variants (_xxx_value -> config width for xxx)
828
+ const configWidths = new Map<string, number>();
829
+ if (columnConfig) {
830
+ for (const col of columnConfig.columns) {
831
+ configWidths.set(col.name, col.width);
832
+ // Also map the lookup column variant
833
+ configWidths.set(`_${col.name}_value`, col.width);
834
+ }
835
+ }
836
+
837
+ // Set sizing for each column
838
+ for (const col of columns) {
839
+ const columnId = String(col.columnId);
840
+ const configWidth = configWidths.get(columnId);
841
+ const width = configWidth ?? 150; // Default to 150 if not in config
842
+ options[columnId] = {
843
+ minWidth: 80,
844
+ defaultWidth: width,
845
+ idealWidth: width,
846
+ };
847
+ }
848
+
849
+ return options;
850
+ }, [columns, columnConfig]);
851
+
852
+ // Handle column resize callback
853
+ // Maps lookup column names back to config names for storage
854
+ const handleColumnResize = useCallback(
855
+ (
856
+ _e: KeyboardEvent | TouchEvent | MouseEvent | undefined,
857
+ data: { columnId: string | number; width: number }
858
+ ) => {
859
+ if (onColumnResize) {
860
+ let columnName = String(data.columnId);
861
+ // If it's a lookup column (_xxx_value), store width under the base attribute name
862
+ if (columnName.startsWith("_") && columnName.endsWith("_value")) {
863
+ columnName = columnName.slice(1, -6);
864
+ }
865
+ onColumnResize(columnName, data.width);
866
+ }
867
+ },
868
+ [onColumnResize]
869
+ );
870
+
871
+ // Infinite scroll: load more when user scrolls near bottom
872
+ const handleItemsRendered = useCallback(
873
+ ({ visibleStopIndex }: { visibleStartIndex: number; visibleStopIndex: number }) => {
874
+ if (!result || !onLoadMore || isLoadingMore) return;
875
+
876
+ // If user has scrolled to within 10 rows of the end, and there are more records
877
+ const rowCount = result.rows.length;
878
+ const threshold = Math.min(10, Math.max(1, Math.floor(rowCount * 0.1))); // 10 rows or 10% of data, whichever is smaller
879
+
880
+ if (visibleStopIndex >= rowCount - threshold && result.moreRecords) {
881
+ console.log("[ResultsGrid] Near bottom, triggering load more:", {
882
+ visibleStopIndex,
883
+ rowCount,
884
+ threshold,
885
+ moreRecords: result.moreRecords,
886
+ });
887
+ onLoadMore();
888
+ }
889
+ },
890
+ [result, onLoadMore, isLoadingMore]
891
+ );
892
+
893
+ // Note: Data is already sorted by Dataverse based on FetchXML orders
894
+ // No local sorting needed - we display rows as returned
895
+
896
+ if (isLoading) {
897
+ return <div className={styles.emptyState}>Loading results...</div>;
898
+ }
899
+
900
+ if (!result) {
901
+ return <div className={styles.emptyState}>Click Execute to run the query</div>;
902
+ }
903
+
904
+ if (result.rows.length === 0) {
905
+ return <div className={styles.emptyState}>No records found</div>;
906
+ }
907
+
908
+ console.log("[ResultsGrid] Rendering grid:", {
909
+ rowCount: result.rows.length,
910
+ columnCount: columns.length,
911
+ gridDimensions,
912
+ primaryIdColumn,
913
+ firstRowId: result.rows.length > 0 ? getRowId(result.rows[0]) : "none",
914
+ columnIds: columns.slice(0, 5).map((c) => c.columnId),
915
+ sortStateMapSize: sortStateMap.size,
916
+ });
917
+
918
+ return (
919
+ <div className={styles.container}>
920
+ <div className={styles.gridWrapper}>
921
+ <div ref={containerRef} className={styles.gridContent}>
922
+ <DataGrid
923
+ aria-label={result?.entityLogicalName ?? "Results"}
924
+ items={result.rows}
925
+ columns={columns}
926
+ sortable
927
+ // Don't set sortState - we manage sort indicators ourselves in renderHeaderCell
928
+ // This avoids double arrows (Fluent's + ours) and gives us control over multi-sort display
929
+ resizableColumns
930
+ resizableColumnsOptions={{ autoFitColumns: false }}
931
+ columnSizingOptions={columnSizingOptions}
932
+ onColumnResize={handleColumnResize}
933
+ onSortChange={handleSortChange}
934
+ selectionMode="multiselect"
935
+ selectedItems={selectedItems}
936
+ onSelectionChange={handleSelectionChange}
937
+ getRowId={getRowId}
938
+ >
939
+ <DataGridHeader ref={headerRef as any} style={{ paddingRight: scrollbarWidth }}>
940
+ <DataGridRow style={{ minHeight: "43px", maxHeight: "43px" }}>
941
+ {({ renderHeaderCell }) => (
942
+ <DataGridHeaderCell className={styles.headerCell}>
943
+ {renderHeaderCell()}
944
+ </DataGridHeaderCell>
945
+ )}
946
+ </DataGridRow>
947
+ </DataGridHeader>
948
+ <DataGridBody<Record<string, unknown>>
949
+ className={styles.body}
950
+ itemSize={ROW_HEIGHT}
951
+ height={Math.max(0, gridDimensions.height - headerHeight)}
952
+ listProps={{
953
+ useIsScrolling: true,
954
+ className: styles.body,
955
+ onItemsRendered: handleItemsRendered,
956
+ }}
957
+ >
958
+ {renderRow}
959
+ </DataGridBody>
960
+ </DataGrid>
961
+ </div>
962
+ <div className={styles.infoBar}>
963
+ <span>
964
+ Rows: {result.rows.length}
965
+ {result.moreRecords && !isLoadingMore && " (more available)"}
966
+ {isLoadingMore && " (loading more...)"}
967
+ {result.executionTimeMs !== undefined && ` | Executed in ${result.executionTimeMs}ms`}
968
+ </span>
969
+ <span>
970
+ Selected: {selectedItems.size}
971
+ {selectedItems.size > 0 &&
972
+ selectedItems.size === result.rows.length &&
973
+ result.moreRecords && (
974
+ <span className={styles.selectionWarning}>
975
+ ⚠ Selection limited to loaded records. Use "Retrieve all pages" to load more.
976
+ </span>
977
+ )}
978
+ </span>
979
+ </div>
980
+ </div>
981
+ </div>
982
+ );
983
+ }