@khester/create-dynamics-app 1.0.8 → 1.1.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/bin/create-dynamics-app.js +1 -1
- package/dist/index.js +140 -15
- package/dist/index.js.map +1 -1
- package/dist/utils/consultingHelpers.d.ts +13 -0
- package/dist/utils/consultingHelpers.d.ts.map +1 -0
- package/dist/utils/consultingHelpers.js +569 -0
- package/dist/utils/consultingHelpers.js.map +1 -0
- package/dist/utils/copyTemplate.d.ts.map +1 -1
- package/dist/utils/copyTemplate.js.map +1 -1
- package/dist/utils/initGit.d.ts.map +1 -1
- package/dist/utils/initGit.js.map +1 -1
- package/dist/utils/installDependencies.d.ts.map +1 -1
- package/dist/utils/installDependencies.js +3 -2
- package/dist/utils/installDependencies.js.map +1 -1
- package/dist/utils/updatePackageJson.d.ts +1 -1
- package/dist/utils/updatePackageJson.d.ts.map +1 -1
- package/dist/utils/updatePackageJson.js +11 -1
- package/dist/utils/updatePackageJson.js.map +1 -1
- package/package.json +1 -1
- package/templates/dynamics-365-starter/INTEGRATION_TEST_RESULTS.md +302 -0
- package/templates/dynamics-365-starter/PHASE_4_COMPLETION_SUMMARY.md +305 -0
- package/templates/dynamics-365-starter/README.md +566 -137
- package/templates/dynamics-365-starter/deployment/QUICKSTART-MAC.md +507 -0
- package/templates/dynamics-365-starter/deployment/QUICKSTART-WINDOWS.md +372 -0
- package/templates/dynamics-365-starter/deployment/README.md +484 -0
- package/templates/dynamics-365-starter/deployment/pipelines/README.md +375 -0
- package/templates/dynamics-365-starter/deployment/pipelines/azure-pipelines.yml +330 -0
- package/templates/dynamics-365-starter/deployment/pipelines/github-actions.yml +422 -0
- package/templates/dynamics-365-starter/deployment/pipelines/jenkins.groovy +636 -0
- package/templates/dynamics-365-starter/deployment/scripts/deploy.ps1 +417 -0
- package/templates/dynamics-365-starter/deployment/scripts/deploy.sh +582 -0
- package/templates/dynamics-365-starter/deployment/scripts/team-onboarding.ps1 +486 -0
- package/templates/dynamics-365-starter/deployment/scripts/team-onboarding.sh +567 -0
- package/templates/dynamics-365-starter/deployment/scripts/validate-setup.ps1 +703 -0
- package/templates/dynamics-365-starter/deployment/scripts/validate-setup.sh +671 -0
- package/templates/dynamics-365-starter/docs/ARCHITECTURE_OVERVIEW.md +506 -0
- package/templates/dynamics-365-starter/docs/BEST_PRACTICES.md +723 -0
- package/templates/dynamics-365-starter/docs/MIGRATION_GUIDE.md +447 -0
- package/templates/dynamics-365-starter/docs/team-standards/README.md +273 -0
- package/templates/dynamics-365-starter/docs/team-standards/client-onboarding.md +577 -0
- package/templates/dynamics-365-starter/docs/team-standards/code-review-checklist.md +359 -0
- package/templates/dynamics-365-starter/docs/team-standards/coding-standards.md +700 -0
- package/templates/dynamics-365-starter/docs/team-standards/cross-platform-team-guide.md +736 -0
- package/templates/dynamics-365-starter/docs/team-standards/development-workflows.md +727 -0
- package/templates/dynamics-365-starter/docs/troubleshooting/common-errors.md +758 -0
- package/templates/dynamics-365-starter/docs/troubleshooting/platform-specific-issues.md +878 -0
- package/templates/dynamics-365-starter/package.json +22 -1
- package/templates/dynamics-365-starter/public/index.html +8 -11
- package/templates/dynamics-365-starter/scripts/custom-build.js +255 -0
- package/templates/dynamics-365-starter/src/client-project-template/README.md +234 -0
- package/templates/dynamics-365-starter/src/client-project-template/config/client.template.json +114 -0
- package/templates/dynamics-365-starter/src/client-project-template/config/environments/template.json +186 -0
- package/templates/dynamics-365-starter/src/client-project-template/scripts/client-setup.js +667 -0
- package/templates/dynamics-365-starter/src/components/AccountForm.css +71 -0
- package/templates/dynamics-365-starter/src/components/AccountForm.tsx +541 -0
- package/templates/dynamics-365-starter/src/components/AccountManagement.css +86 -0
- package/templates/dynamics-365-starter/src/components/AccountManagement.tsx +370 -0
- package/templates/dynamics-365-starter/src/components/ContactForm.tsx +149 -63
- package/templates/dynamics-365-starter/src/components/ContactManagement.tsx +153 -63
- package/templates/dynamics-365-starter/src/components/Logging/LogDialog.tsx +291 -0
- package/templates/dynamics-365-starter/src/components/Logging/LoggingContext.tsx +166 -0
- package/templates/dynamics-365-starter/src/components/Logging/LoggingDebugPanel.css +192 -0
- package/templates/dynamics-365-starter/src/components/Logging/LoggingDebugPanel.tsx +177 -0
- package/templates/dynamics-365-starter/src/components/Logging/LoggingProvider.tsx +3 -0
- package/templates/dynamics-365-starter/src/components/Logging/logger.ts +193 -0
- package/templates/dynamics-365-starter/src/constants/account.ts +410 -0
- package/templates/dynamics-365-starter/src/constants/contact.ts +362 -0
- package/templates/dynamics-365-starter/src/examples/README.md +52 -0
- package/templates/dynamics-365-starter/src/examples/component-examples/opportunity-management.tsx +625 -0
- package/templates/dynamics-365-starter/src/examples/entity-examples/opportunity-model.ts +545 -0
- package/templates/dynamics-365-starter/src/examples/integration-examples/custom-pcf-wrapper.tsx +722 -0
- package/templates/dynamics-365-starter/src/examples/workflow-examples/sales-workflow.ts +662 -0
- package/templates/dynamics-365-starter/src/index.tsx +107 -19
- package/templates/dynamics-365-starter/src/models/Account.ts +480 -0
- package/templates/dynamics-365-starter/src/models/BaseEntity.ts +204 -0
- package/templates/dynamics-365-starter/src/models/Contact.ts +580 -0
- package/templates/dynamics-365-starter/src/page-templates/EntityDashboard.tsx +519 -0
- package/templates/dynamics-365-starter/src/page-templates/EntityDetailPage.tsx +456 -0
- package/templates/dynamics-365-starter/src/page-templates/EntityListPage.tsx +406 -0
- package/templates/dynamics-365-starter/src/page-templates/RelatedEntitiesPage.tsx +578 -0
- package/templates/dynamics-365-starter/src/page-templates/SearchPage.tsx +629 -0
- package/templates/dynamics-365-starter/src/pcf/ContactControlWrapper.tsx +75 -22
- package/templates/dynamics-365-starter/src/pcf/MultiEntityControlWrapper.tsx +205 -0
- package/templates/dynamics-365-starter/src/providers/DynamicsProvider.tsx +297 -80
- package/templates/dynamics-365-starter/src/services/MockApiService.ts +260 -0
- package/templates/dynamics-365-starter/src/services/ServiceFactory.ts +65 -0
- package/templates/dynamics-365-starter/src/services/XrmApiService.ts +213 -0
- package/templates/dynamics-365-starter/src/styles/index.css +74 -7
- package/templates/dynamics-365-starter/tools/entity-generator/index.js +168 -0
- package/templates/dynamics-365-starter/tools/entity-generator/templates/constants.template.ts +124 -0
- package/templates/dynamics-365-starter/tools/entity-generator/templates/form.template.css +283 -0
- package/templates/dynamics-365-starter/tools/entity-generator/templates/form.template.tsx +275 -0
- package/templates/dynamics-365-starter/tools/entity-generator/templates/management.template.css +204 -0
- package/templates/dynamics-365-starter/tools/entity-generator/templates/management.template.tsx +413 -0
- package/templates/dynamics-365-starter/tools/entity-generator/templates/model.template.ts +250 -0
- package/templates/dynamics-365-starter/tools/metadata-sync/d365-client.js +410 -0
- package/templates/dynamics-365-starter/tools/metadata-sync/index.js +512 -0
- package/templates/dynamics-365-starter/tools/metadata-sync/type-generator.js +675 -0
- package/templates/dynamics-365-starter/tsconfig.json +11 -8
- package/templates/dynamics-365-starter/webpack.config.js +8 -9
- package/templates/power-pages-starter/README.md +7 -1
- package/templates/power-pages-starter/public/index.html +8 -11
- package/templates/power-pages-starter/src/components/ContactForm.tsx +60 -41
- package/templates/power-pages-starter/src/index.tsx +3 -3
- package/templates/power-pages-starter/src/providers/PowerPagesProvider.tsx +46 -23
- package/templates/power-pages-starter/tsconfig.json +3 -9
- package/templates/power-pages-starter/webpack.config.js +8 -3
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Stack,
|
|
4
|
+
Text,
|
|
5
|
+
Pivot,
|
|
6
|
+
PivotItem,
|
|
7
|
+
DetailsList,
|
|
8
|
+
IColumn,
|
|
9
|
+
SelectionMode,
|
|
10
|
+
CommandBar,
|
|
11
|
+
ICommandBarItemProps,
|
|
12
|
+
MessageBar,
|
|
13
|
+
MessageBarType,
|
|
14
|
+
Spinner,
|
|
15
|
+
SpinnerSize,
|
|
16
|
+
Panel,
|
|
17
|
+
PanelType,
|
|
18
|
+
SearchBox,
|
|
19
|
+
Breadcrumb,
|
|
20
|
+
IBreadcrumbItem,
|
|
21
|
+
} from '@fluentui/react';
|
|
22
|
+
import { BaseEntity } from '../models/BaseEntity';
|
|
23
|
+
import { useDynamicsApi } from '../providers/DynamicsProvider';
|
|
24
|
+
import { Logger } from '../components/Logging/logger';
|
|
25
|
+
|
|
26
|
+
interface RelatedEntityConfig<T extends BaseEntity> {
|
|
27
|
+
key: string;
|
|
28
|
+
title: string;
|
|
29
|
+
entityLogicalName: string;
|
|
30
|
+
entityClass: any;
|
|
31
|
+
columns: IColumn[];
|
|
32
|
+
fetchXmlTemplate: string; // Should include {{parentId}} placeholder
|
|
33
|
+
relationshipField: string;
|
|
34
|
+
allowCreate?: boolean;
|
|
35
|
+
allowEdit?: boolean;
|
|
36
|
+
allowDelete?: boolean;
|
|
37
|
+
renderForm?: (
|
|
38
|
+
entity: T | null,
|
|
39
|
+
onSave: (entity: T) => void,
|
|
40
|
+
onCancel: () => void
|
|
41
|
+
) => React.ReactNode;
|
|
42
|
+
customCommands?: ICommandBarItemProps[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface RelatedEntitiesPageProps<TParent extends BaseEntity> {
|
|
46
|
+
parentEntity: TParent;
|
|
47
|
+
parentTitle: string;
|
|
48
|
+
relatedEntityConfigs: RelatedEntityConfig<any>[];
|
|
49
|
+
onBack?: () => void;
|
|
50
|
+
breadcrumbItems?: IBreadcrumbItem[];
|
|
51
|
+
enableSearch?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generic related entities page template for Dynamics 365
|
|
56
|
+
* Displays master-detail relationships with tabbed navigation
|
|
57
|
+
*/
|
|
58
|
+
export function RelatedEntitiesPage<TParent extends BaseEntity>({
|
|
59
|
+
parentEntity,
|
|
60
|
+
parentTitle,
|
|
61
|
+
relatedEntityConfigs,
|
|
62
|
+
onBack,
|
|
63
|
+
breadcrumbItems = [],
|
|
64
|
+
enableSearch = true,
|
|
65
|
+
}: RelatedEntitiesPageProps<TParent>) {
|
|
66
|
+
const { apiService } = useDynamicsApi();
|
|
67
|
+
const [selectedTab, setSelectedTab] = useState(
|
|
68
|
+
relatedEntityConfigs[0]?.key || ''
|
|
69
|
+
);
|
|
70
|
+
const [relatedData, setRelatedData] = useState<{ [key: string]: any[] }>({});
|
|
71
|
+
const [filteredData, setFilteredData] = useState<{ [key: string]: any[] }>(
|
|
72
|
+
{}
|
|
73
|
+
);
|
|
74
|
+
const [loading, setLoading] = useState<{ [key: string]: boolean }>({});
|
|
75
|
+
const [error, setError] = useState<string | null>(null);
|
|
76
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
77
|
+
const [selectedItems, setSelectedItems] = useState<{ [key: string]: any }>(
|
|
78
|
+
{}
|
|
79
|
+
);
|
|
80
|
+
const [showPanel, setShowPanel] = useState(false);
|
|
81
|
+
const [panelConfig, setPanelConfig] =
|
|
82
|
+
useState<RelatedEntityConfig<any> | null>(null);
|
|
83
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
84
|
+
const [refreshTriggers, setRefreshTriggers] = useState<{
|
|
85
|
+
[key: string]: number;
|
|
86
|
+
}>({});
|
|
87
|
+
|
|
88
|
+
// Load related entities for a specific tab
|
|
89
|
+
const loadRelatedEntities = useCallback(
|
|
90
|
+
async (config: RelatedEntityConfig<any>) => {
|
|
91
|
+
if (!apiService) {
|
|
92
|
+
setError('API service not available');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
setLoading((prev) => ({ ...prev, [config.key]: true }));
|
|
98
|
+
setError(null);
|
|
99
|
+
Logger.log(`Loading ${config.title}...`);
|
|
100
|
+
|
|
101
|
+
const parentId =
|
|
102
|
+
(parentEntity as any).id ||
|
|
103
|
+
(parentEntity as any)[
|
|
104
|
+
Object.keys(parentEntity).find((key) => key.includes('id')) || ''
|
|
105
|
+
];
|
|
106
|
+
const fetchXml = config.fetchXmlTemplate.replace(
|
|
107
|
+
'{{parentId}}',
|
|
108
|
+
parentId
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const result = await apiService.retrieveMultipleRecords(
|
|
112
|
+
config.entityLogicalName,
|
|
113
|
+
fetchXml
|
|
114
|
+
);
|
|
115
|
+
const entities = (result.entities || []).map(
|
|
116
|
+
(data: any) => new config.entityClass(data)
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
setRelatedData((prev) => ({ ...prev, [config.key]: entities }));
|
|
120
|
+
setFilteredData((prev) => ({ ...prev, [config.key]: entities }));
|
|
121
|
+
|
|
122
|
+
Logger.log(`Loaded ${entities.length} ${config.title}`);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
const errorMessage =
|
|
125
|
+
err instanceof Error ? err.message : `Failed to load ${config.title}`;
|
|
126
|
+
setError(errorMessage);
|
|
127
|
+
Logger.error(
|
|
128
|
+
`Error loading ${config.title}:`,
|
|
129
|
+
'RelatedEntitiesPage',
|
|
130
|
+
err
|
|
131
|
+
);
|
|
132
|
+
} finally {
|
|
133
|
+
setLoading((prev) => ({ ...prev, [config.key]: false }));
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
[apiService, parentEntity]
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Load data when tab changes or refresh triggers
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
const config = relatedEntityConfigs.find((c) => c.key === selectedTab);
|
|
142
|
+
if (config) {
|
|
143
|
+
loadRelatedEntities(config);
|
|
144
|
+
}
|
|
145
|
+
}, [selectedTab, relatedEntityConfigs, loadRelatedEntities, refreshTriggers]);
|
|
146
|
+
|
|
147
|
+
// Handle search
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!enableSearch) return;
|
|
150
|
+
|
|
151
|
+
const newFilteredData = { ...relatedData };
|
|
152
|
+
|
|
153
|
+
if (searchQuery.trim()) {
|
|
154
|
+
for (const [key, entities] of Object.entries(relatedData)) {
|
|
155
|
+
newFilteredData[key] = entities.filter((entity) =>
|
|
156
|
+
Object.values(entity).some(
|
|
157
|
+
(value) =>
|
|
158
|
+
typeof value === 'string' &&
|
|
159
|
+
value.toLowerCase().includes(searchQuery.toLowerCase())
|
|
160
|
+
)
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
setFilteredData(newFilteredData);
|
|
166
|
+
}, [relatedData, searchQuery, enableSearch]);
|
|
167
|
+
|
|
168
|
+
// Handle item selection
|
|
169
|
+
const handleItemSelection = (config: RelatedEntityConfig<any>, item: any) => {
|
|
170
|
+
setSelectedItems((prev) => ({ ...prev, [config.key]: item }));
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Handle refresh
|
|
174
|
+
const handleRefresh = (configKey: string) => {
|
|
175
|
+
setRefreshTriggers((prev) => ({
|
|
176
|
+
...prev,
|
|
177
|
+
[configKey]: (prev[configKey] || 0) + 1,
|
|
178
|
+
}));
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Handle new entity
|
|
182
|
+
const handleNew = (config: RelatedEntityConfig<any>) => {
|
|
183
|
+
setPanelConfig(config);
|
|
184
|
+
setSelectedItems((prev) => ({ ...prev, [config.key]: null }));
|
|
185
|
+
setIsEditing(false);
|
|
186
|
+
setShowPanel(true);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Handle edit entity
|
|
190
|
+
const handleEdit = (config: RelatedEntityConfig<any>) => {
|
|
191
|
+
const selectedItem = selectedItems[config.key];
|
|
192
|
+
if (selectedItem) {
|
|
193
|
+
setPanelConfig(config);
|
|
194
|
+
setIsEditing(true);
|
|
195
|
+
setShowPanel(true);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Handle delete entity
|
|
200
|
+
const handleDelete = async (config: RelatedEntityConfig<any>) => {
|
|
201
|
+
const selectedItem = selectedItems[config.key];
|
|
202
|
+
if (!selectedItem || !apiService) {
|
|
203
|
+
setError('Selected item or API service not available');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
// Use the entity class static method or apiService directly
|
|
209
|
+
if (typeof selectedItem.delete === 'function') {
|
|
210
|
+
await selectedItem.delete(apiService);
|
|
211
|
+
} else {
|
|
212
|
+
// Fallback to direct API call
|
|
213
|
+
const entityId =
|
|
214
|
+
selectedItem.id || selectedItem[`${config.entityLogicalName}id`];
|
|
215
|
+
if (entityId) {
|
|
216
|
+
await apiService.deleteRecord(config.entityLogicalName, entityId);
|
|
217
|
+
} else {
|
|
218
|
+
throw new Error('Could not determine entity ID for deletion');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
setSelectedItems((prev) => ({ ...prev, [config.key]: null }));
|
|
223
|
+
handleRefresh(config.key);
|
|
224
|
+
Logger.log(`${config.title} item deleted successfully`);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
const errorMessage =
|
|
227
|
+
err instanceof Error
|
|
228
|
+
? err.message
|
|
229
|
+
: `Failed to delete ${config.title} item`;
|
|
230
|
+
setError(errorMessage);
|
|
231
|
+
Logger.error(
|
|
232
|
+
`Error deleting ${config.title} item:`,
|
|
233
|
+
'RelatedEntitiesPage',
|
|
234
|
+
err
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Handle save
|
|
240
|
+
const handleSave = async (entity: any) => {
|
|
241
|
+
if (!panelConfig || !apiService) {
|
|
242
|
+
setError('Panel configuration or API service not available');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
if (isEditing) {
|
|
248
|
+
// Update existing entity
|
|
249
|
+
if (typeof entity.update === 'function') {
|
|
250
|
+
await entity.update(apiService);
|
|
251
|
+
} else {
|
|
252
|
+
// Fallback to direct API call
|
|
253
|
+
const entityId =
|
|
254
|
+
entity.id || entity[`${panelConfig.entityLogicalName}id`];
|
|
255
|
+
if (entityId) {
|
|
256
|
+
const entityData = { ...entity };
|
|
257
|
+
delete entityData.id; // Remove id from update data
|
|
258
|
+
await apiService.updateRecord(
|
|
259
|
+
panelConfig.entityLogicalName,
|
|
260
|
+
entityId,
|
|
261
|
+
entityData
|
|
262
|
+
);
|
|
263
|
+
} else {
|
|
264
|
+
throw new Error('Could not determine entity ID for update');
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
// Create new entity
|
|
269
|
+
const parentId =
|
|
270
|
+
(parentEntity as any).id ||
|
|
271
|
+
(parentEntity as any)[
|
|
272
|
+
Object.keys(parentEntity).find((key) => key.includes('id')) || ''
|
|
273
|
+
];
|
|
274
|
+
entity[panelConfig.relationshipField] = parentId;
|
|
275
|
+
|
|
276
|
+
if (typeof entity.constructor.create === 'function') {
|
|
277
|
+
await entity.constructor.create(apiService, entity);
|
|
278
|
+
} else {
|
|
279
|
+
// Fallback to direct API call
|
|
280
|
+
await apiService.createRecord(panelConfig.entityLogicalName, entity);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
setShowPanel(false);
|
|
285
|
+
setPanelConfig(null);
|
|
286
|
+
setIsEditing(false);
|
|
287
|
+
handleRefresh(panelConfig.key);
|
|
288
|
+
|
|
289
|
+
Logger.log(`${panelConfig.title} saved successfully`);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
const errorMessage =
|
|
292
|
+
err instanceof Error
|
|
293
|
+
? err.message
|
|
294
|
+
: `Failed to save ${panelConfig.title}`;
|
|
295
|
+
setError(errorMessage);
|
|
296
|
+
Logger.error(
|
|
297
|
+
`Error saving ${panelConfig.title}:`,
|
|
298
|
+
'RelatedEntitiesPage',
|
|
299
|
+
err
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// Handle panel dismiss
|
|
305
|
+
const handlePanelDismiss = () => {
|
|
306
|
+
setShowPanel(false);
|
|
307
|
+
setPanelConfig(null);
|
|
308
|
+
setIsEditing(false);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// Get current config and data
|
|
312
|
+
const currentConfig = relatedEntityConfigs.find((c) => c.key === selectedTab);
|
|
313
|
+
const currentData = filteredData[selectedTab] || [];
|
|
314
|
+
const currentSelectedItem = selectedItems[selectedTab];
|
|
315
|
+
const isCurrentLoading = loading[selectedTab] || false;
|
|
316
|
+
|
|
317
|
+
// Create command bar for current tab
|
|
318
|
+
const createCommandBar = (
|
|
319
|
+
config: RelatedEntityConfig<any>
|
|
320
|
+
): ICommandBarItemProps[] => {
|
|
321
|
+
const items: ICommandBarItemProps[] = [
|
|
322
|
+
{
|
|
323
|
+
key: 'refresh',
|
|
324
|
+
text: 'Refresh',
|
|
325
|
+
iconProps: { iconName: 'Refresh' },
|
|
326
|
+
onClick: () => handleRefresh(config.key),
|
|
327
|
+
},
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
if (config.allowCreate !== false) {
|
|
331
|
+
items.unshift({
|
|
332
|
+
key: 'new',
|
|
333
|
+
text: `New ${config.title}`,
|
|
334
|
+
iconProps: { iconName: 'Add' },
|
|
335
|
+
onClick: () => handleNew(config),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (config.allowEdit !== false && currentSelectedItem) {
|
|
340
|
+
items.push({
|
|
341
|
+
key: 'edit',
|
|
342
|
+
text: 'Edit',
|
|
343
|
+
iconProps: { iconName: 'Edit' },
|
|
344
|
+
onClick: () => handleEdit(config),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (config.allowDelete !== false && currentSelectedItem) {
|
|
349
|
+
items.push({
|
|
350
|
+
key: 'delete',
|
|
351
|
+
text: 'Delete',
|
|
352
|
+
iconProps: { iconName: 'Delete' },
|
|
353
|
+
onClick: () => {
|
|
354
|
+
handleDelete(config);
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return [...items, ...(config.customCommands || [])];
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// Enhanced columns with click handlers
|
|
363
|
+
const createEnhancedColumns = (
|
|
364
|
+
config: RelatedEntityConfig<any>
|
|
365
|
+
): IColumn[] => {
|
|
366
|
+
return config.columns.map((column) => ({
|
|
367
|
+
...column,
|
|
368
|
+
onRender:
|
|
369
|
+
column.onRender ||
|
|
370
|
+
((item: any) => {
|
|
371
|
+
const value = item[column.fieldName || ''];
|
|
372
|
+
if (column.key === config.columns[0].key) {
|
|
373
|
+
// Make first column clickable
|
|
374
|
+
return (
|
|
375
|
+
<span
|
|
376
|
+
style={{ color: '#0078d4', cursor: 'pointer', fontWeight: 500 }}
|
|
377
|
+
onClick={() => handleItemSelection(config, item)}
|
|
378
|
+
>
|
|
379
|
+
{value || ''}
|
|
380
|
+
</span>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return <span>{value || ''}</span>;
|
|
384
|
+
}),
|
|
385
|
+
}));
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// Default breadcrumb
|
|
389
|
+
const defaultBreadcrumbItems: IBreadcrumbItem[] = [
|
|
390
|
+
{ text: parentTitle, key: 'parent', onClick: onBack },
|
|
391
|
+
{ text: 'Related Records', key: 'related', isCurrentItem: true },
|
|
392
|
+
];
|
|
393
|
+
|
|
394
|
+
const finalBreadcrumbItems =
|
|
395
|
+
breadcrumbItems.length > 0 ? breadcrumbItems : defaultBreadcrumbItems;
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<div style={{ padding: '20px', maxWidth: '100%' }}>
|
|
399
|
+
<Stack tokens={{ childrenGap: 20 }}>
|
|
400
|
+
{/* Breadcrumb */}
|
|
401
|
+
{onBack && <Breadcrumb items={finalBreadcrumbItems} />}
|
|
402
|
+
|
|
403
|
+
{/* Header */}
|
|
404
|
+
<Stack>
|
|
405
|
+
<Text variant="xxLarge" style={{ fontWeight: 600, color: '#323130' }}>
|
|
406
|
+
{(parentEntity as any).name || parentTitle} - Related Records
|
|
407
|
+
</Text>
|
|
408
|
+
<Text variant="medium" style={{ color: '#605e5c' }}>
|
|
409
|
+
Manage related entities and relationships
|
|
410
|
+
</Text>
|
|
411
|
+
</Stack>
|
|
412
|
+
|
|
413
|
+
{/* Error Message */}
|
|
414
|
+
{error && (
|
|
415
|
+
<MessageBar
|
|
416
|
+
messageBarType={MessageBarType.error}
|
|
417
|
+
onDismiss={() => setError(null)}
|
|
418
|
+
>
|
|
419
|
+
{error}
|
|
420
|
+
</MessageBar>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
{/* Search */}
|
|
424
|
+
{enableSearch && (
|
|
425
|
+
<SearchBox
|
|
426
|
+
placeholder="Search related records..."
|
|
427
|
+
value={searchQuery}
|
|
428
|
+
onChange={(_, newValue) => setSearchQuery(newValue || '')}
|
|
429
|
+
style={{ maxWidth: 400 }}
|
|
430
|
+
/>
|
|
431
|
+
)}
|
|
432
|
+
|
|
433
|
+
{/* Tabs and Content */}
|
|
434
|
+
<Pivot
|
|
435
|
+
selectedKey={selectedTab}
|
|
436
|
+
onLinkClick={(item) => setSelectedTab(item?.props.itemKey || '')}
|
|
437
|
+
>
|
|
438
|
+
{relatedEntityConfigs.map((config) => (
|
|
439
|
+
<PivotItem
|
|
440
|
+
key={config.key}
|
|
441
|
+
headerText={`${config.title} (${(filteredData[config.key] || []).length})`}
|
|
442
|
+
itemKey={config.key}
|
|
443
|
+
>
|
|
444
|
+
<Stack tokens={{ childrenGap: 16 }} style={{ paddingTop: 20 }}>
|
|
445
|
+
{/* Command Bar */}
|
|
446
|
+
<CommandBar items={createCommandBar(config)} />
|
|
447
|
+
|
|
448
|
+
{/* Content */}
|
|
449
|
+
{isCurrentLoading ? (
|
|
450
|
+
<div
|
|
451
|
+
style={{
|
|
452
|
+
display: 'flex',
|
|
453
|
+
justifyContent: 'center',
|
|
454
|
+
alignItems: 'center',
|
|
455
|
+
minHeight: 200,
|
|
456
|
+
flexDirection: 'column',
|
|
457
|
+
}}
|
|
458
|
+
>
|
|
459
|
+
<Spinner
|
|
460
|
+
size={SpinnerSize.large}
|
|
461
|
+
label={`Loading ${config.title}...`}
|
|
462
|
+
/>
|
|
463
|
+
</div>
|
|
464
|
+
) : currentData.length === 0 ? (
|
|
465
|
+
<div
|
|
466
|
+
style={{
|
|
467
|
+
textAlign: 'center',
|
|
468
|
+
padding: '40px 20px',
|
|
469
|
+
color: '#605e5c',
|
|
470
|
+
border: '1px solid #edebe9',
|
|
471
|
+
borderRadius: 4,
|
|
472
|
+
}}
|
|
473
|
+
>
|
|
474
|
+
<div
|
|
475
|
+
style={{
|
|
476
|
+
fontSize: 48,
|
|
477
|
+
marginBottom: 16,
|
|
478
|
+
color: '#a19f9d',
|
|
479
|
+
}}
|
|
480
|
+
>
|
|
481
|
+
📋
|
|
482
|
+
</div>
|
|
483
|
+
<Text
|
|
484
|
+
variant="large"
|
|
485
|
+
style={{
|
|
486
|
+
fontWeight: 600,
|
|
487
|
+
marginBottom: 8,
|
|
488
|
+
color: '#323130',
|
|
489
|
+
}}
|
|
490
|
+
>
|
|
491
|
+
{searchQuery
|
|
492
|
+
? 'No matching records found'
|
|
493
|
+
: `No ${config.title} found`}
|
|
494
|
+
</Text>
|
|
495
|
+
<Text variant="medium">
|
|
496
|
+
{searchQuery
|
|
497
|
+
? 'Try adjusting your search criteria'
|
|
498
|
+
: config.allowCreate !== false
|
|
499
|
+
? `Click "New ${config.title}" to get started`
|
|
500
|
+
: 'No records available'}
|
|
501
|
+
</Text>
|
|
502
|
+
</div>
|
|
503
|
+
) : (
|
|
504
|
+
<DetailsList
|
|
505
|
+
items={currentData}
|
|
506
|
+
columns={createEnhancedColumns(config)}
|
|
507
|
+
setKey={config.key}
|
|
508
|
+
layoutMode={0}
|
|
509
|
+
selectionMode={SelectionMode.single}
|
|
510
|
+
onActiveItemChanged={(item) =>
|
|
511
|
+
handleItemSelection(config, item)
|
|
512
|
+
}
|
|
513
|
+
isHeaderVisible={true}
|
|
514
|
+
/>
|
|
515
|
+
)}
|
|
516
|
+
</Stack>
|
|
517
|
+
</PivotItem>
|
|
518
|
+
))}
|
|
519
|
+
</Pivot>
|
|
520
|
+
|
|
521
|
+
{/* Form Panel */}
|
|
522
|
+
{panelConfig && (
|
|
523
|
+
<Panel
|
|
524
|
+
headerText={
|
|
525
|
+
isEditing
|
|
526
|
+
? `Edit ${panelConfig.title}`
|
|
527
|
+
: `New ${panelConfig.title}`
|
|
528
|
+
}
|
|
529
|
+
isOpen={showPanel}
|
|
530
|
+
onDismiss={handlePanelDismiss}
|
|
531
|
+
type={PanelType.medium}
|
|
532
|
+
closeButtonAriaLabel="Close"
|
|
533
|
+
>
|
|
534
|
+
<div style={{ paddingTop: 20 }}>
|
|
535
|
+
{panelConfig.renderForm ? (
|
|
536
|
+
panelConfig.renderForm(
|
|
537
|
+
isEditing ? selectedItems[panelConfig.key] : null,
|
|
538
|
+
handleSave,
|
|
539
|
+
handlePanelDismiss
|
|
540
|
+
)
|
|
541
|
+
) : (
|
|
542
|
+
<div
|
|
543
|
+
style={{ padding: 20, textAlign: 'center', color: '#605e5c' }}
|
|
544
|
+
>
|
|
545
|
+
<Text>Form not configured for {panelConfig.title}</Text>
|
|
546
|
+
</div>
|
|
547
|
+
)}
|
|
548
|
+
</div>
|
|
549
|
+
</Panel>
|
|
550
|
+
)}
|
|
551
|
+
</Stack>
|
|
552
|
+
</div>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Helper function to create related entity FetchXML
|
|
557
|
+
export function createRelatedEntityFetchXml(
|
|
558
|
+
entityName: string,
|
|
559
|
+
attributes: string[],
|
|
560
|
+
relationshipField: string,
|
|
561
|
+
orderBy?: string
|
|
562
|
+
): string {
|
|
563
|
+
const attributeElements = attributes
|
|
564
|
+
.map((attr) => `<attribute name="${attr}" />`)
|
|
565
|
+
.join('\n ');
|
|
566
|
+
const orderElement = orderBy ? `<order attribute="${orderBy}" />` : '';
|
|
567
|
+
|
|
568
|
+
return `
|
|
569
|
+
<fetch>
|
|
570
|
+
<entity name="${entityName}">
|
|
571
|
+
${attributeElements}
|
|
572
|
+
${orderElement}
|
|
573
|
+
<filter>
|
|
574
|
+
<condition attribute="${relationshipField}" operator="eq" value="{{parentId}}" />
|
|
575
|
+
</filter>
|
|
576
|
+
</entity>
|
|
577
|
+
</fetch>`;
|
|
578
|
+
}
|