@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.
- package/CHANGELOG.md +120 -0
- package/README.md +250 -0
- package/eslint.config.js +23 -0
- package/index.html +71 -0
- package/package.json +53 -0
- package/public/vite.svg +1 -0
- package/src/app/AppShell.tsx +1733 -0
- package/src/assets/20px-Microsoft_Office_Excel_(2025/342/200/223present).svg.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/features/fetchxml/api/dataverseMetadata.ts +192 -0
- package/src/features/fetchxml/api/excelExport.ts +318 -0
- package/src/features/fetchxml/api/formattedValues.ts +107 -0
- package/src/features/fetchxml/api/pptbClient.ts +2635 -0
- package/src/features/fetchxml/model/displaySettings.ts +18 -0
- package/src/features/fetchxml/model/fetchxml.ts +346 -0
- package/src/features/fetchxml/model/fetchxmlIntellisense.ts +583 -0
- package/src/features/fetchxml/model/fetchxmlParser.ts +708 -0
- package/src/features/fetchxml/model/layoutxml.ts +372 -0
- package/src/features/fetchxml/model/nodes.ts +255 -0
- package/src/features/fetchxml/model/operators.ts +624 -0
- package/src/features/fetchxml/model/treeUtils.ts +174 -0
- package/src/features/fetchxml/state/builderStore.tsx +1160 -0
- package/src/features/fetchxml/state/cache.ts +393 -0
- package/src/features/fetchxml/ui/Dialogs/BulkDeleteDialog.tsx +352 -0
- package/src/features/fetchxml/ui/Dialogs/DeleteConfirmDialog.tsx +396 -0
- package/src/features/fetchxml/ui/Dialogs/SaveViewDialog.tsx +612 -0
- package/src/features/fetchxml/ui/Dialogs/SolutionPicker.tsx +114 -0
- package/src/features/fetchxml/ui/Dialogs/WorkflowPickerDialog.tsx +437 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/AttributeEditor.tsx +214 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ConditionEditor.tsx +533 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FetchOptionsEditor.tsx +296 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/FilterEditor.tsx +106 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/LinkEntityEditor.tsx +433 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/OrderEditor.tsx +161 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel/editors/ValueOfPicker.tsx +194 -0
- package/src/features/fetchxml/ui/LeftPane/PropertiesPanel.tsx +225 -0
- package/src/features/fetchxml/ui/LeftPane/TreeView.tsx +604 -0
- package/src/features/fetchxml/ui/RightPane/AddColumnsPanel.tsx +800 -0
- package/src/features/fetchxml/ui/RightPane/ColumnConfigDialog.tsx +170 -0
- package/src/features/fetchxml/ui/RightPane/DataGridCellRenderers.tsx +356 -0
- package/src/features/fetchxml/ui/RightPane/EditColumnsPanel.tsx +372 -0
- package/src/features/fetchxml/ui/RightPane/FetchXmlEditor.tsx +399 -0
- package/src/features/fetchxml/ui/RightPane/FormattedValueUtils.ts +189 -0
- package/src/features/fetchxml/ui/RightPane/LayoutXmlViewer.tsx +171 -0
- package/src/features/fetchxml/ui/RightPane/PreviewTabs.tsx +471 -0
- package/src/features/fetchxml/ui/RightPane/ResultsCommandBar.tsx +430 -0
- package/src/features/fetchxml/ui/RightPane/ResultsGrid.tsx +983 -0
- package/src/features/fetchxml/ui/RightPane/XmlTextArea.tsx +65 -0
- package/src/features/fetchxml/ui/Settings/SettingsDrawer.tsx +175 -0
- package/src/features/fetchxml/ui/Toolbar/EntitySelector.tsx +788 -0
- package/src/features/fetchxml/ui/Toolbar/LoadViewPicker.tsx +274 -0
- package/src/features/fetchxml/ui/Toolbar/SaveViewButton.tsx +174 -0
- package/src/main.tsx +11 -0
- package/src/shared/components/AttributePicker.tsx +138 -0
- package/src/shared/components/BetweenInputPicker.tsx +120 -0
- package/src/shared/components/BooleanValuePicker.tsx +165 -0
- package/src/shared/components/DateTimeInputPicker.tsx +170 -0
- package/src/shared/components/DynamicValueList.tsx +128 -0
- package/src/shared/components/ExcelIcon.tsx +30 -0
- package/src/shared/components/MultiValuePicker.tsx +178 -0
- package/src/shared/components/NumericInputPicker.tsx +156 -0
- package/src/shared/components/OptionSetValuePicker.tsx +150 -0
- package/src/shared/components/RelationshipPicker.tsx +268 -0
- package/src/shared/contexts/ThemeContext.tsx +19 -0
- package/src/shared/hooks/useAccessMode.ts +77 -0
- package/src/shared/hooks/useLazyMetadata.ts +132 -0
- package/src/shared/hooks/usePptbContext.ts +170 -0
- package/src/shared/hooks/usePublisherFilter.ts +214 -0
- package/src/shared/hooks/useSolutionFilter.ts +162 -0
- package/src/shared/utils/debug.ts +87 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +7 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Add Columns Panel (Drawer)
|
|
3
|
+
* Shows available attributes with support for related entities (lookups)
|
|
4
|
+
* Matches Power Apps Model-Driven Apps UX
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useMemo, useCallback, useEffect } from "react";
|
|
8
|
+
import {
|
|
9
|
+
DrawerBody,
|
|
10
|
+
DrawerHeader,
|
|
11
|
+
DrawerHeaderTitle,
|
|
12
|
+
OverlayDrawer,
|
|
13
|
+
Button,
|
|
14
|
+
makeStyles,
|
|
15
|
+
tokens,
|
|
16
|
+
Text,
|
|
17
|
+
Input,
|
|
18
|
+
Checkbox,
|
|
19
|
+
Tree,
|
|
20
|
+
TreeItem,
|
|
21
|
+
TreeItemLayout,
|
|
22
|
+
Spinner,
|
|
23
|
+
TabList,
|
|
24
|
+
Tab,
|
|
25
|
+
Combobox,
|
|
26
|
+
Option,
|
|
27
|
+
Badge,
|
|
28
|
+
Accordion,
|
|
29
|
+
AccordionItem,
|
|
30
|
+
AccordionHeader,
|
|
31
|
+
AccordionPanel,
|
|
32
|
+
type SelectTabData,
|
|
33
|
+
type SelectTabEvent,
|
|
34
|
+
} from "@fluentui/react-components";
|
|
35
|
+
import { Dismiss24Regular, Search20Regular } from "@fluentui/react-icons";
|
|
36
|
+
import type { AttributeMetadata, RelationshipMetadata } from "../../api/pptbClient";
|
|
37
|
+
|
|
38
|
+
const useStyles = makeStyles({
|
|
39
|
+
drawer: {
|
|
40
|
+
width: "420px",
|
|
41
|
+
},
|
|
42
|
+
searchContainer: {
|
|
43
|
+
display: "flex",
|
|
44
|
+
gap: tokens.spacingHorizontalS,
|
|
45
|
+
marginBottom: tokens.spacingVerticalM,
|
|
46
|
+
},
|
|
47
|
+
searchInput: {
|
|
48
|
+
flex: 1,
|
|
49
|
+
},
|
|
50
|
+
filterCombo: {
|
|
51
|
+
minWidth: "120px",
|
|
52
|
+
},
|
|
53
|
+
tabContent: {
|
|
54
|
+
marginTop: tokens.spacingVerticalM,
|
|
55
|
+
},
|
|
56
|
+
attributeList: {
|
|
57
|
+
display: "flex",
|
|
58
|
+
flexDirection: "column",
|
|
59
|
+
gap: tokens.spacingVerticalXXS,
|
|
60
|
+
maxHeight: "calc(100vh - 300px)",
|
|
61
|
+
overflowY: "auto",
|
|
62
|
+
},
|
|
63
|
+
attributeItem: {
|
|
64
|
+
display: "flex",
|
|
65
|
+
alignItems: "center",
|
|
66
|
+
gap: tokens.spacingHorizontalS,
|
|
67
|
+
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
|
|
68
|
+
borderRadius: tokens.borderRadiusSmall,
|
|
69
|
+
cursor: "pointer",
|
|
70
|
+
"&:hover": {
|
|
71
|
+
backgroundColor: tokens.colorNeutralBackground1Hover,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
attributeItemSelected: {
|
|
75
|
+
backgroundColor: tokens.colorNeutralBackground1Selected,
|
|
76
|
+
},
|
|
77
|
+
relationshipTree: {
|
|
78
|
+
maxHeight: "calc(100vh - 300px)",
|
|
79
|
+
overflowY: "auto",
|
|
80
|
+
},
|
|
81
|
+
relationshipItem: {
|
|
82
|
+
display: "flex",
|
|
83
|
+
alignItems: "center",
|
|
84
|
+
gap: tokens.spacingHorizontalS,
|
|
85
|
+
},
|
|
86
|
+
relationshipLabel: {
|
|
87
|
+
display: "flex",
|
|
88
|
+
flexDirection: "column",
|
|
89
|
+
},
|
|
90
|
+
relationshipName: {
|
|
91
|
+
fontWeight: tokens.fontWeightSemibold,
|
|
92
|
+
},
|
|
93
|
+
relationshipEntity: {
|
|
94
|
+
fontSize: tokens.fontSizeBase200,
|
|
95
|
+
color: tokens.colorNeutralForeground3,
|
|
96
|
+
},
|
|
97
|
+
relationshipType: {
|
|
98
|
+
marginLeft: tokens.spacingHorizontalS,
|
|
99
|
+
},
|
|
100
|
+
relationshipHeader: {
|
|
101
|
+
display: "flex",
|
|
102
|
+
alignItems: "center",
|
|
103
|
+
justifyContent: "space-between",
|
|
104
|
+
width: "100%",
|
|
105
|
+
},
|
|
106
|
+
accordionHeader: {
|
|
107
|
+
display: "flex",
|
|
108
|
+
alignItems: "center",
|
|
109
|
+
gap: tokens.spacingHorizontalS,
|
|
110
|
+
},
|
|
111
|
+
warningBadge: {
|
|
112
|
+
marginLeft: tokens.spacingHorizontalS,
|
|
113
|
+
},
|
|
114
|
+
loadingContainer: {
|
|
115
|
+
display: "flex",
|
|
116
|
+
alignItems: "center",
|
|
117
|
+
justifyContent: "center",
|
|
118
|
+
padding: tokens.spacingVerticalXXL,
|
|
119
|
+
},
|
|
120
|
+
footer: {
|
|
121
|
+
display: "flex",
|
|
122
|
+
justifyContent: "flex-end",
|
|
123
|
+
gap: tokens.spacingHorizontalS,
|
|
124
|
+
padding: tokens.spacingVerticalM,
|
|
125
|
+
borderTop: `1px solid ${tokens.colorNeutralStroke1}`,
|
|
126
|
+
marginTop: "auto",
|
|
127
|
+
},
|
|
128
|
+
emptyState: {
|
|
129
|
+
display: "flex",
|
|
130
|
+
flexDirection: "column",
|
|
131
|
+
alignItems: "center",
|
|
132
|
+
justifyContent: "center",
|
|
133
|
+
padding: tokens.spacingVerticalXXL,
|
|
134
|
+
color: tokens.colorNeutralForeground3,
|
|
135
|
+
textAlign: "center",
|
|
136
|
+
},
|
|
137
|
+
selectedCount: {
|
|
138
|
+
marginRight: "auto",
|
|
139
|
+
color: tokens.colorNeutralForeground2,
|
|
140
|
+
display: "flex",
|
|
141
|
+
alignItems: "center",
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
type FilterType = "all" | "standard" | "custom";
|
|
146
|
+
|
|
147
|
+
export interface RelatedEntityColumn {
|
|
148
|
+
/** The relationship used to access this entity */
|
|
149
|
+
relationship: RelationshipMetadata;
|
|
150
|
+
/** The attribute on the related entity */
|
|
151
|
+
attribute: AttributeMetadata;
|
|
152
|
+
/** Display name including relationship context, e.g. "First Name (Primary Contact)" */
|
|
153
|
+
displayName: string;
|
|
154
|
+
/** The lookup attribute name on the root entity (for N:1) */
|
|
155
|
+
lookupAttribute: string;
|
|
156
|
+
/** The relationship type: N1 (lookup) or 1N (one-to-many) */
|
|
157
|
+
relationshipType: "N1" | "1N";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface AddColumnSelection {
|
|
161
|
+
/** Root entity attributes to add */
|
|
162
|
+
rootAttributes: string[];
|
|
163
|
+
/** Related entity columns to add (will create link-entity if needed) */
|
|
164
|
+
relatedColumns: RelatedEntityColumn[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface AddColumnsPanelProps {
|
|
168
|
+
/** Whether the panel is open */
|
|
169
|
+
open: boolean;
|
|
170
|
+
/** Entity display name */
|
|
171
|
+
entityDisplayName?: string;
|
|
172
|
+
/** Entity logical name */
|
|
173
|
+
entityLogicalName?: string;
|
|
174
|
+
/** Available attributes for the root entity */
|
|
175
|
+
availableAttributes?: AttributeMetadata[];
|
|
176
|
+
/** Currently selected attribute names (already in the query) */
|
|
177
|
+
selectedAttributes?: string[];
|
|
178
|
+
/** Lookup relationships (many-to-one) for related entity columns */
|
|
179
|
+
lookupRelationships?: RelationshipMetadata[];
|
|
180
|
+
/** One-to-many relationships for 1-N related entity columns */
|
|
181
|
+
oneToManyRelationships?: RelationshipMetadata[];
|
|
182
|
+
/** Whether relationship data is loading */
|
|
183
|
+
isLoadingRelationships?: boolean;
|
|
184
|
+
/** Callback to load attributes for a related entity */
|
|
185
|
+
onLoadRelatedAttributes?: (entityLogicalName: string) => Promise<AttributeMetadata[]>;
|
|
186
|
+
/** Called when panel is closed */
|
|
187
|
+
onClose: () => void;
|
|
188
|
+
/** Called when columns are selected and Apply is clicked */
|
|
189
|
+
onApply: (selection: AddColumnSelection) => void;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function AddColumnsPanel({
|
|
193
|
+
open,
|
|
194
|
+
entityDisplayName,
|
|
195
|
+
entityLogicalName,
|
|
196
|
+
availableAttributes = [],
|
|
197
|
+
selectedAttributes = [],
|
|
198
|
+
lookupRelationships = [],
|
|
199
|
+
oneToManyRelationships = [],
|
|
200
|
+
isLoadingRelationships,
|
|
201
|
+
onLoadRelatedAttributes,
|
|
202
|
+
onClose,
|
|
203
|
+
onApply,
|
|
204
|
+
}: AddColumnsPanelProps) {
|
|
205
|
+
const styles = useStyles();
|
|
206
|
+
const [activeTab, setActiveTab] = useState<"entity" | "related">("entity");
|
|
207
|
+
const [searchText, setSearchText] = useState("");
|
|
208
|
+
const [filterType, setFilterType] = useState<FilterType>("all");
|
|
209
|
+
const [selectedRootAttrs, setSelectedRootAttrs] = useState<Set<string>>(new Set());
|
|
210
|
+
const [selectedRelatedCols, setSelectedRelatedCols] = useState<RelatedEntityColumn[]>([]);
|
|
211
|
+
const [expandedRelationships, setExpandedRelationships] = useState<Set<string>>(new Set());
|
|
212
|
+
const [relatedAttributesCache, setRelatedAttributesCache] = useState<
|
|
213
|
+
Map<string, AttributeMetadata[]>
|
|
214
|
+
>(new Map());
|
|
215
|
+
const [loadingEntities, setLoadingEntities] = useState<Set<string>>(new Set());
|
|
216
|
+
|
|
217
|
+
// Reset state when panel opens
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (open) {
|
|
220
|
+
setSelectedRootAttrs(new Set());
|
|
221
|
+
setSelectedRelatedCols([]);
|
|
222
|
+
setSearchText("");
|
|
223
|
+
setFilterType("all");
|
|
224
|
+
setActiveTab("entity");
|
|
225
|
+
}
|
|
226
|
+
}, [open]);
|
|
227
|
+
|
|
228
|
+
// Filter attributes based on search and filter type
|
|
229
|
+
const filteredAttributes = useMemo(() => {
|
|
230
|
+
let attrs = availableAttributes.filter(
|
|
231
|
+
(attr) => !selectedAttributes.includes(attr.LogicalName)
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// Apply filter type
|
|
235
|
+
if (filterType === "standard") {
|
|
236
|
+
attrs = attrs.filter(
|
|
237
|
+
(attr) => !attr.LogicalName.startsWith("new_") && !attr.SchemaName.includes("_")
|
|
238
|
+
);
|
|
239
|
+
} else if (filterType === "custom") {
|
|
240
|
+
attrs = attrs.filter(
|
|
241
|
+
(attr) => attr.LogicalName.startsWith("new_") || attr.SchemaName.includes("_")
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Apply search
|
|
246
|
+
if (searchText) {
|
|
247
|
+
const search = searchText.toLowerCase();
|
|
248
|
+
attrs = attrs.filter(
|
|
249
|
+
(attr) =>
|
|
250
|
+
attr.LogicalName.toLowerCase().includes(search) ||
|
|
251
|
+
(attr.DisplayName?.UserLocalizedLabel?.Label || "").toLowerCase().includes(search)
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Sort by display name
|
|
256
|
+
return attrs.sort((a, b) => {
|
|
257
|
+
const aName = a.DisplayName?.UserLocalizedLabel?.Label || a.LogicalName;
|
|
258
|
+
const bName = b.DisplayName?.UserLocalizedLabel?.Label || b.LogicalName;
|
|
259
|
+
return aName.localeCompare(bName);
|
|
260
|
+
});
|
|
261
|
+
}, [availableAttributes, selectedAttributes, searchText, filterType]);
|
|
262
|
+
|
|
263
|
+
// Combined relationship type for display
|
|
264
|
+
type RelationshipWithType = {
|
|
265
|
+
relationship: RelationshipMetadata;
|
|
266
|
+
type: "N1" | "1N";
|
|
267
|
+
displayName: string;
|
|
268
|
+
relatedEntity: string;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Filter and combine N:1 (lookup) and 1:N relationships
|
|
272
|
+
const { filteredN1Relationships, filtered1NRelationships } = useMemo(() => {
|
|
273
|
+
const search = searchText.toLowerCase();
|
|
274
|
+
|
|
275
|
+
// Process N:1 (lookup/many-to-one) relationships
|
|
276
|
+
let n1Rels: RelationshipWithType[] = lookupRelationships.map((rel) => {
|
|
277
|
+
const lookupAttr = availableAttributes.find(
|
|
278
|
+
(a) => a.LogicalName === rel.ReferencingAttribute
|
|
279
|
+
);
|
|
280
|
+
const displayName =
|
|
281
|
+
lookupAttr?.DisplayName?.UserLocalizedLabel?.Label || rel.ReferencingAttribute;
|
|
282
|
+
return {
|
|
283
|
+
relationship: rel,
|
|
284
|
+
type: "N1" as const,
|
|
285
|
+
displayName,
|
|
286
|
+
relatedEntity: rel.ReferencedEntity,
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Process 1:N (one-to-many) relationships
|
|
291
|
+
let oneNRels: RelationshipWithType[] = oneToManyRelationships.map((rel) => {
|
|
292
|
+
// For 1:N, ReferencingEntity is the related entity (the "many" side)
|
|
293
|
+
return {
|
|
294
|
+
relationship: rel,
|
|
295
|
+
type: "1N" as const,
|
|
296
|
+
displayName: rel.ReferencingEntity, // Show the related entity name
|
|
297
|
+
relatedEntity: rel.ReferencingEntity,
|
|
298
|
+
};
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Apply search filter
|
|
302
|
+
if (searchText) {
|
|
303
|
+
n1Rels = n1Rels.filter(
|
|
304
|
+
(r) =>
|
|
305
|
+
r.relatedEntity.toLowerCase().includes(search) ||
|
|
306
|
+
r.displayName.toLowerCase().includes(search) ||
|
|
307
|
+
r.relationship.SchemaName.toLowerCase().includes(search)
|
|
308
|
+
);
|
|
309
|
+
oneNRels = oneNRels.filter(
|
|
310
|
+
(r) =>
|
|
311
|
+
r.relatedEntity.toLowerCase().includes(search) ||
|
|
312
|
+
r.displayName.toLowerCase().includes(search) ||
|
|
313
|
+
r.relationship.SchemaName.toLowerCase().includes(search)
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Sort by display name
|
|
318
|
+
n1Rels.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
319
|
+
oneNRels.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
filteredN1Relationships: n1Rels,
|
|
323
|
+
filtered1NRelationships: oneNRels,
|
|
324
|
+
};
|
|
325
|
+
}, [lookupRelationships, oneToManyRelationships, availableAttributes, searchText]);
|
|
326
|
+
|
|
327
|
+
const handleTabSelect = useCallback((_e: SelectTabEvent, data: SelectTabData) => {
|
|
328
|
+
setActiveTab(data.value as "entity" | "related");
|
|
329
|
+
}, []);
|
|
330
|
+
|
|
331
|
+
const handleRootAttributeToggle = useCallback((logicalName: string) => {
|
|
332
|
+
setSelectedRootAttrs((prev) => {
|
|
333
|
+
const next = new Set(prev);
|
|
334
|
+
if (next.has(logicalName)) {
|
|
335
|
+
next.delete(logicalName);
|
|
336
|
+
} else {
|
|
337
|
+
next.add(logicalName);
|
|
338
|
+
}
|
|
339
|
+
return next;
|
|
340
|
+
});
|
|
341
|
+
}, []);
|
|
342
|
+
|
|
343
|
+
const handleExpandRelationship = useCallback(
|
|
344
|
+
async (relationship: RelationshipMetadata, relatedEntity: string) => {
|
|
345
|
+
const relKey = relationship.SchemaName;
|
|
346
|
+
|
|
347
|
+
setExpandedRelationships((prev) => {
|
|
348
|
+
const next = new Set(prev);
|
|
349
|
+
if (next.has(relKey)) {
|
|
350
|
+
next.delete(relKey);
|
|
351
|
+
} else {
|
|
352
|
+
next.add(relKey);
|
|
353
|
+
}
|
|
354
|
+
return next;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Load related entity attributes if not already loaded
|
|
358
|
+
if (!relatedAttributesCache.has(relatedEntity) && onLoadRelatedAttributes) {
|
|
359
|
+
setLoadingEntities((prev) => new Set(prev).add(relatedEntity));
|
|
360
|
+
try {
|
|
361
|
+
const attrs = await onLoadRelatedAttributes(relatedEntity);
|
|
362
|
+
setRelatedAttributesCache((prev) => new Map(prev).set(relatedEntity, attrs));
|
|
363
|
+
} finally {
|
|
364
|
+
setLoadingEntities((prev) => {
|
|
365
|
+
const next = new Set(prev);
|
|
366
|
+
next.delete(relatedEntity);
|
|
367
|
+
return next;
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
[relatedAttributesCache, onLoadRelatedAttributes]
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const handleRelatedAttributeToggle = useCallback(
|
|
376
|
+
(
|
|
377
|
+
relationship: RelationshipMetadata,
|
|
378
|
+
attribute: AttributeMetadata,
|
|
379
|
+
relType: "N1" | "1N",
|
|
380
|
+
relatedEntity: string
|
|
381
|
+
) => {
|
|
382
|
+
const attrDisplayName =
|
|
383
|
+
attribute.DisplayName?.UserLocalizedLabel?.Label || attribute.LogicalName;
|
|
384
|
+
|
|
385
|
+
let lookupAttr: string;
|
|
386
|
+
let displaySuffix: string;
|
|
387
|
+
|
|
388
|
+
if (relType === "N1") {
|
|
389
|
+
// For N:1, the lookup attribute is on the root entity
|
|
390
|
+
lookupAttr = relationship.ReferencingAttribute;
|
|
391
|
+
const lookupDisplayName =
|
|
392
|
+
availableAttributes.find((a) => a.LogicalName === lookupAttr)?.DisplayName
|
|
393
|
+
?.UserLocalizedLabel?.Label || lookupAttr;
|
|
394
|
+
displaySuffix = lookupDisplayName;
|
|
395
|
+
} else {
|
|
396
|
+
// For 1:N, there's no lookup on root - use the related entity name
|
|
397
|
+
lookupAttr = relationship.ReferencingAttribute; // FK on the related entity
|
|
398
|
+
displaySuffix = relatedEntity;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const relatedCol: RelatedEntityColumn = {
|
|
402
|
+
relationship,
|
|
403
|
+
attribute,
|
|
404
|
+
displayName: `${attrDisplayName} (${displaySuffix})`,
|
|
405
|
+
lookupAttribute: lookupAttr,
|
|
406
|
+
relationshipType: relType,
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
setSelectedRelatedCols((prev) => {
|
|
410
|
+
// Check if already selected
|
|
411
|
+
const existingIndex = prev.findIndex(
|
|
412
|
+
(col) =>
|
|
413
|
+
col.relationship.SchemaName === relationship.SchemaName &&
|
|
414
|
+
col.attribute.LogicalName === attribute.LogicalName
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
if (existingIndex >= 0) {
|
|
418
|
+
// Remove it
|
|
419
|
+
return prev.filter((_, i) => i !== existingIndex);
|
|
420
|
+
} else {
|
|
421
|
+
// Add it
|
|
422
|
+
return [...prev, relatedCol];
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
},
|
|
426
|
+
[availableAttributes]
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const isRelatedAttributeSelected = useCallback(
|
|
430
|
+
(relationship: RelationshipMetadata, attribute: AttributeMetadata) => {
|
|
431
|
+
return selectedRelatedCols.some(
|
|
432
|
+
(col) =>
|
|
433
|
+
col.relationship.SchemaName === relationship.SchemaName &&
|
|
434
|
+
col.attribute.LogicalName === attribute.LogicalName
|
|
435
|
+
);
|
|
436
|
+
},
|
|
437
|
+
[selectedRelatedCols]
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
const handleApply = useCallback(() => {
|
|
441
|
+
onApply({
|
|
442
|
+
rootAttributes: Array.from(selectedRootAttrs),
|
|
443
|
+
relatedColumns: selectedRelatedCols,
|
|
444
|
+
});
|
|
445
|
+
}, [selectedRootAttrs, selectedRelatedCols, onApply]);
|
|
446
|
+
|
|
447
|
+
const totalSelected = selectedRootAttrs.size + selectedRelatedCols.length;
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<OverlayDrawer
|
|
451
|
+
open={open}
|
|
452
|
+
onOpenChange={(_e, data) => !data.open && onClose()}
|
|
453
|
+
position="end"
|
|
454
|
+
size="medium"
|
|
455
|
+
className={styles.drawer}
|
|
456
|
+
>
|
|
457
|
+
<DrawerHeader>
|
|
458
|
+
<DrawerHeaderTitle
|
|
459
|
+
action={
|
|
460
|
+
<Button
|
|
461
|
+
appearance="subtle"
|
|
462
|
+
aria-label="Close"
|
|
463
|
+
icon={<Dismiss24Regular />}
|
|
464
|
+
onClick={onClose}
|
|
465
|
+
/>
|
|
466
|
+
}
|
|
467
|
+
>
|
|
468
|
+
Add columns
|
|
469
|
+
</DrawerHeaderTitle>
|
|
470
|
+
</DrawerHeader>
|
|
471
|
+
<DrawerBody>
|
|
472
|
+
<TabList selectedValue={activeTab} onTabSelect={handleTabSelect}>
|
|
473
|
+
<Tab value="entity">{entityDisplayName || entityLogicalName || "Entity"}</Tab>
|
|
474
|
+
<Tab value="related">Related</Tab>
|
|
475
|
+
</TabList>
|
|
476
|
+
|
|
477
|
+
<div className={styles.tabContent}>
|
|
478
|
+
<div className={styles.searchContainer}>
|
|
479
|
+
<Input
|
|
480
|
+
className={styles.searchInput}
|
|
481
|
+
contentBefore={<Search20Regular />}
|
|
482
|
+
placeholder="Search columns..."
|
|
483
|
+
value={searchText}
|
|
484
|
+
onChange={(_e, data) => setSearchText(data.value)}
|
|
485
|
+
/>
|
|
486
|
+
<Combobox
|
|
487
|
+
className={styles.filterCombo}
|
|
488
|
+
value={
|
|
489
|
+
filterType === "all" ? "All" : filterType === "standard" ? "Standard" : "Custom"
|
|
490
|
+
}
|
|
491
|
+
onOptionSelect={(_e, data) =>
|
|
492
|
+
setFilterType((data.optionValue as FilterType) || "all")
|
|
493
|
+
}
|
|
494
|
+
>
|
|
495
|
+
<Option value="all">All</Option>
|
|
496
|
+
<Option value="standard">Standard</Option>
|
|
497
|
+
<Option value="custom">Custom</Option>
|
|
498
|
+
</Combobox>
|
|
499
|
+
</div>
|
|
500
|
+
|
|
501
|
+
{activeTab === "entity" && (
|
|
502
|
+
<div className={styles.attributeList}>
|
|
503
|
+
{filteredAttributes.length === 0 ? (
|
|
504
|
+
<div className={styles.emptyState}>
|
|
505
|
+
<Text>
|
|
506
|
+
{searchText ? "No matching attributes found" : "All attributes already added"}
|
|
507
|
+
</Text>
|
|
508
|
+
</div>
|
|
509
|
+
) : (
|
|
510
|
+
filteredAttributes.map((attr) => (
|
|
511
|
+
<div
|
|
512
|
+
key={attr.LogicalName}
|
|
513
|
+
className={`${styles.attributeItem} ${
|
|
514
|
+
selectedRootAttrs.has(attr.LogicalName) ? styles.attributeItemSelected : ""
|
|
515
|
+
}`}
|
|
516
|
+
onClick={() => handleRootAttributeToggle(attr.LogicalName)}
|
|
517
|
+
role="checkbox"
|
|
518
|
+
aria-checked={selectedRootAttrs.has(attr.LogicalName)}
|
|
519
|
+
tabIndex={0}
|
|
520
|
+
onKeyDown={(e) => {
|
|
521
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
522
|
+
e.preventDefault();
|
|
523
|
+
handleRootAttributeToggle(attr.LogicalName);
|
|
524
|
+
}
|
|
525
|
+
}}
|
|
526
|
+
>
|
|
527
|
+
<Checkbox
|
|
528
|
+
checked={selectedRootAttrs.has(attr.LogicalName)}
|
|
529
|
+
onChange={(e) => {
|
|
530
|
+
e.stopPropagation();
|
|
531
|
+
handleRootAttributeToggle(attr.LogicalName);
|
|
532
|
+
}}
|
|
533
|
+
/>
|
|
534
|
+
<Text>{attr.DisplayName?.UserLocalizedLabel?.Label || attr.LogicalName}</Text>
|
|
535
|
+
</div>
|
|
536
|
+
))
|
|
537
|
+
)}
|
|
538
|
+
</div>
|
|
539
|
+
)}
|
|
540
|
+
|
|
541
|
+
{activeTab === "related" && (
|
|
542
|
+
<div className={styles.relationshipTree}>
|
|
543
|
+
{isLoadingRelationships ? (
|
|
544
|
+
<div className={styles.loadingContainer}>
|
|
545
|
+
<Spinner size="small" label="Loading relationships..." />
|
|
546
|
+
</div>
|
|
547
|
+
) : filteredN1Relationships.length === 0 && filtered1NRelationships.length === 0 ? (
|
|
548
|
+
<div className={styles.emptyState}>
|
|
549
|
+
<Text>No relationships found</Text>
|
|
550
|
+
</div>
|
|
551
|
+
) : (
|
|
552
|
+
<Accordion multiple collapsible defaultOpenItems={["n1"]}>
|
|
553
|
+
{/* N:1 (Lookup) Relationships */}
|
|
554
|
+
{filteredN1Relationships.length > 0 && (
|
|
555
|
+
<AccordionItem value="n1">
|
|
556
|
+
<AccordionHeader size="small">
|
|
557
|
+
<div className={styles.accordionHeader}>
|
|
558
|
+
<Text weight="semibold">Lookups (N:1)</Text>
|
|
559
|
+
<Badge appearance="filled" color="informative" size="small">
|
|
560
|
+
{filteredN1Relationships.length}
|
|
561
|
+
</Badge>
|
|
562
|
+
</div>
|
|
563
|
+
</AccordionHeader>
|
|
564
|
+
<AccordionPanel>
|
|
565
|
+
<Tree aria-label="Lookup relationships">
|
|
566
|
+
{filteredN1Relationships.map((relItem) => {
|
|
567
|
+
const rel = relItem.relationship;
|
|
568
|
+
const isExpanded = expandedRelationships.has(rel.SchemaName);
|
|
569
|
+
const relatedAttrs = relatedAttributesCache.get(relItem.relatedEntity);
|
|
570
|
+
const isLoading = loadingEntities.has(relItem.relatedEntity);
|
|
571
|
+
|
|
572
|
+
return (
|
|
573
|
+
<TreeItem key={rel.SchemaName} itemType="branch" open={isExpanded}>
|
|
574
|
+
<TreeItemLayout
|
|
575
|
+
onClick={() =>
|
|
576
|
+
handleExpandRelationship(rel, relItem.relatedEntity)
|
|
577
|
+
}
|
|
578
|
+
>
|
|
579
|
+
<div className={styles.relationshipHeader}>
|
|
580
|
+
<div className={styles.relationshipLabel}>
|
|
581
|
+
<span className={styles.relationshipName}>
|
|
582
|
+
{relItem.displayName}
|
|
583
|
+
</span>
|
|
584
|
+
<span className={styles.relationshipEntity}>
|
|
585
|
+
{relItem.relatedEntity}
|
|
586
|
+
</span>
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
</TreeItemLayout>
|
|
590
|
+
{isExpanded && (
|
|
591
|
+
<Tree>
|
|
592
|
+
{isLoading ? (
|
|
593
|
+
<TreeItem itemType="leaf">
|
|
594
|
+
<TreeItemLayout>
|
|
595
|
+
<Spinner size="tiny" label="Loading..." />
|
|
596
|
+
</TreeItemLayout>
|
|
597
|
+
</TreeItem>
|
|
598
|
+
) : relatedAttrs && relatedAttrs.length > 0 ? (
|
|
599
|
+
relatedAttrs
|
|
600
|
+
.filter((attr) => attr.AttributeType !== "Virtual")
|
|
601
|
+
.sort((a, b) => {
|
|
602
|
+
const aName =
|
|
603
|
+
a.DisplayName?.UserLocalizedLabel?.Label ||
|
|
604
|
+
a.LogicalName;
|
|
605
|
+
const bName =
|
|
606
|
+
b.DisplayName?.UserLocalizedLabel?.Label ||
|
|
607
|
+
b.LogicalName;
|
|
608
|
+
return aName.localeCompare(bName);
|
|
609
|
+
})
|
|
610
|
+
.map((attr) => (
|
|
611
|
+
<TreeItem key={attr.LogicalName} itemType="leaf">
|
|
612
|
+
<TreeItemLayout
|
|
613
|
+
onClick={() =>
|
|
614
|
+
handleRelatedAttributeToggle(
|
|
615
|
+
rel,
|
|
616
|
+
attr,
|
|
617
|
+
"N1",
|
|
618
|
+
relItem.relatedEntity
|
|
619
|
+
)
|
|
620
|
+
}
|
|
621
|
+
>
|
|
622
|
+
<div className={styles.attributeItem}>
|
|
623
|
+
<Checkbox
|
|
624
|
+
checked={isRelatedAttributeSelected(rel, attr)}
|
|
625
|
+
onChange={(e) => {
|
|
626
|
+
e.stopPropagation();
|
|
627
|
+
handleRelatedAttributeToggle(
|
|
628
|
+
rel,
|
|
629
|
+
attr,
|
|
630
|
+
"N1",
|
|
631
|
+
relItem.relatedEntity
|
|
632
|
+
);
|
|
633
|
+
}}
|
|
634
|
+
/>
|
|
635
|
+
<Text>
|
|
636
|
+
{attr.DisplayName?.UserLocalizedLabel?.Label ||
|
|
637
|
+
attr.LogicalName}
|
|
638
|
+
</Text>
|
|
639
|
+
</div>
|
|
640
|
+
</TreeItemLayout>
|
|
641
|
+
</TreeItem>
|
|
642
|
+
))
|
|
643
|
+
) : (
|
|
644
|
+
<TreeItem itemType="leaf">
|
|
645
|
+
<TreeItemLayout>
|
|
646
|
+
<Text style={{ color: tokens.colorNeutralForeground3 }}>
|
|
647
|
+
No attributes available
|
|
648
|
+
</Text>
|
|
649
|
+
</TreeItemLayout>
|
|
650
|
+
</TreeItem>
|
|
651
|
+
)}
|
|
652
|
+
</Tree>
|
|
653
|
+
)}
|
|
654
|
+
</TreeItem>
|
|
655
|
+
);
|
|
656
|
+
})}
|
|
657
|
+
</Tree>
|
|
658
|
+
</AccordionPanel>
|
|
659
|
+
</AccordionItem>
|
|
660
|
+
)}
|
|
661
|
+
|
|
662
|
+
{/* 1:N (One-to-Many) Relationships */}
|
|
663
|
+
{filtered1NRelationships.length > 0 && (
|
|
664
|
+
<AccordionItem value="1n">
|
|
665
|
+
<AccordionHeader size="small">
|
|
666
|
+
<div className={styles.accordionHeader}>
|
|
667
|
+
<Text weight="semibold">Related (1:N)</Text>
|
|
668
|
+
<Badge appearance="filled" color="informative" size="small">
|
|
669
|
+
{filtered1NRelationships.length}
|
|
670
|
+
</Badge>
|
|
671
|
+
<Badge
|
|
672
|
+
appearance="tint"
|
|
673
|
+
color="warning"
|
|
674
|
+
size="small"
|
|
675
|
+
className={styles.warningBadge}
|
|
676
|
+
>
|
|
677
|
+
May duplicate rows
|
|
678
|
+
</Badge>
|
|
679
|
+
</div>
|
|
680
|
+
</AccordionHeader>
|
|
681
|
+
<AccordionPanel>
|
|
682
|
+
<Tree aria-label="One-to-many relationships">
|
|
683
|
+
{filtered1NRelationships.map((relItem) => {
|
|
684
|
+
const rel = relItem.relationship;
|
|
685
|
+
const isExpanded = expandedRelationships.has(rel.SchemaName);
|
|
686
|
+
const relatedAttrs = relatedAttributesCache.get(relItem.relatedEntity);
|
|
687
|
+
const isLoading = loadingEntities.has(relItem.relatedEntity);
|
|
688
|
+
|
|
689
|
+
return (
|
|
690
|
+
<TreeItem key={rel.SchemaName} itemType="branch" open={isExpanded}>
|
|
691
|
+
<TreeItemLayout
|
|
692
|
+
onClick={() =>
|
|
693
|
+
handleExpandRelationship(rel, relItem.relatedEntity)
|
|
694
|
+
}
|
|
695
|
+
>
|
|
696
|
+
<div className={styles.relationshipHeader}>
|
|
697
|
+
<div className={styles.relationshipLabel}>
|
|
698
|
+
<span className={styles.relationshipName}>
|
|
699
|
+
{relItem.displayName}
|
|
700
|
+
</span>
|
|
701
|
+
<span className={styles.relationshipEntity}>
|
|
702
|
+
{rel.SchemaName}
|
|
703
|
+
</span>
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
706
|
+
</TreeItemLayout>
|
|
707
|
+
{isExpanded && (
|
|
708
|
+
<Tree>
|
|
709
|
+
{isLoading ? (
|
|
710
|
+
<TreeItem itemType="leaf">
|
|
711
|
+
<TreeItemLayout>
|
|
712
|
+
<Spinner size="tiny" label="Loading..." />
|
|
713
|
+
</TreeItemLayout>
|
|
714
|
+
</TreeItem>
|
|
715
|
+
) : relatedAttrs && relatedAttrs.length > 0 ? (
|
|
716
|
+
relatedAttrs
|
|
717
|
+
.filter((attr) => attr.AttributeType !== "Virtual")
|
|
718
|
+
.sort((a, b) => {
|
|
719
|
+
const aName =
|
|
720
|
+
a.DisplayName?.UserLocalizedLabel?.Label ||
|
|
721
|
+
a.LogicalName;
|
|
722
|
+
const bName =
|
|
723
|
+
b.DisplayName?.UserLocalizedLabel?.Label ||
|
|
724
|
+
b.LogicalName;
|
|
725
|
+
return aName.localeCompare(bName);
|
|
726
|
+
})
|
|
727
|
+
.map((attr) => (
|
|
728
|
+
<TreeItem key={attr.LogicalName} itemType="leaf">
|
|
729
|
+
<TreeItemLayout
|
|
730
|
+
onClick={() =>
|
|
731
|
+
handleRelatedAttributeToggle(
|
|
732
|
+
rel,
|
|
733
|
+
attr,
|
|
734
|
+
"1N",
|
|
735
|
+
relItem.relatedEntity
|
|
736
|
+
)
|
|
737
|
+
}
|
|
738
|
+
>
|
|
739
|
+
<div className={styles.attributeItem}>
|
|
740
|
+
<Checkbox
|
|
741
|
+
checked={isRelatedAttributeSelected(rel, attr)}
|
|
742
|
+
onChange={(e) => {
|
|
743
|
+
e.stopPropagation();
|
|
744
|
+
handleRelatedAttributeToggle(
|
|
745
|
+
rel,
|
|
746
|
+
attr,
|
|
747
|
+
"1N",
|
|
748
|
+
relItem.relatedEntity
|
|
749
|
+
);
|
|
750
|
+
}}
|
|
751
|
+
/>
|
|
752
|
+
<Text>
|
|
753
|
+
{attr.DisplayName?.UserLocalizedLabel?.Label ||
|
|
754
|
+
attr.LogicalName}
|
|
755
|
+
</Text>
|
|
756
|
+
</div>
|
|
757
|
+
</TreeItemLayout>
|
|
758
|
+
</TreeItem>
|
|
759
|
+
))
|
|
760
|
+
) : (
|
|
761
|
+
<TreeItem itemType="leaf">
|
|
762
|
+
<TreeItemLayout>
|
|
763
|
+
<Text style={{ color: tokens.colorNeutralForeground3 }}>
|
|
764
|
+
No attributes available
|
|
765
|
+
</Text>
|
|
766
|
+
</TreeItemLayout>
|
|
767
|
+
</TreeItem>
|
|
768
|
+
)}
|
|
769
|
+
</Tree>
|
|
770
|
+
)}
|
|
771
|
+
</TreeItem>
|
|
772
|
+
);
|
|
773
|
+
})}
|
|
774
|
+
</Tree>
|
|
775
|
+
</AccordionPanel>
|
|
776
|
+
</AccordionItem>
|
|
777
|
+
)}
|
|
778
|
+
</Accordion>
|
|
779
|
+
)}
|
|
780
|
+
</div>
|
|
781
|
+
)}
|
|
782
|
+
</div>
|
|
783
|
+
|
|
784
|
+
<div className={styles.footer}>
|
|
785
|
+
{totalSelected > 0 && (
|
|
786
|
+
<Text className={styles.selectedCount}>
|
|
787
|
+
{totalSelected} column{totalSelected !== 1 ? "s" : ""} selected
|
|
788
|
+
</Text>
|
|
789
|
+
)}
|
|
790
|
+
<Button appearance="secondary" onClick={onClose}>
|
|
791
|
+
Close
|
|
792
|
+
</Button>
|
|
793
|
+
<Button appearance="primary" onClick={handleApply} disabled={totalSelected === 0}>
|
|
794
|
+
Add
|
|
795
|
+
</Button>
|
|
796
|
+
</div>
|
|
797
|
+
</DrawerBody>
|
|
798
|
+
</OverlayDrawer>
|
|
799
|
+
);
|
|
800
|
+
}
|