@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,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LoadViewPicker - Grouped combobox for selecting System or Personal Views
|
|
3
|
+
* Only visible when root entity is selected in tree
|
|
4
|
+
* Fetches views for the selected entity and allows loading view's FetchXML into the tree
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useEffect, useMemo, useCallback, type ChangeEvent } from "react";
|
|
8
|
+
import {
|
|
9
|
+
Combobox,
|
|
10
|
+
Option,
|
|
11
|
+
OptionGroup,
|
|
12
|
+
makeStyles,
|
|
13
|
+
Spinner,
|
|
14
|
+
tokens,
|
|
15
|
+
type ComboboxProps,
|
|
16
|
+
} from "@fluentui/react-components";
|
|
17
|
+
import {
|
|
18
|
+
getAllViews,
|
|
19
|
+
type SavedView,
|
|
20
|
+
type EntityMetadata,
|
|
21
|
+
type LoadedViewInfo,
|
|
22
|
+
} from "../../api/pptbClient";
|
|
23
|
+
import { debugLog } from "../../../../shared/utils/debug";
|
|
24
|
+
|
|
25
|
+
const useStyles = makeStyles({
|
|
26
|
+
container: {
|
|
27
|
+
display: "flex",
|
|
28
|
+
flexDirection: "column",
|
|
29
|
+
gap: "4px",
|
|
30
|
+
flex: 1,
|
|
31
|
+
minWidth: "200px",
|
|
32
|
+
maxWidth: "350px",
|
|
33
|
+
},
|
|
34
|
+
label: {
|
|
35
|
+
fontSize: "14px",
|
|
36
|
+
fontWeight: 600,
|
|
37
|
+
color: tokens.colorNeutralForeground2,
|
|
38
|
+
},
|
|
39
|
+
combobox: {
|
|
40
|
+
width: "100%",
|
|
41
|
+
},
|
|
42
|
+
loadingOption: {
|
|
43
|
+
display: "flex",
|
|
44
|
+
alignItems: "center",
|
|
45
|
+
gap: "8px",
|
|
46
|
+
padding: "8px",
|
|
47
|
+
},
|
|
48
|
+
noViewsOption: {
|
|
49
|
+
fontStyle: "italic",
|
|
50
|
+
color: tokens.colorNeutralForeground3,
|
|
51
|
+
},
|
|
52
|
+
defaultBadge: {
|
|
53
|
+
fontSize: "10px",
|
|
54
|
+
backgroundColor: tokens.colorBrandBackground,
|
|
55
|
+
color: tokens.colorNeutralForegroundOnBrand,
|
|
56
|
+
padding: "1px 4px",
|
|
57
|
+
borderRadius: "4px",
|
|
58
|
+
marginLeft: "6px",
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
interface LoadViewPickerProps {
|
|
63
|
+
/** Currently selected entity metadata (with ObjectTypeCode) */
|
|
64
|
+
selectedEntityMetadata: EntityMetadata | null;
|
|
65
|
+
/** Callback when a view is selected - provides the view info for execution optimization */
|
|
66
|
+
onViewSelect: (viewInfo: LoadedViewInfo) => void;
|
|
67
|
+
/** Callback when the view selection is cleared */
|
|
68
|
+
onViewClear?: () => void;
|
|
69
|
+
/** Optional: disable the picker */
|
|
70
|
+
disabled?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function LoadViewPicker({
|
|
74
|
+
selectedEntityMetadata,
|
|
75
|
+
onViewSelect,
|
|
76
|
+
onViewClear,
|
|
77
|
+
disabled = false,
|
|
78
|
+
}: LoadViewPickerProps) {
|
|
79
|
+
const styles = useStyles();
|
|
80
|
+
|
|
81
|
+
// State for views
|
|
82
|
+
const [systemViews, setSystemViews] = useState<SavedView[]>([]);
|
|
83
|
+
const [personalViews, setPersonalViews] = useState<SavedView[]>([]);
|
|
84
|
+
const [loading, setLoading] = useState(false);
|
|
85
|
+
const [error, setError] = useState<string | null>(null);
|
|
86
|
+
|
|
87
|
+
// Combobox state - track both input value and selected view name
|
|
88
|
+
const [inputValue, setInputValue] = useState("");
|
|
89
|
+
const [selectedViewName, setSelectedViewName] = useState<string | null>(null);
|
|
90
|
+
|
|
91
|
+
// Fetch views when entity changes
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!selectedEntityMetadata) {
|
|
94
|
+
setSystemViews([]);
|
|
95
|
+
setPersonalViews([]);
|
|
96
|
+
setError(null);
|
|
97
|
+
setSelectedViewName(null);
|
|
98
|
+
setInputValue("");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const fetchViews = async () => {
|
|
103
|
+
setLoading(true);
|
|
104
|
+
setError(null);
|
|
105
|
+
setSelectedViewName(null);
|
|
106
|
+
setInputValue("");
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
debugLog("viewAPI", `Loading views for ${selectedEntityMetadata.LogicalName}`);
|
|
110
|
+
|
|
111
|
+
const { systemViews: sysViews, personalViews: persViews } = await getAllViews(
|
|
112
|
+
selectedEntityMetadata.LogicalName
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
setSystemViews(sysViews);
|
|
116
|
+
setPersonalViews(persViews);
|
|
117
|
+
|
|
118
|
+
debugLog(
|
|
119
|
+
"viewAPI",
|
|
120
|
+
`Loaded ${sysViews.length} system views and ${persViews.length} personal views`
|
|
121
|
+
);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const message = err instanceof Error ? err.message : "Failed to load views";
|
|
124
|
+
setError(message);
|
|
125
|
+
console.error("LoadViewPicker: Failed to fetch views:", err);
|
|
126
|
+
} finally {
|
|
127
|
+
setLoading(false);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
fetchViews();
|
|
132
|
+
}, [selectedEntityMetadata]);
|
|
133
|
+
|
|
134
|
+
// Combine all views for filtering
|
|
135
|
+
const allViews = useMemo(() => {
|
|
136
|
+
return [...systemViews, ...personalViews];
|
|
137
|
+
}, [systemViews, personalViews]);
|
|
138
|
+
|
|
139
|
+
// Filter options based on input value (only when typing, not when showing selected)
|
|
140
|
+
const filterQuery = inputValue && inputValue !== selectedViewName ? inputValue : "";
|
|
141
|
+
|
|
142
|
+
const filteredSystemViews = useMemo(() => {
|
|
143
|
+
if (!filterQuery.trim()) return systemViews;
|
|
144
|
+
const lowerQuery = filterQuery.toLowerCase();
|
|
145
|
+
return systemViews.filter((v) => v.name.toLowerCase().includes(lowerQuery));
|
|
146
|
+
}, [systemViews, filterQuery]);
|
|
147
|
+
|
|
148
|
+
const filteredPersonalViews = useMemo(() => {
|
|
149
|
+
if (!filterQuery.trim()) return personalViews;
|
|
150
|
+
const lowerQuery = filterQuery.toLowerCase();
|
|
151
|
+
return personalViews.filter((v) => v.name.toLowerCase().includes(lowerQuery));
|
|
152
|
+
}, [personalViews, filterQuery]);
|
|
153
|
+
|
|
154
|
+
// Handle selection
|
|
155
|
+
const handleOptionSelect = useCallback<NonNullable<ComboboxProps["onOptionSelect"]>>(
|
|
156
|
+
(_event, data) => {
|
|
157
|
+
// Handle clear action - when clearable button is clicked, optionValue is empty
|
|
158
|
+
if (!data.optionValue) {
|
|
159
|
+
setSelectedViewName(null);
|
|
160
|
+
setInputValue("");
|
|
161
|
+
onViewClear?.();
|
|
162
|
+
debugLog("viewAPI", "View selection cleared");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!selectedEntityMetadata) return;
|
|
167
|
+
|
|
168
|
+
const view = allViews.find((v) => v.id === data.optionValue);
|
|
169
|
+
if (view) {
|
|
170
|
+
// Create LoadedViewInfo with all needed data for execution optimization
|
|
171
|
+
const viewInfo: LoadedViewInfo = {
|
|
172
|
+
id: view.id,
|
|
173
|
+
type: view.type,
|
|
174
|
+
originalFetchXml: view.fetchxml,
|
|
175
|
+
entitySetName: selectedEntityMetadata.EntitySetName,
|
|
176
|
+
name: view.name,
|
|
177
|
+
layoutxml: view.layoutxml, // Include layoutxml for column configuration
|
|
178
|
+
};
|
|
179
|
+
onViewSelect(viewInfo);
|
|
180
|
+
debugLog("viewAPI", `Selected view: ${view.name} (${view.type}) - ID: ${view.id}`);
|
|
181
|
+
// Set the selected view name to display in the combobox
|
|
182
|
+
setSelectedViewName(view.name);
|
|
183
|
+
setInputValue(view.name);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
[allViews, onViewSelect, onViewClear, selectedEntityMetadata]
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// Handle input change for filtering
|
|
190
|
+
const handleInputChange = useCallback(
|
|
191
|
+
(event: ChangeEvent<HTMLInputElement>) => {
|
|
192
|
+
setInputValue(event.target.value);
|
|
193
|
+
// Clear selection when user starts typing something different
|
|
194
|
+
if (event.target.value !== selectedViewName) {
|
|
195
|
+
setSelectedViewName(null);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
[selectedViewName]
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Don't render if no entity selected
|
|
202
|
+
if (!selectedEntityMetadata) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const hasViews = systemViews.length > 0 || personalViews.length > 0;
|
|
207
|
+
const hasFilteredViews = filteredSystemViews.length > 0 || filteredPersonalViews.length > 0;
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div className={styles.container}>
|
|
211
|
+
<label className={styles.label}>Load View</label>
|
|
212
|
+
<Combobox
|
|
213
|
+
className={styles.combobox}
|
|
214
|
+
placeholder={loading ? "Loading views..." : "Select a view to load"}
|
|
215
|
+
disabled={disabled || loading || !hasViews}
|
|
216
|
+
value={inputValue}
|
|
217
|
+
onChange={handleInputChange}
|
|
218
|
+
onOptionSelect={handleOptionSelect}
|
|
219
|
+
clearable={!!selectedViewName}
|
|
220
|
+
freeform
|
|
221
|
+
>
|
|
222
|
+
{loading && (
|
|
223
|
+
<Option key="loading" value="" text="Loading..." disabled>
|
|
224
|
+
<div className={styles.loadingOption}>
|
|
225
|
+
<Spinner size="tiny" />
|
|
226
|
+
<span>Loading views...</span>
|
|
227
|
+
</div>
|
|
228
|
+
</Option>
|
|
229
|
+
)}
|
|
230
|
+
|
|
231
|
+
{!loading && error && (
|
|
232
|
+
<Option key="error" value="" text="Error" disabled>
|
|
233
|
+
<span style={{ color: tokens.colorPaletteRedForeground1 }}>{error}</span>
|
|
234
|
+
</Option>
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
{!loading && !error && !hasViews && (
|
|
238
|
+
<Option key="no-views" value="" text="No views available" disabled>
|
|
239
|
+
<span className={styles.noViewsOption}>No views available</span>
|
|
240
|
+
</Option>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{!loading && !error && hasViews && !hasFilteredViews && filterQuery && (
|
|
244
|
+
<Option key="no-match" value="" text="No matching views" disabled>
|
|
245
|
+
<span className={styles.noViewsOption}>No matching views</span>
|
|
246
|
+
</Option>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{!loading && !error && filteredSystemViews.length > 0 && (
|
|
250
|
+
<OptionGroup label="System Views">
|
|
251
|
+
{filteredSystemViews.map((view) => (
|
|
252
|
+
<Option key={view.id} value={view.id} text={view.name}>
|
|
253
|
+
<span>
|
|
254
|
+
{view.name}
|
|
255
|
+
{view.isDefault && <span className={styles.defaultBadge}>Default</span>}
|
|
256
|
+
</span>
|
|
257
|
+
</Option>
|
|
258
|
+
))}
|
|
259
|
+
</OptionGroup>
|
|
260
|
+
)}
|
|
261
|
+
|
|
262
|
+
{!loading && !error && filteredPersonalViews.length > 0 && (
|
|
263
|
+
<OptionGroup label="Personal Views">
|
|
264
|
+
{filteredPersonalViews.map((view) => (
|
|
265
|
+
<Option key={view.id} value={view.id} text={view.name}>
|
|
266
|
+
{view.name}
|
|
267
|
+
</Option>
|
|
268
|
+
))}
|
|
269
|
+
</OptionGroup>
|
|
270
|
+
)}
|
|
271
|
+
</Combobox>
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SaveViewButton - SplitButton for saving views to Dataverse
|
|
3
|
+
* Supports Save, Save As, and Save and Publish actions
|
|
4
|
+
* Handles both System Views (savedquery) and Personal Views (userquery)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useCallback } from "react";
|
|
8
|
+
import {
|
|
9
|
+
Menu,
|
|
10
|
+
MenuTrigger,
|
|
11
|
+
MenuList,
|
|
12
|
+
MenuItem,
|
|
13
|
+
MenuPopover,
|
|
14
|
+
SplitButton,
|
|
15
|
+
makeStyles,
|
|
16
|
+
tokens,
|
|
17
|
+
Tooltip,
|
|
18
|
+
type MenuButtonProps,
|
|
19
|
+
} from "@fluentui/react-components";
|
|
20
|
+
import { SaveRegular, SaveArrowRightRegular, SendRegular } from "@fluentui/react-icons";
|
|
21
|
+
import { SaveViewDialog, type SaveViewDialogMode } from "../Dialogs/SaveViewDialog";
|
|
22
|
+
|
|
23
|
+
const useStyles = makeStyles({
|
|
24
|
+
splitButton: {
|
|
25
|
+
minWidth: "110px",
|
|
26
|
+
},
|
|
27
|
+
menuItem: {
|
|
28
|
+
display: "flex",
|
|
29
|
+
alignItems: "center",
|
|
30
|
+
gap: "8px",
|
|
31
|
+
},
|
|
32
|
+
menuIcon: {
|
|
33
|
+
fontSize: "16px",
|
|
34
|
+
color: tokens.colorNeutralForeground2,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
interface SaveViewButtonProps {
|
|
39
|
+
/** Current FetchXML to save */
|
|
40
|
+
fetchXml: string;
|
|
41
|
+
/** Current LayoutXML configuration */
|
|
42
|
+
layoutXml: string;
|
|
43
|
+
/** Entity logical name for the view */
|
|
44
|
+
entityLogicalName: string;
|
|
45
|
+
/** Entity object type code for LayoutXML */
|
|
46
|
+
objectTypeCode: number;
|
|
47
|
+
/** Primary ID attribute for LayoutXML */
|
|
48
|
+
primaryIdAttribute: string;
|
|
49
|
+
/** Loaded view info (for Save/overwrite) */
|
|
50
|
+
loadedView: {
|
|
51
|
+
id: string;
|
|
52
|
+
type: "system" | "personal";
|
|
53
|
+
name: string;
|
|
54
|
+
} | null;
|
|
55
|
+
/** Callback after successful save */
|
|
56
|
+
onSaveComplete?: (viewId: string, viewType: "system" | "personal", viewName: string) => void;
|
|
57
|
+
/** Disabled state */
|
|
58
|
+
disabled?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function SaveViewButton({
|
|
62
|
+
fetchXml,
|
|
63
|
+
layoutXml,
|
|
64
|
+
entityLogicalName,
|
|
65
|
+
objectTypeCode,
|
|
66
|
+
primaryIdAttribute,
|
|
67
|
+
loadedView,
|
|
68
|
+
onSaveComplete,
|
|
69
|
+
disabled = false,
|
|
70
|
+
}: SaveViewButtonProps) {
|
|
71
|
+
const styles = useStyles();
|
|
72
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
73
|
+
const [dialogMode, setDialogMode] = useState<SaveViewDialogMode>("save");
|
|
74
|
+
const [shouldPublish, setShouldPublish] = useState(false);
|
|
75
|
+
|
|
76
|
+
const handleSave = useCallback(() => {
|
|
77
|
+
setShouldPublish(false);
|
|
78
|
+
setDialogMode("save");
|
|
79
|
+
setDialogOpen(true);
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
const handleSaveAs = useCallback(() => {
|
|
83
|
+
setShouldPublish(false);
|
|
84
|
+
setDialogMode("saveAs");
|
|
85
|
+
setDialogOpen(true);
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
const handleSaveAndPublish = useCallback(() => {
|
|
89
|
+
setShouldPublish(true);
|
|
90
|
+
setDialogMode("save");
|
|
91
|
+
setDialogOpen(true);
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const handleDialogClose = useCallback(() => {
|
|
95
|
+
setDialogOpen(false);
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const handleDialogSaveComplete = useCallback(
|
|
99
|
+
(viewId: string, viewType: "system" | "personal", viewName: string) => {
|
|
100
|
+
setDialogOpen(false);
|
|
101
|
+
onSaveComplete?.(viewId, viewType, viewName);
|
|
102
|
+
},
|
|
103
|
+
[onSaveComplete]
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Primary button text based on state
|
|
107
|
+
const primaryText = loadedView ? "Save" : "Save As";
|
|
108
|
+
const primaryIcon = loadedView ? <SaveRegular /> : <SaveArrowRightRegular />;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<>
|
|
112
|
+
<Menu positioning="below-end">
|
|
113
|
+
<MenuTrigger disableButtonEnhancement>
|
|
114
|
+
{(triggerProps: MenuButtonProps) => (
|
|
115
|
+
<Tooltip
|
|
116
|
+
content={loadedView ? `Save changes to "${loadedView.name}"` : "Save as new view"}
|
|
117
|
+
relationship="description"
|
|
118
|
+
>
|
|
119
|
+
<SplitButton
|
|
120
|
+
className={styles.splitButton}
|
|
121
|
+
appearance="primary"
|
|
122
|
+
icon={primaryIcon}
|
|
123
|
+
menuButton={triggerProps}
|
|
124
|
+
primaryActionButton={{
|
|
125
|
+
onClick: loadedView ? handleSave : handleSaveAs,
|
|
126
|
+
}}
|
|
127
|
+
disabled={disabled}
|
|
128
|
+
>
|
|
129
|
+
{primaryText}
|
|
130
|
+
</SplitButton>
|
|
131
|
+
</Tooltip>
|
|
132
|
+
)}
|
|
133
|
+
</MenuTrigger>
|
|
134
|
+
<MenuPopover>
|
|
135
|
+
<MenuList>
|
|
136
|
+
{loadedView && (
|
|
137
|
+
<MenuItem icon={<SaveRegular className={styles.menuIcon} />} onClick={handleSave}>
|
|
138
|
+
<span className={styles.menuItem}>Save</span>
|
|
139
|
+
</MenuItem>
|
|
140
|
+
)}
|
|
141
|
+
<MenuItem
|
|
142
|
+
icon={<SaveArrowRightRegular className={styles.menuIcon} />}
|
|
143
|
+
onClick={handleSaveAs}
|
|
144
|
+
>
|
|
145
|
+
<span className={styles.menuItem}>Save As...</span>
|
|
146
|
+
</MenuItem>
|
|
147
|
+
{loadedView?.type === "system" && (
|
|
148
|
+
<MenuItem
|
|
149
|
+
icon={<SendRegular className={styles.menuIcon} />}
|
|
150
|
+
onClick={handleSaveAndPublish}
|
|
151
|
+
>
|
|
152
|
+
<span className={styles.menuItem}>Save and Publish</span>
|
|
153
|
+
</MenuItem>
|
|
154
|
+
)}
|
|
155
|
+
</MenuList>
|
|
156
|
+
</MenuPopover>
|
|
157
|
+
</Menu>
|
|
158
|
+
|
|
159
|
+
<SaveViewDialog
|
|
160
|
+
open={dialogOpen}
|
|
161
|
+
onClose={handleDialogClose}
|
|
162
|
+
onSaveComplete={handleDialogSaveComplete}
|
|
163
|
+
mode={dialogMode}
|
|
164
|
+
shouldPublish={shouldPublish}
|
|
165
|
+
fetchXml={fetchXml}
|
|
166
|
+
layoutXml={layoutXml}
|
|
167
|
+
entityLogicalName={entityLogicalName}
|
|
168
|
+
objectTypeCode={objectTypeCode}
|
|
169
|
+
primaryIdAttribute={primaryIdAttribute}
|
|
170
|
+
loadedView={loadedView}
|
|
171
|
+
/>
|
|
172
|
+
</>
|
|
173
|
+
);
|
|
174
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { AppShell } from "./app/AppShell";
|
|
4
|
+
// Import debug utility to expose functions to window
|
|
5
|
+
import "./shared/utils/debug";
|
|
6
|
+
|
|
7
|
+
createRoot(document.getElementById("root")!).render(
|
|
8
|
+
<StrictMode>
|
|
9
|
+
<AppShell />
|
|
10
|
+
</StrictMode>
|
|
11
|
+
);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Attribute Picker component using Fluent UI v9 Combobox
|
|
3
|
+
* Loads attributes from metadata and displays as searchable dropdown
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect } from "react";
|
|
7
|
+
import { Combobox, useComboboxFilter, useId, type ComboboxProps } from "@fluentui/react-components";
|
|
8
|
+
import { loadEntityAttributes } from "../../features/fetchxml/api/dataverseMetadata";
|
|
9
|
+
import type { AttributeMetadata } from "../../features/fetchxml/api/pptbClient";
|
|
10
|
+
|
|
11
|
+
interface AttributePickerProps {
|
|
12
|
+
entityLogicalName: string;
|
|
13
|
+
value: string;
|
|
14
|
+
onChange: (logicalName: string) => void;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
/** Filter to only show attributes of these types */
|
|
18
|
+
filterByTypes?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AttributePicker({
|
|
22
|
+
entityLogicalName,
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
placeholder = "Select or type attribute name",
|
|
26
|
+
disabled = false,
|
|
27
|
+
filterByTypes,
|
|
28
|
+
}: AttributePickerProps) {
|
|
29
|
+
const comboId = useId("attribute-combobox");
|
|
30
|
+
const [attributes, setAttributes] = useState<AttributeMetadata[]>([]);
|
|
31
|
+
const [loading, setLoading] = useState(false);
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
const [query, setQuery] = useState<string>("");
|
|
34
|
+
|
|
35
|
+
// Load attributes when entity changes
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!entityLogicalName) {
|
|
38
|
+
setAttributes([]);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
setLoading(true);
|
|
43
|
+
setError(null);
|
|
44
|
+
|
|
45
|
+
loadEntityAttributes(entityLogicalName)
|
|
46
|
+
.then((attrs: AttributeMetadata[]) => {
|
|
47
|
+
// Apply type filtering if specified
|
|
48
|
+
let filteredAttrs = attrs;
|
|
49
|
+
if (filterByTypes && filterByTypes.length > 0) {
|
|
50
|
+
filteredAttrs = attrs.filter((a) => filterByTypes.includes(a.AttributeType || ""));
|
|
51
|
+
}
|
|
52
|
+
setAttributes(filteredAttrs);
|
|
53
|
+
setLoading(false);
|
|
54
|
+
})
|
|
55
|
+
.catch((err: unknown) => {
|
|
56
|
+
console.error("Failed to load attributes:", err);
|
|
57
|
+
setError("Failed to load attributes");
|
|
58
|
+
setLoading(false);
|
|
59
|
+
});
|
|
60
|
+
}, [entityLogicalName, filterByTypes]);
|
|
61
|
+
|
|
62
|
+
// Sync query with value when value changes or attributes load
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (value && value !== "new_attribute" && attributes.length > 0) {
|
|
65
|
+
const attr = attributes.find((a) => a.LogicalName === value);
|
|
66
|
+
if (attr) {
|
|
67
|
+
const displayName = attr.DisplayName?.UserLocalizedLabel?.Label || attr.SchemaName;
|
|
68
|
+
setQuery(`${displayName} (${attr.LogicalName})`);
|
|
69
|
+
} else {
|
|
70
|
+
// If attribute not found in metadata, just show the logical name
|
|
71
|
+
setQuery(value);
|
|
72
|
+
}
|
|
73
|
+
} else if (!value || value === "new_attribute") {
|
|
74
|
+
setQuery("");
|
|
75
|
+
}
|
|
76
|
+
}, [value, attributes]);
|
|
77
|
+
|
|
78
|
+
// Convert attributes to options for filtering
|
|
79
|
+
const options = attributes.map((attr) => {
|
|
80
|
+
const displayName = attr.DisplayName?.UserLocalizedLabel?.Label || attr.SchemaName;
|
|
81
|
+
const label = `${displayName} (${attr.LogicalName})`;
|
|
82
|
+
return {
|
|
83
|
+
children: label,
|
|
84
|
+
value: attr.LogicalName,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Use Fluent UI filtering
|
|
89
|
+
const filteredChildren = useComboboxFilter(query, options, {
|
|
90
|
+
noOptionsMessage: "No attributes match your search.",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const onOptionSelect: ComboboxProps["onOptionSelect"] = (_e, data) => {
|
|
94
|
+
if (data.optionValue) {
|
|
95
|
+
setQuery(data.optionText ?? "");
|
|
96
|
+
onChange(data.optionValue);
|
|
97
|
+
} else {
|
|
98
|
+
// Clear button clicked - clear the attribute
|
|
99
|
+
setQuery("");
|
|
100
|
+
onChange("");
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Track the selected option for visual indicator (but don't populate the input)
|
|
105
|
+
const selectedOptions = value && value !== "new_attribute" ? [value] : [];
|
|
106
|
+
|
|
107
|
+
if (error) {
|
|
108
|
+
// Fallback to regular input on error
|
|
109
|
+
return (
|
|
110
|
+
<Combobox
|
|
111
|
+
aria-labelledby={comboId}
|
|
112
|
+
value={query}
|
|
113
|
+
onChange={(ev) => {
|
|
114
|
+
const inputValue = ev.target.value;
|
|
115
|
+
setQuery(inputValue);
|
|
116
|
+
onChange(inputValue);
|
|
117
|
+
}}
|
|
118
|
+
placeholder={placeholder}
|
|
119
|
+
disabled={disabled}
|
|
120
|
+
/>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<Combobox
|
|
126
|
+
aria-labelledby={comboId}
|
|
127
|
+
onOptionSelect={onOptionSelect}
|
|
128
|
+
selectedOptions={selectedOptions}
|
|
129
|
+
value={query}
|
|
130
|
+
onChange={(ev) => setQuery(ev.target.value)}
|
|
131
|
+
placeholder={loading ? "Loading attributes..." : placeholder}
|
|
132
|
+
disabled={disabled || loading}
|
|
133
|
+
clearable
|
|
134
|
+
>
|
|
135
|
+
{filteredChildren}
|
|
136
|
+
</Combobox>
|
|
137
|
+
);
|
|
138
|
+
}
|