@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,18 @@
1
+ /**
2
+ * Display settings types and defaults
3
+ */
4
+
5
+ export type ValueDisplayMode = "formatted" | "raw" | "both";
6
+
7
+ export interface DisplaySettings {
8
+ /** Show logical names in column headers instead of display names */
9
+ useLogicalNames: boolean;
10
+ /** How to display cell values: formatted, raw, or both */
11
+ valueDisplayMode: ValueDisplayMode;
12
+ }
13
+
14
+ /** Default display settings */
15
+ export const defaultDisplaySettings: DisplaySettings = {
16
+ useLogicalNames: false,
17
+ valueDisplayMode: "formatted",
18
+ };
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Pure functions to generate FetchXML from tree structure
3
+ * Follows Dataverse FetchXML semantics
4
+ */
5
+
6
+ import type {
7
+ FetchNode,
8
+ EntityNode,
9
+ AttributeNode,
10
+ AllAttributesNode,
11
+ OrderNode,
12
+ FilterNode,
13
+ ConditionNode,
14
+ LinkEntityNode,
15
+ } from "./nodes";
16
+ import { operatorRequiresValue } from "./operators";
17
+
18
+ /**
19
+ * Convert a value to string for FetchXML, avoiding scientific notation for numbers
20
+ */
21
+ function formatValueForFetchXml(val: unknown): string {
22
+ if (typeof val === "number") {
23
+ // Avoid scientific notation (1e-12) by using toFixed with sufficient precision
24
+ // Then remove trailing zeros
25
+ if (Math.abs(val) < 1e-10 && val !== 0) {
26
+ // Very small numbers - use high precision
27
+ return val.toFixed(20).replace(/\.?0+$/, "");
28
+ } else if (Number.isInteger(val)) {
29
+ // Integer - no decimal point needed
30
+ return val.toString();
31
+ } else {
32
+ // Decimal - preserve precision but remove trailing zeros
33
+ return val.toFixed(10).replace(/\.?0+$/, "");
34
+ }
35
+ }
36
+ return String(val);
37
+ }
38
+
39
+ /**
40
+ * Generate complete FetchXML from FetchNode
41
+ */
42
+ export function generateFetchXml(fetchNode: FetchNode): string {
43
+ const lines: string[] = [];
44
+
45
+ // Build fetch element with options
46
+ const fetchAttrs: string[] = [];
47
+ if (fetchNode.options.aggregate) fetchAttrs.push('aggregate="true"');
48
+ if (fetchNode.options.distinct) fetchAttrs.push('distinct="true"');
49
+ if (fetchNode.options.top !== undefined) fetchAttrs.push(`top="${fetchNode.options.top}"`);
50
+ if (fetchNode.options.count !== undefined) fetchAttrs.push(`count="${fetchNode.options.count}"`);
51
+ if (fetchNode.options.page !== undefined) fetchAttrs.push(`page="${fetchNode.options.page}"`);
52
+ if (fetchNode.options.returnTotalRecordCount) fetchAttrs.push('returntotalrecordcount="true"');
53
+ if (fetchNode.options.noLock) fetchAttrs.push('no-lock="true"');
54
+ if (fetchNode.options.utcOffset !== undefined)
55
+ fetchAttrs.push(`utc-offset="${fetchNode.options.utcOffset}"`);
56
+ if (fetchNode.options.pagingCookie)
57
+ fetchAttrs.push(`paging-cookie="${escapeXml(fetchNode.options.pagingCookie)}"`);
58
+
59
+ const fetchAttrStr = fetchAttrs.length > 0 ? " " + fetchAttrs.join(" ") : "";
60
+ lines.push(`<fetch${fetchAttrStr}>`);
61
+
62
+ // Generate entity
63
+ lines.push(...generateEntity(fetchNode.entity, 1));
64
+
65
+ lines.push("</fetch>");
66
+
67
+ return lines.join("\n");
68
+ }
69
+
70
+ /**
71
+ * Generate entity element
72
+ */
73
+ function generateEntity(entity: EntityNode, indent: number): string[] {
74
+ const lines: string[] = [];
75
+ const spaces = " ".repeat(indent);
76
+
77
+ lines.push(`${spaces}<entity name="${entity.name}">`);
78
+
79
+ // All-attributes (single node, not array)
80
+ if (entity.allAttributes?.enabled) {
81
+ lines.push(...generateAllAttributes(entity.allAttributes, indent + 1));
82
+ }
83
+
84
+ // CRITICAL: Always include the primary ID attribute for row selection to work
85
+ // The primary ID is {entityname}id (e.g., accountid, contactid)
86
+ const primaryIdAttrName = `${entity.name}id`;
87
+ const hasPrimaryId =
88
+ entity.attributes.some((attr) => attr.name === primaryIdAttrName) ||
89
+ entity.allAttributes?.enabled;
90
+
91
+ if (!hasPrimaryId) {
92
+ // Inject primary ID attribute at the beginning
93
+ const primaryIdSpaces = " ".repeat(indent + 1);
94
+ lines.push(`${primaryIdSpaces}<attribute name="${primaryIdAttrName}" />`);
95
+ }
96
+
97
+ // Attributes
98
+ entity.attributes.forEach((attr) => {
99
+ lines.push(...generateAttribute(attr, indent + 1));
100
+ });
101
+
102
+ // Orders
103
+ entity.orders?.forEach((order) => {
104
+ lines.push(...generateOrder(order, indent + 1));
105
+ });
106
+
107
+ // Filters
108
+ entity.filters?.forEach((filter) => {
109
+ lines.push(...generateFilter(filter, indent + 1));
110
+ });
111
+
112
+ // Link-entities
113
+ entity.links?.forEach((link) => {
114
+ lines.push(...generateLinkEntity(link, indent + 1));
115
+ });
116
+
117
+ lines.push(`${spaces}</entity>`);
118
+
119
+ return lines;
120
+ }
121
+
122
+ /**
123
+ * Generate all-attributes element
124
+ */
125
+ function generateAllAttributes(_allAttr: AllAttributesNode, indent: number): string[] {
126
+ const spaces = " ".repeat(indent);
127
+ return [`${spaces}<all-attributes />`];
128
+ }
129
+
130
+ /**
131
+ * Generate attribute element
132
+ */
133
+ function generateAttribute(attr: AttributeNode, indent: number): string[] {
134
+ const spaces = " ".repeat(indent);
135
+ const attrs: string[] = [`name="${attr.name}"`];
136
+
137
+ if (attr.alias) attrs.push(`alias="${attr.alias}"`);
138
+ if (attr.aggregate) attrs.push(`aggregate="${attr.aggregate}"`);
139
+ if (attr.groupby) attrs.push('groupby="true"');
140
+ if (attr.dategrouping) attrs.push(`dategrouping="${attr.dategrouping}"`);
141
+ if (attr.usertimezone !== undefined) attrs.push(`usertimezone="${attr.usertimezone}"`);
142
+
143
+ return [`${spaces}<attribute ${attrs.join(" ")} />`];
144
+ }
145
+
146
+ /**
147
+ * Generate order element
148
+ */
149
+ function generateOrder(order: OrderNode, indent: number): string[] {
150
+ const spaces = " ".repeat(indent);
151
+ const attrs: string[] = [`attribute="${order.attribute}"`];
152
+
153
+ if (order.descending) attrs.push('descending="true"');
154
+ if (order.entityname) attrs.push(`entityname="${order.entityname}"`);
155
+
156
+ return [`${spaces}<order ${attrs.join(" ")} />`];
157
+ }
158
+
159
+ /**
160
+ * Generate filter element (recursive for subfilters)
161
+ */
162
+ function generateFilter(filter: FilterNode, indent: number): string[] {
163
+ const lines: string[] = [];
164
+ const spaces = " ".repeat(indent);
165
+
166
+ const attrs: string[] = [`type="${filter.conjunction}"`];
167
+ if (filter.hint) attrs.push(`hint="${filter.hint}"`);
168
+
169
+ lines.push(`${spaces}<filter ${attrs.join(" ")}>`);
170
+
171
+ // Conditions
172
+ filter.conditions?.forEach((condition) => {
173
+ lines.push(...generateCondition(condition, indent + 1));
174
+ });
175
+
176
+ // Subfilters (recursive)
177
+ filter.subfilters?.forEach((subfilter) => {
178
+ lines.push(...generateFilter(subfilter, indent + 1));
179
+ });
180
+
181
+ // Link-entities inside filter (for any/not any/all/not all)
182
+ filter.links?.forEach((link) => {
183
+ lines.push(...generateLinkEntity(link, indent + 1));
184
+ });
185
+
186
+ lines.push(`${spaces}</filter>`);
187
+
188
+ return lines;
189
+ }
190
+
191
+ /**
192
+ * Generate condition element
193
+ */
194
+ function generateCondition(condition: ConditionNode, indent: number): string[] {
195
+ const lines: string[] = [];
196
+ const spaces = " ".repeat(indent);
197
+
198
+ const attrs: string[] = [
199
+ `attribute="${condition.attribute}"`,
200
+ `operator="${condition.operator}"`,
201
+ ];
202
+
203
+ if (condition.entityname) attrs.push(`entityname="${condition.entityname}"`);
204
+ if (condition.aggregate) attrs.push(`aggregate="${condition.aggregate}"`);
205
+ // Note: ConditionNode doesn't have alias in our type definition
206
+ if (condition.valueof) attrs.push(`valueof="${condition.valueof}"`);
207
+
208
+ // Use the centralized operator logic to determine if value is needed
209
+ const needsValue = operatorRequiresValue(condition.operator);
210
+
211
+ if (!needsValue) {
212
+ // Self-closing tag for operators that don't need values (null, eq-userid, etc.)
213
+ return [`${spaces}<condition ${attrs.join(" ")} />`];
214
+ }
215
+
216
+ // Operators that need value attribute
217
+ if (condition.value !== undefined && condition.value !== null) {
218
+ // Handle array values (for 'in', 'not-in', 'contain-values', 'between', etc.)
219
+ if (Array.isArray(condition.value)) {
220
+ lines.push(`${spaces}<condition ${attrs.join(" ")}>`);
221
+ condition.value.forEach((val) => {
222
+ if (val !== undefined && val !== null) {
223
+ lines.push(`${spaces} <value>${escapeXml(formatValueForFetchXml(val))}</value>`);
224
+ }
225
+ });
226
+ lines.push(`${spaces}</condition>`);
227
+ } else {
228
+ // Single value attribute
229
+ attrs.push(`value="${escapeXml(formatValueForFetchXml(condition.value))}"`);
230
+ lines.push(`${spaces}<condition ${attrs.join(" ")} />`);
231
+ }
232
+ } else {
233
+ // No value provided but operator needs one - still generate with empty value
234
+ lines.push(`${spaces}<condition ${attrs.join(" ")} />`);
235
+ }
236
+
237
+ return lines;
238
+ }
239
+
240
+ /**
241
+ * Generate link-entity element (recursive)
242
+ */
243
+ function generateLinkEntity(link: LinkEntityNode, indent: number): string[] {
244
+ const lines: string[] = [];
245
+ const spaces = " ".repeat(indent);
246
+
247
+ const attrs: string[] = [`name="${link.name}"`, `from="${link.from}"`, `to="${link.to}"`];
248
+
249
+ if (link.alias) attrs.push(`alias="${link.alias}"`);
250
+ if (link.linkType !== "inner") attrs.push(`link-type="${link.linkType}"`);
251
+ if (link.intersect) attrs.push('intersect="true"');
252
+ if (link.visible !== undefined) attrs.push(`visible="${link.visible}"`);
253
+
254
+ lines.push(`${spaces}<link-entity ${attrs.join(" ")}>`);
255
+
256
+ // All-attributes (single node, not array)
257
+ if (link.allAttributes?.enabled) {
258
+ lines.push(...generateAllAttributes(link.allAttributes, indent + 1));
259
+ }
260
+
261
+ // Attributes
262
+ link.attributes?.forEach((attr) => {
263
+ lines.push(...generateAttribute(attr, indent + 1));
264
+ });
265
+
266
+ // Orders
267
+ link.orders?.forEach((order) => {
268
+ lines.push(...generateOrder(order, indent + 1));
269
+ });
270
+
271
+ // Filters
272
+ link.filters?.forEach((filter) => {
273
+ lines.push(...generateFilter(filter, indent + 1));
274
+ });
275
+
276
+ // Nested link-entities (recursive)
277
+ link.links?.forEach((nestedLink) => {
278
+ lines.push(...generateLinkEntity(nestedLink, indent + 1));
279
+ });
280
+
281
+ lines.push(`${spaces}</link-entity>`);
282
+
283
+ return lines;
284
+ }
285
+
286
+ /**
287
+ * Escape XML special characters
288
+ */
289
+ function escapeXml(str: string): string {
290
+ return str
291
+ .replace(/&/g, "&amp;")
292
+ .replace(/</g, "&lt;")
293
+ .replace(/>/g, "&gt;")
294
+ .replace(/"/g, "&quot;")
295
+ .replace(/'/g, "&apos;");
296
+ }
297
+
298
+ /**
299
+ * Modify FetchXML string to add/update paging parameters
300
+ * Used for fetching subsequent pages during Retrieve All or infinite scroll
301
+ *
302
+ * Per Dataverse requirements:
303
+ * - page: Page number (1-based)
304
+ * - paging-cookie: XML cookie from previous response, must be XML-entity encoded
305
+ * - count: Page size (records per page)
306
+ */
307
+ export function addPagingToFetchXml(
308
+ fetchXml: string,
309
+ page: number,
310
+ pagingCookie?: string,
311
+ count?: number
312
+ ): string {
313
+ // Parse the fetch element attributes
314
+ const fetchMatch = fetchXml.match(/<fetch([^>]*)>/);
315
+ if (!fetchMatch) return fetchXml;
316
+
317
+ let fetchAttrs = fetchMatch[1];
318
+
319
+ // Remove existing page, paging-cookie, count attributes
320
+ // Note: paging-cookie can contain encoded quotes, so we need a more robust pattern
321
+ fetchAttrs = fetchAttrs
322
+ .replace(/\s*page="[^"]*"/g, "")
323
+ .replace(/\s*paging-cookie="[^"]*"/g, "") // This works because the value is already XML-encoded (no raw quotes)
324
+ .replace(/\s*count="[^"]*"/g, "");
325
+
326
+ // Add new paging attributes
327
+ fetchAttrs = fetchAttrs.trim();
328
+
329
+ // Always add page number (even page 1 for explicit paging requests)
330
+ fetchAttrs += ` page="${page}"`;
331
+
332
+ // Add paging cookie if provided (required for reliable paging after page 1)
333
+ if (pagingCookie) {
334
+ // The paging cookie needs to be XML-escaped when inserted as an attribute value
335
+ // Dataverse returns it as raw XML, we need to encode it for the attribute
336
+ fetchAttrs += ` paging-cookie="${escapeXml(pagingCookie)}"`;
337
+ }
338
+
339
+ // Add count (page size) if specified
340
+ if (count !== undefined) {
341
+ fetchAttrs += ` count="${count}"`;
342
+ }
343
+
344
+ // Reconstruct the fetch element
345
+ return fetchXml.replace(/<fetch[^>]*>/, `<fetch${fetchAttrs ? " " + fetchAttrs : ""}>`);
346
+ }