@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 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Lazy metadata loading with caching and promise memoization
3
+ * Prevents duplicate API calls for the same entity
4
+ */
5
+
6
+ import * as pptbClient from "../api/pptbClient";
7
+ import { metadataCache } from "../state/cache";
8
+ import type { EntityMetadata, AttributeMetadata, RelationshipMetadata } from "../api/pptbClient";
9
+
10
+ /**
11
+ * Load all entities (with caching and de-duplication)
12
+ */
13
+ export async function loadAllEntities(advancedFindOnly: boolean = true): Promise<EntityMetadata[]> {
14
+ // Check cache first
15
+ const cached = metadataCache.getAllEntities();
16
+ if (cached) {
17
+ return cached;
18
+ }
19
+
20
+ // Check if request is already in-flight
21
+ const inFlight = metadataCache.getAllEntitiesPromise();
22
+ if (inFlight) {
23
+ return inFlight;
24
+ }
25
+
26
+ // Make new request
27
+ const promise = pptbClient.getAllEntities(advancedFindOnly);
28
+ metadataCache.setAllEntitiesPromise(promise);
29
+
30
+ try {
31
+ const entities = await promise;
32
+ metadataCache.setAllEntities(entities);
33
+ metadataCache.clearAllEntitiesPromise();
34
+ return entities;
35
+ } catch (error) {
36
+ console.error("loadAllEntities: API error:", error);
37
+ metadataCache.clearAllEntitiesPromise();
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Load entity metadata (with caching)
44
+ */
45
+ export async function loadEntityMetadata(logicalName: string): Promise<EntityMetadata> {
46
+ // Check cache first
47
+ const cached = metadataCache.getEntityMetadata(logicalName);
48
+ if (cached) {
49
+ return cached;
50
+ }
51
+
52
+ // Make request
53
+ const metadata = await pptbClient.getEntityMetadata(logicalName);
54
+ metadataCache.setEntityMetadata(logicalName, metadata);
55
+ return metadata;
56
+ }
57
+
58
+ /**
59
+ * Load entity attributes (with caching and de-duplication)
60
+ * @param logicalName - Entity logical name
61
+ * @param advancedFindOnly - Filter to only attributes valid for advanced find (default: true)
62
+ */
63
+ export async function loadEntityAttributes(
64
+ logicalName: string,
65
+ advancedFindOnly: boolean = true
66
+ ): Promise<AttributeMetadata[]> {
67
+ // Check cache first
68
+ const cacheKey = `${logicalName}_${advancedFindOnly}`;
69
+ const cached = metadataCache.getEntityAttributes(cacheKey);
70
+ if (cached) {
71
+ return cached;
72
+ }
73
+
74
+ // Check if request is already in-flight
75
+ const inFlight = metadataCache.getAttributesPromise(cacheKey);
76
+ if (inFlight) {
77
+ return inFlight;
78
+ }
79
+
80
+ // Make new request
81
+ const promise = pptbClient.getEntityAttributes(logicalName, advancedFindOnly);
82
+ metadataCache.setAttributesPromise(cacheKey, promise);
83
+
84
+ try {
85
+ const attributes = await promise;
86
+ metadataCache.setEntityAttributes(cacheKey, attributes);
87
+ metadataCache.clearAttributesPromise(cacheKey);
88
+ return attributes;
89
+ } catch (error) {
90
+ metadataCache.clearAttributesPromise(cacheKey);
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Load a single attribute with full metadata including OptionSet expansion
97
+ * Used for loading picklist/boolean options for value pickers
98
+ * @param entityLogicalName - Entity logical name
99
+ * @param attributeLogicalName - Attribute logical name
100
+ */
101
+ export async function loadAttributeWithOptionSet(
102
+ entityLogicalName: string,
103
+ attributeLogicalName: string
104
+ ): Promise<AttributeMetadata> {
105
+ // Check cache first
106
+ const cacheKey = `${entityLogicalName}_${attributeLogicalName}_optionset`;
107
+ const cached = metadataCache.getEntityAttributes(cacheKey);
108
+ if (cached && cached.length > 0) {
109
+ return cached[0];
110
+ }
111
+
112
+ // Make request with OptionSet expansion
113
+ const attribute = await pptbClient.getAttributeWithOptionSet(
114
+ entityLogicalName,
115
+ attributeLogicalName
116
+ );
117
+
118
+ // Cache the result
119
+ metadataCache.setEntityAttributes(cacheKey, [attribute]);
120
+ return attribute;
121
+ }
122
+
123
+ /**
124
+ * Load a single attribute with detailed type-specific metadata (MinValue, MaxValue, Precision, Format)
125
+ * Used for numeric and datetime attributes that need validation constraints
126
+ * @param entityLogicalName - Entity logical name
127
+ * @param attributeLogicalName - Attribute logical name
128
+ * @param attributeType - Attribute type (Integer, BigInt, Decimal, Double, DateTime)
129
+ */
130
+ export async function loadAttributeDetailedMetadata(
131
+ entityLogicalName: string,
132
+ attributeLogicalName: string,
133
+ attributeType: string
134
+ ): Promise<AttributeMetadata> {
135
+ // Check cache first
136
+ const cacheKey = `${entityLogicalName}_${attributeLogicalName}_detailed`;
137
+ const cached = metadataCache.getEntityAttributes(cacheKey);
138
+ if (cached && cached.length > 0) {
139
+ return cached[0];
140
+ }
141
+
142
+ // Make request with type casting to get detailed properties
143
+ const attribute = await pptbClient.getAttributeDetailedMetadata(
144
+ entityLogicalName,
145
+ attributeLogicalName,
146
+ attributeType
147
+ );
148
+
149
+ // Cache the result
150
+ metadataCache.setEntityAttributes(cacheKey, [attribute]);
151
+ return attribute;
152
+ }
153
+
154
+ /**
155
+ * Load entity relationships (with caching)
156
+ * @param logicalName - Entity logical name
157
+ * @param advancedFindOnly - Filter to only relationships valid for advanced find (default: true)
158
+ */
159
+ export async function loadEntityRelationships(
160
+ logicalName: string,
161
+ advancedFindOnly: boolean = true
162
+ ): Promise<{
163
+ oneToMany: RelationshipMetadata[];
164
+ manyToOne: RelationshipMetadata[];
165
+ manyToMany: RelationshipMetadata[];
166
+ }> {
167
+ // Check cache first
168
+ const cacheKey = `${logicalName}_${advancedFindOnly}`;
169
+ const cached = metadataCache.getEntityRelationships(cacheKey);
170
+ if (cached) {
171
+ return cached;
172
+ }
173
+
174
+ // Make request
175
+ const relationships = await pptbClient.getEntityRelationships(logicalName, advancedFindOnly);
176
+ metadataCache.setEntityRelationships(cacheKey, relationships);
177
+ return relationships;
178
+ }
179
+
180
+ /**
181
+ * Clear all metadata cache
182
+ */
183
+ export function clearMetadataCache(): void {
184
+ metadataCache.clear();
185
+ }
186
+
187
+ /**
188
+ * Clear cache for specific entity
189
+ */
190
+ export function clearEntityCache(logicalName: string): void {
191
+ metadataCache.clearEntity(logicalName);
192
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Local Excel export using exceljs library
3
+ * Exports query results with native Excel data types (numbers, dates, etc.)
4
+ */
5
+
6
+ import ExcelJS from "exceljs";
7
+ import type { AttributeMetadata } from "./pptbClient";
8
+ import { getFormattedValue } from "../ui/RightPane/FormattedValueUtils";
9
+ import type { DisplaySettings } from "../model/displaySettings";
10
+
11
+ interface ExportToExcelLocalOptions {
12
+ /** Records to export */
13
+ records: Record<string, unknown>[];
14
+ /** Column keys in the order they should appear */
15
+ columns: string[];
16
+ /** Map of column key to display name for header */
17
+ columnDisplayNames: Map<string, string>;
18
+ /** Multi-entity attribute metadata for type conversion */
19
+ attributeMetadata?: Map<string, Map<string, AttributeMetadata>>;
20
+ /** Entity logical name for single-entity metadata lookup */
21
+ entityName?: string;
22
+ /** Filename without extension */
23
+ fileName?: string;
24
+ /** Display settings for column headers and value modes */
25
+ displaySettings?: DisplaySettings;
26
+ }
27
+
28
+ /**
29
+ * Export records to Excel with native data types
30
+ * Returns the workbook buffer for download
31
+ */
32
+ export async function exportToExcelLocal(
33
+ options: ExportToExcelLocalOptions
34
+ ): Promise<{ buffer: ArrayBuffer; fileName: string }> {
35
+ const {
36
+ records,
37
+ columns,
38
+ columnDisplayNames,
39
+ attributeMetadata,
40
+ entityName,
41
+ fileName = "export",
42
+ displaySettings,
43
+ } = options;
44
+
45
+ // Extract display settings with defaults
46
+ const useLogicalNames = displaySettings?.useLogicalNames ?? false;
47
+ const valueDisplayMode = displaySettings?.valueDisplayMode ?? "formatted";
48
+
49
+ const workbook = new ExcelJS.Workbook();
50
+ workbook.creator = "FetchXML Studio";
51
+ workbook.created = new Date();
52
+
53
+ const worksheet = workbook.addWorksheet("Data");
54
+
55
+ // Helper to get attribute metadata
56
+ const getAttrMeta = (col: string): AttributeMetadata | undefined => {
57
+ if (!attributeMetadata) return undefined;
58
+
59
+ // Try with entity name first
60
+ if (entityName) {
61
+ const entityAttrs = attributeMetadata.get(entityName);
62
+ if (entityAttrs) {
63
+ // Direct column name
64
+ if (entityAttrs.has(col)) return entityAttrs.get(col);
65
+ // Lookup field (_attr_value -> attr)
66
+ if (col.startsWith("_") && col.endsWith("_value")) {
67
+ const baseAttr = col.slice(1, -6);
68
+ if (entityAttrs.has(baseAttr)) return entityAttrs.get(baseAttr);
69
+ }
70
+ }
71
+ }
72
+
73
+ // Try to find in any entity's metadata
74
+ for (const [, entityAttrs] of attributeMetadata) {
75
+ if (entityAttrs.has(col)) return entityAttrs.get(col);
76
+ if (col.startsWith("_") && col.endsWith("_value")) {
77
+ const baseAttr = col.slice(1, -6);
78
+ if (entityAttrs.has(baseAttr)) return entityAttrs.get(baseAttr);
79
+ }
80
+ }
81
+
82
+ return undefined;
83
+ };
84
+
85
+ // Helper to check if a column has formatted values in the data
86
+ const hasFormattedValues = (col: string): boolean => {
87
+ return records.some((record) => {
88
+ const rawValue = record[col];
89
+ const formattedValue = getFormattedValue(record, col);
90
+ return formattedValue !== undefined && formattedValue !== rawValue;
91
+ });
92
+ };
93
+
94
+ // Build export columns list - for "both" mode, add raw columns where formatted values exist
95
+ interface ExportColumn {
96
+ sourceCol: string;
97
+ headerName: string;
98
+ isRawColumn: boolean;
99
+ }
100
+
101
+ const exportColumns: ExportColumn[] = [];
102
+ for (const col of columns) {
103
+ // Determine header name based on useLogicalNames setting
104
+ const displayName = columnDisplayNames.get(col) || col;
105
+ const headerName = useLogicalNames ? col : displayName;
106
+
107
+ if (valueDisplayMode === "both" && hasFormattedValues(col)) {
108
+ // Add formatted column first, then raw column
109
+ exportColumns.push({ sourceCol: col, headerName, isRawColumn: false });
110
+ exportColumns.push({ sourceCol: col, headerName: `${headerName} (Raw)`, isRawColumn: true });
111
+ } else {
112
+ // Just one column
113
+ exportColumns.push({ sourceCol: col, headerName, isRawColumn: false });
114
+ }
115
+ }
116
+
117
+ // Create header row
118
+ const headerRow = worksheet.addRow(exportColumns.map((ec) => ec.headerName));
119
+ headerRow.font = { bold: true };
120
+ headerRow.fill = {
121
+ type: "pattern",
122
+ pattern: "solid",
123
+ fgColor: { argb: "FFE0E0E0" },
124
+ };
125
+
126
+ // Set column widths based on attribute type and header length
127
+ worksheet.columns = exportColumns.map((ec, index) => {
128
+ const attr = getAttrMeta(ec.sourceCol);
129
+ let width = Math.max(ec.headerName.length + 2, 10);
130
+
131
+ // Adjust width based on attribute type
132
+ switch (attr?.AttributeType) {
133
+ case "DateTime":
134
+ width = Math.max(width, 18);
135
+ break;
136
+ case "Money":
137
+ case "Decimal":
138
+ case "Double":
139
+ width = Math.max(width, 14);
140
+ break;
141
+ case "Lookup":
142
+ case "Customer":
143
+ case "Owner":
144
+ width = Math.max(width, 25);
145
+ break;
146
+ case "Memo":
147
+ width = Math.max(width, 40);
148
+ break;
149
+ }
150
+
151
+ return { key: `col_${index}`, width };
152
+ });
153
+
154
+ // Helper to get cell value based on display mode and column type
155
+ const getCellValue = (
156
+ record: Record<string, unknown>,
157
+ col: string,
158
+ isRawColumn: boolean,
159
+ attr: AttributeMetadata | undefined
160
+ ): unknown => {
161
+ const rawValue = record[col];
162
+ const formattedValue = getFormattedValue(record, col);
163
+
164
+ // Handle null/undefined
165
+ if (rawValue === null || rawValue === undefined) {
166
+ return "";
167
+ }
168
+
169
+ // For raw columns or raw display mode, return raw value with type conversion
170
+ if (isRawColumn || valueDisplayMode === "raw") {
171
+ switch (attr?.AttributeType) {
172
+ case "Integer":
173
+ case "BigInt":
174
+ return typeof rawValue === "number" ? rawValue : parseInt(String(rawValue), 10);
175
+ case "Decimal":
176
+ case "Double":
177
+ case "Money":
178
+ return typeof rawValue === "number" ? rawValue : parseFloat(String(rawValue));
179
+ case "DateTime":
180
+ if (rawValue instanceof Date) return rawValue;
181
+ if (typeof rawValue === "string") {
182
+ const date = new Date(rawValue);
183
+ return isNaN(date.getTime()) ? rawValue : date;
184
+ }
185
+ return rawValue;
186
+ case "Boolean":
187
+ return rawValue;
188
+ default:
189
+ return rawValue;
190
+ }
191
+ }
192
+
193
+ // For formatted or "both" mode (main column), return formatted values
194
+ switch (attr?.AttributeType) {
195
+ case "Boolean":
196
+ return formattedValue || (rawValue ? "Yes" : "No");
197
+
198
+ case "Integer":
199
+ case "BigInt":
200
+ // Numbers still get typed, but formatted value is used if different
201
+ return typeof rawValue === "number" ? rawValue : parseInt(String(rawValue), 10);
202
+
203
+ case "Decimal":
204
+ case "Double":
205
+ case "Money":
206
+ return typeof rawValue === "number" ? rawValue : parseFloat(String(rawValue));
207
+
208
+ case "DateTime":
209
+ // Return as Date object for Excel to format properly
210
+ if (rawValue instanceof Date) return rawValue;
211
+ if (typeof rawValue === "string") {
212
+ const date = new Date(rawValue);
213
+ return isNaN(date.getTime()) ? rawValue : date;
214
+ }
215
+ return rawValue;
216
+
217
+ case "Picklist":
218
+ case "State":
219
+ case "Status":
220
+ // Return formatted value (label) for choice fields
221
+ return formattedValue || rawValue;
222
+
223
+ case "Lookup":
224
+ case "Customer":
225
+ case "Owner":
226
+ // Return formatted value (name) for lookup fields
227
+ return formattedValue || rawValue;
228
+
229
+ default:
230
+ // For strings and unknown types
231
+ return formattedValue !== undefined && formattedValue !== rawValue
232
+ ? formattedValue
233
+ : String(rawValue);
234
+ }
235
+ };
236
+
237
+ // Add data rows with proper types
238
+ for (const record of records) {
239
+ const rowValues: unknown[] = exportColumns.map((ec) => {
240
+ const attr = getAttrMeta(ec.sourceCol);
241
+ return getCellValue(record, ec.sourceCol, ec.isRawColumn, attr);
242
+ });
243
+
244
+ const dataRow = worksheet.addRow(rowValues);
245
+
246
+ // Apply number format based on attribute type (only for non-raw columns in formatted mode)
247
+ exportColumns.forEach((ec, index) => {
248
+ const attr = getAttrMeta(ec.sourceCol);
249
+ const cell = dataRow.getCell(index + 1);
250
+
251
+ // Skip formatting for raw columns (show actual values)
252
+ if (ec.isRawColumn) return;
253
+
254
+ switch (attr?.AttributeType) {
255
+ case "DateTime":
256
+ cell.numFmt = "yyyy-mm-dd hh:mm:ss";
257
+ break;
258
+ case "Money":
259
+ // Use currency format
260
+ cell.numFmt = '"$"#,##0.00';
261
+ break;
262
+ case "Decimal":
263
+ case "Double": {
264
+ // Get precision from metadata or default to 2
265
+ const precision = (attr as { Precision?: number })?.Precision ?? 2;
266
+ cell.numFmt = `0.${"0".repeat(precision)}`;
267
+ break;
268
+ }
269
+ case "Integer":
270
+ case "BigInt":
271
+ cell.numFmt = "#,##0";
272
+ break;
273
+ }
274
+ });
275
+ }
276
+
277
+ // Freeze the header row
278
+ worksheet.views = [{ state: "frozen", ySplit: 1 }];
279
+
280
+ // Auto-filter
281
+ if (exportColumns.length > 0) {
282
+ worksheet.autoFilter = {
283
+ from: { row: 1, column: 1 },
284
+ to: { row: records.length + 1, column: exportColumns.length },
285
+ };
286
+ }
287
+
288
+ // Generate buffer
289
+ const buffer = await workbook.xlsx.writeBuffer();
290
+ // Include full timestamp (date + time) to avoid overwrite prompts on repeated exports
291
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
292
+ const finalFileName = `${fileName}_${timestamp}.xlsx`;
293
+
294
+ return {
295
+ buffer: buffer as ArrayBuffer,
296
+ fileName: finalFileName,
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Trigger browser download of the Excel file
302
+ */
303
+ export function downloadExcelFile(buffer: ArrayBuffer, fileName: string): void {
304
+ const blob = new Blob([buffer], {
305
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
306
+ });
307
+ const url = URL.createObjectURL(blob);
308
+
309
+ const link = document.createElement("a");
310
+ link.href = url;
311
+ link.download = fileName;
312
+ document.body.appendChild(link);
313
+ link.click();
314
+
315
+ // Cleanup
316
+ document.body.removeChild(link);
317
+ URL.revokeObjectURL(url);
318
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Utilities for working with Dataverse formatted values
3
+ * Formatted values come from Web API with @OData.Community.Display.V1.FormattedValue annotations
4
+ */
5
+
6
+ /**
7
+ * Get the formatted value for a property if available
8
+ * Falls back to raw value if no formatted value exists
9
+ * Handles both regular properties and lookup properties (with _propertyname_value pattern)
10
+ *
11
+ * @example
12
+ * // Record from API:
13
+ * // {
14
+ * // "statuscode": 1,
15
+ * // "statuscode@OData.Community.Display.V1.FormattedValue": "Active",
16
+ * // "createdon": "2024-01-15T10:30:00Z",
17
+ * // "createdon@OData.Community.Display.V1.FormattedValue": "1/15/2024 10:30 AM",
18
+ * // "_createdby_value": "guid-here",
19
+ * // "_createdby_value@OData.Community.Display.V1.FormattedValue": "John Doe"
20
+ * // }
21
+ *
22
+ * getFormattedValue(record, "statuscode") // Returns "Active"
23
+ * getFormattedValue(record, "createdon") // Returns "1/15/2024 10:30 AM"
24
+ * getFormattedValue(record, "_createdby_value") // Returns "John Doe"
25
+ * getFormattedValue(record, "name") // Returns raw name value
26
+ */
27
+ export function getFormattedValue(
28
+ record: Record<string, unknown>,
29
+ propertyName: string
30
+ ): unknown {
31
+ const formattedKey = `${propertyName}@OData.Community.Display.V1.FormattedValue`;
32
+
33
+ // Return formatted value if it exists, otherwise return raw value
34
+ if (formattedKey in record && record[formattedKey] !== null && record[formattedKey] !== undefined) {
35
+ return record[formattedKey];
36
+ }
37
+
38
+ return record[propertyName];
39
+ }
40
+
41
+ /**
42
+ * Check if a property has a formatted value annotation
43
+ */
44
+ export function hasFormattedValue(
45
+ record: Record<string, unknown>,
46
+ propertyName: string
47
+ ): boolean {
48
+ const formattedKey = `${propertyName}@OData.Community.Display.V1.FormattedValue`;
49
+ return formattedKey in record;
50
+ }
51
+
52
+ /**
53
+ * Get both raw and formatted values for a property
54
+ */
55
+ export function getValuePair(
56
+ record: Record<string, unknown>,
57
+ propertyName: string
58
+ ): {
59
+ raw: unknown;
60
+ formatted?: unknown;
61
+ } {
62
+ const raw = record[propertyName];
63
+ const formattedKey = `${propertyName}@OData.Community.Display.V1.FormattedValue`;
64
+ const formatted = record[formattedKey];
65
+
66
+ return {
67
+ raw,
68
+ formatted: formatted !== null && formatted !== undefined ? formatted : undefined,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Transform a record to use formatted values where available
74
+ * Creates a new object with formatted values replacing raw values
75
+ * Useful for displaying data in grids
76
+ */
77
+ export function transformRecordWithFormattedValues(
78
+ record: Record<string, unknown>
79
+ ): Record<string, unknown> {
80
+ const transformed: Record<string, unknown> = {};
81
+
82
+ // Get all property names (excluding OData annotations)
83
+ const propertyNames = Object.keys(record).filter(
84
+ (key) => !key.includes("@OData") && !key.includes("@Microsoft")
85
+ );
86
+
87
+ for (const prop of propertyNames) {
88
+ transformed[prop] = getFormattedValue(record, prop);
89
+ }
90
+
91
+ return transformed;
92
+ }
93
+
94
+ /**
95
+ * Check if the response includes formatted values
96
+ * Useful for detecting if the API was called with Prefer: odata.include-annotations="*"
97
+ */
98
+ export function responseHasFormattedValues(
99
+ records: Record<string, unknown>[]
100
+ ): boolean {
101
+ if (records.length === 0) return false;
102
+
103
+ const firstRecord = records[0];
104
+ const keys = Object.keys(firstRecord);
105
+
106
+ return keys.some((key) => key.includes("@OData.Community.Display.V1.FormattedValue"));
107
+ }