@khester/create-dynamics-app 2.0.0 → 2.2.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/README.md +28 -0
- package/dist/artifacts/registry.d.ts +4 -3
- package/dist/artifacts/registry.d.ts.map +1 -1
- package/dist/artifacts/registry.js +145 -11
- package/dist/artifacts/registry.js.map +1 -1
- package/dist/artifacts/types.d.ts +10 -1
- package/dist/artifacts/types.d.ts.map +1 -1
- package/dist/index.js +19 -2
- package/dist/index.js.map +1 -1
- package/dist/injectDevTools.d.ts.map +1 -1
- package/dist/injectDevTools.js +4 -2
- package/dist/injectDevTools.js.map +1 -1
- package/dist/scaffold.d.ts +23 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +27 -1
- package/dist/scaffold.js.map +1 -1
- package/package.json +3 -2
- package/templates/grid-starter/ARCHITECTURE.md +66 -0
- package/templates/grid-starter/README.md +122 -0
- package/templates/grid-starter/env.example +16 -0
- package/templates/grid-starter/gitignore +6 -0
- package/templates/grid-starter/index.html +16 -0
- package/templates/grid-starter/package.json +39 -0
- package/templates/grid-starter/src/App.tsx +23 -0
- package/templates/grid-starter/src/core/services/FetchApiService.ts +117 -0
- package/templates/grid-starter/src/core/services/IApiService.ts +37 -0
- package/templates/grid-starter/src/core/services/MockApiService.ts +72 -0
- package/templates/grid-starter/src/core/services/ServiceFactory.ts +58 -0
- package/templates/grid-starter/src/core/services/XrmApiService.ts +135 -0
- package/templates/grid-starter/src/core/services/crudLogging.ts +52 -0
- package/templates/grid-starter/src/dev-tools/DevPanel.tsx +239 -0
- package/templates/grid-starter/src/grid/GridPage.tsx +119 -0
- package/templates/grid-starter/src/index.tsx +18 -0
- package/templates/grid-starter/src/vite-env.d.ts +15 -0
- package/templates/grid-starter/tools/deploy/deploy-webresource.cjs +117 -0
- package/templates/grid-starter/tsconfig.json +19 -0
- package/templates/grid-starter/vite.config.ts +76 -0
- package/templates/pcf-field/_variants/ValueInput.boolean.tsx +2 -0
- package/templates/pcf-field/_variants/ValueInput.date.tsx +2 -0
- package/templates/pcf-field/_variants/ValueInput.number.tsx +2 -0
- package/templates/pcf-field/_variants/ValueInput.optionset.tsx +77 -0
- package/templates/pcf-field/_variants/ValueInput.text.tsx +2 -0
- package/templates/pcf-field/index.ts +1 -1
- package/templates/pcf-field/package.json +3 -1
- package/templates/pcf-field/{{componentName}}Component.tsx +2 -0
- package/templates/react-custom-page/ARCHITECTURE.md +75 -0
- package/templates/react-custom-page/README.md +74 -568
- package/templates/react-custom-page/env.example +16 -0
- package/templates/react-custom-page/gitignore +1 -0
- package/templates/react-custom-page/index.html +16 -0
- package/templates/react-custom-page/package.json +21 -49
- package/templates/react-custom-page/src/App.tsx +26 -0
- package/templates/react-custom-page/src/core/recordContext.test.ts +30 -0
- package/templates/react-custom-page/src/core/recordContext.ts +51 -0
- package/templates/react-custom-page/src/core/services/FetchApiService.ts +117 -0
- package/templates/react-custom-page/src/core/services/IApiService.ts +37 -0
- package/templates/react-custom-page/src/core/services/MockApiService.ts +73 -0
- package/templates/react-custom-page/src/core/services/ServiceFactory.ts +58 -0
- package/templates/react-custom-page/src/core/services/XrmApiService.ts +135 -0
- package/templates/react-custom-page/src/core/services/crudLogging.ts +52 -0
- package/templates/react-custom-page/src/dev-tools/DevPanel.tsx +238 -0
- package/templates/react-custom-page/src/domain/diff.test.ts +87 -0
- package/templates/react-custom-page/src/domain/diff.ts +38 -0
- package/templates/react-custom-page/src/example/ExamplePage.tsx +140 -0
- package/templates/react-custom-page/src/example/exampleError.ts +36 -0
- package/templates/react-custom-page/src/example/hooks/useExampleData.ts +40 -0
- package/templates/react-custom-page/src/example/hooks/useExampleForm.ts +99 -0
- package/templates/react-custom-page/src/example/mappers/accountMapper.test.ts +38 -0
- package/templates/react-custom-page/src/example/mappers/accountMapper.ts +55 -0
- package/templates/react-custom-page/src/example/models/Account.ts +74 -0
- package/templates/react-custom-page/src/index.tsx +18 -128
- package/templates/react-custom-page/src/vite-env.d.ts +15 -0
- package/templates/react-custom-page/tools/deploy/deploy-webresource.cjs +117 -0
- package/templates/react-custom-page/tsconfig.json +12 -22
- package/templates/react-custom-page/vite.config.ts +76 -0
- package/templates/starter-page/README.md +38 -0
- package/templates/starter-page/_variants/App.dashboard.v8.tsx +46 -0
- package/templates/starter-page/_variants/App.form.v8.tsx +59 -0
- package/templates/starter-page/_variants/App.master-detail.v8.tsx +61 -0
- package/templates/starter-page/_variants/App.panel.v8.tsx +99 -0
- package/templates/starter-page/gitignore +5 -0
- package/templates/starter-page/package.json +27 -0
- package/templates/starter-page/public/index.html +11 -0
- package/templates/starter-page/src/index.tsx +10 -0
- package/templates/starter-page/src/services/dataverse.ts +30 -0
- package/templates/starter-page/tsconfig.json +15 -0
- package/templates/starter-page/webpack.config.js +17 -0
- package/templates/react-custom-page/deployment/README.md +0 -484
- package/templates/react-custom-page/docs/ARCHITECTURE_OVERVIEW.md +0 -506
- package/templates/react-custom-page/docs/BEST_PRACTICES.md +0 -723
- package/templates/react-custom-page/docs/MIGRATION_GUIDE.md +0 -447
- package/templates/react-custom-page/public/index.html +0 -15
- package/templates/react-custom-page/scripts/custom-build.js +0 -255
- package/templates/react-custom-page/src/components/AccountForm.css +0 -71
- package/templates/react-custom-page/src/components/AccountForm.tsx +0 -541
- package/templates/react-custom-page/src/components/AccountManagement.css +0 -86
- package/templates/react-custom-page/src/components/AccountManagement.tsx +0 -370
- package/templates/react-custom-page/src/components/ContactForm.css +0 -48
- package/templates/react-custom-page/src/components/ContactForm.tsx +0 -327
- package/templates/react-custom-page/src/components/ContactManagement.css +0 -86
- package/templates/react-custom-page/src/components/ContactManagement.tsx +0 -357
- package/templates/react-custom-page/src/components/Logging/LogDialog.tsx +0 -291
- package/templates/react-custom-page/src/components/Logging/LoggingContext.tsx +0 -166
- package/templates/react-custom-page/src/components/Logging/LoggingDebugPanel.css +0 -192
- package/templates/react-custom-page/src/components/Logging/LoggingDebugPanel.tsx +0 -177
- package/templates/react-custom-page/src/components/Logging/LoggingProvider.tsx +0 -3
- package/templates/react-custom-page/src/components/Logging/logger.ts +0 -193
- package/templates/react-custom-page/src/constants/account.ts +0 -410
- package/templates/react-custom-page/src/constants/contact.ts +0 -362
- package/templates/react-custom-page/src/models/Account.ts +0 -480
- package/templates/react-custom-page/src/models/BaseEntity.ts +0 -204
- package/templates/react-custom-page/src/models/Contact.ts +0 -580
- package/templates/react-custom-page/src/pcf/ContactControlWrapper.tsx +0 -107
- package/templates/react-custom-page/src/pcf/MultiEntityControlWrapper.tsx +0 -205
- package/templates/react-custom-page/src/providers/DynamicsProvider.tsx +0 -353
- package/templates/react-custom-page/src/services/MockApiService.ts +0 -260
- package/templates/react-custom-page/src/services/ServiceFactory.ts +0 -65
- package/templates/react-custom-page/src/services/XrmApiService.ts +0 -213
- package/templates/react-custom-page/src/styles/index.css +0 -171
- package/templates/react-custom-page/tools/metadata-sync/index.js +0 -152
- package/templates/react-custom-page/webpack.config.js +0 -57
- /package/templates/_shared/dev-tools/auth/{get-token.js → get-token.cjs} +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { IApiService } from "../../core/services/IApiService";
|
|
3
|
+
import { Account, IAccount } from "../models/Account";
|
|
4
|
+
import {
|
|
5
|
+
AccountForm,
|
|
6
|
+
AccountFormField,
|
|
7
|
+
ACCOUNT_FORM_FIELDS,
|
|
8
|
+
toFormFields,
|
|
9
|
+
toUpdatePayload,
|
|
10
|
+
} from "../mappers/accountMapper";
|
|
11
|
+
import { changedKeys, hasChanged } from "../../domain/diff";
|
|
12
|
+
import { normalizeDataverseError } from "../exampleError";
|
|
13
|
+
|
|
14
|
+
const EMPTY: AccountForm = {
|
|
15
|
+
name: "",
|
|
16
|
+
accountNumber: "",
|
|
17
|
+
phone: "",
|
|
18
|
+
email: "",
|
|
19
|
+
website: "",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface ExampleFormState {
|
|
23
|
+
form: AccountForm;
|
|
24
|
+
isDirty: boolean;
|
|
25
|
+
isSaving: boolean;
|
|
26
|
+
saveError: string | null;
|
|
27
|
+
saveSucceeded: boolean;
|
|
28
|
+
setField: (field: AccountFormField, value: string) => void;
|
|
29
|
+
save: () => void;
|
|
30
|
+
reset: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* View-model for the account form: holds editable state + a baseline snapshot,
|
|
35
|
+
* computes dirtiness via the pure `diff` helpers, and persists only the changed
|
|
36
|
+
* fields. The component renders this state — it owns no business logic.
|
|
37
|
+
*/
|
|
38
|
+
export function useExampleForm(
|
|
39
|
+
api: IApiService,
|
|
40
|
+
account: IAccount | null,
|
|
41
|
+
): ExampleFormState {
|
|
42
|
+
const [form, setForm] = useState<AccountForm>(EMPTY);
|
|
43
|
+
const initialRef = useRef<AccountForm | null>(null);
|
|
44
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
45
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
46
|
+
const [saveSucceeded, setSaveSucceeded] = useState(false);
|
|
47
|
+
|
|
48
|
+
// Re-seed the form (and the baseline) whenever a freshly-loaded account arrives.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!account) return;
|
|
51
|
+
const fields = toFormFields(account);
|
|
52
|
+
setForm(fields);
|
|
53
|
+
initialRef.current = fields;
|
|
54
|
+
}, [account]);
|
|
55
|
+
|
|
56
|
+
const setField = useCallback((field: AccountFormField, value: string) => {
|
|
57
|
+
setForm((prev) => ({ ...prev, [field]: value }));
|
|
58
|
+
setSaveSucceeded(false);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const isDirty = hasChanged(form, initialRef.current, ACCOUNT_FORM_FIELDS);
|
|
62
|
+
|
|
63
|
+
const save = useCallback(() => {
|
|
64
|
+
if (!account) return;
|
|
65
|
+
const changed = changedKeys(form, initialRef.current, ACCOUNT_FORM_FIELDS);
|
|
66
|
+
if (changed.length === 0) return;
|
|
67
|
+
|
|
68
|
+
setIsSaving(true);
|
|
69
|
+
setSaveError(null);
|
|
70
|
+
setSaveSucceeded(false);
|
|
71
|
+
Account.update(api, account.accountId, toUpdatePayload(changed, form))
|
|
72
|
+
.then(() => {
|
|
73
|
+
initialRef.current = form;
|
|
74
|
+
setSaveSucceeded(true);
|
|
75
|
+
})
|
|
76
|
+
.catch((e) =>
|
|
77
|
+
setSaveError(normalizeDataverseError(e, "Failed to save account.")),
|
|
78
|
+
)
|
|
79
|
+
.finally(() => setIsSaving(false));
|
|
80
|
+
}, [api, account, form]);
|
|
81
|
+
|
|
82
|
+
// Revert unsaved edits back to the last loaded/saved baseline.
|
|
83
|
+
const reset = useCallback(() => {
|
|
84
|
+
if (initialRef.current) setForm(initialRef.current);
|
|
85
|
+
setSaveError(null);
|
|
86
|
+
setSaveSucceeded(false);
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
form,
|
|
91
|
+
isDirty,
|
|
92
|
+
isSaving,
|
|
93
|
+
saveError,
|
|
94
|
+
saveSucceeded,
|
|
95
|
+
setField,
|
|
96
|
+
save,
|
|
97
|
+
reset,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { toFormFields, toUpdatePayload } from "./accountMapper";
|
|
3
|
+
import type { IAccount } from "../models/Account";
|
|
4
|
+
|
|
5
|
+
const sample: IAccount = {
|
|
6
|
+
accountId: "acc-1",
|
|
7
|
+
name: "Contoso",
|
|
8
|
+
accountNumber: "ACC-1",
|
|
9
|
+
phone: "555-0100",
|
|
10
|
+
email: "a@contoso.example",
|
|
11
|
+
website: "https://contoso.example",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("toFormFields", () => {
|
|
15
|
+
it("projects the editable fields from an account", () => {
|
|
16
|
+
expect(toFormFields(sample)).toEqual({
|
|
17
|
+
name: "Contoso",
|
|
18
|
+
accountNumber: "ACC-1",
|
|
19
|
+
phone: "555-0100",
|
|
20
|
+
email: "a@contoso.example",
|
|
21
|
+
website: "https://contoso.example",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("toUpdatePayload", () => {
|
|
27
|
+
it("maps only the changed fields to their Dataverse attribute names", () => {
|
|
28
|
+
const form = { ...toFormFields(sample), name: "Fabrikam", phone: "999" };
|
|
29
|
+
expect(toUpdatePayload(["name", "phone"], form)).toEqual({
|
|
30
|
+
name: "Fabrikam",
|
|
31
|
+
telephone1: "999",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns an empty payload when nothing changed", () => {
|
|
36
|
+
expect(toUpdatePayload([], toFormFields(sample))).toEqual({});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { IAccount } from "../models/Account";
|
|
2
|
+
|
|
3
|
+
// Pure translation between the Dataverse-shaped IAccount and the form's editable
|
|
4
|
+
// fields. Keeping it pure makes it the cheapest layer to unit-test (see the
|
|
5
|
+
// adjacent .test.ts) and keeps the component free of any field-mapping logic.
|
|
6
|
+
|
|
7
|
+
export interface AccountForm {
|
|
8
|
+
name: string;
|
|
9
|
+
accountNumber: string;
|
|
10
|
+
phone: string;
|
|
11
|
+
email: string;
|
|
12
|
+
website: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type AccountFormField = keyof AccountForm;
|
|
16
|
+
|
|
17
|
+
export const ACCOUNT_FORM_FIELDS: ReadonlyArray<AccountFormField> = [
|
|
18
|
+
"name",
|
|
19
|
+
"accountNumber",
|
|
20
|
+
"phone",
|
|
21
|
+
"email",
|
|
22
|
+
"website",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// Form field → Dataverse logical attribute name (for the update payload).
|
|
26
|
+
const FIELD_TO_ATTR: Record<AccountFormField, string> = {
|
|
27
|
+
name: "name",
|
|
28
|
+
accountNumber: "accountnumber",
|
|
29
|
+
phone: "telephone1",
|
|
30
|
+
email: "emailaddress1",
|
|
31
|
+
website: "websiteurl",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Project an account into the flat, editable form shape. */
|
|
35
|
+
export function toFormFields(account: IAccount): AccountForm {
|
|
36
|
+
return {
|
|
37
|
+
name: account.name,
|
|
38
|
+
accountNumber: account.accountNumber,
|
|
39
|
+
phone: account.phone,
|
|
40
|
+
email: account.email,
|
|
41
|
+
website: account.website,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Build a Dataverse update payload from only the changed form fields. */
|
|
46
|
+
export function toUpdatePayload(
|
|
47
|
+
changed: ReadonlyArray<AccountFormField>,
|
|
48
|
+
form: AccountForm,
|
|
49
|
+
): Record<string, string> {
|
|
50
|
+
const payload: Record<string, string> = {};
|
|
51
|
+
for (const field of changed) {
|
|
52
|
+
payload[FIELD_TO_ATTR[field]] = form[field];
|
|
53
|
+
}
|
|
54
|
+
return payload;
|
|
55
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { IApiService } from "../../core/services/IApiService";
|
|
2
|
+
import { logger } from "@khester/reusable-components";
|
|
3
|
+
|
|
4
|
+
// Dataverse `account` attribute logical names. Inlined here (no separate
|
|
5
|
+
// constants file) so this model is the single source of truth for the fields
|
|
6
|
+
// the page reads and writes. CRUD lives in the model — never in the component.
|
|
7
|
+
const ATTR = {
|
|
8
|
+
id: "accountid",
|
|
9
|
+
name: "name",
|
|
10
|
+
accountNumber: "accountnumber",
|
|
11
|
+
phone: "telephone1",
|
|
12
|
+
email: "emailaddress1",
|
|
13
|
+
website: "websiteurl",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
const ENTITY_SET = "accounts";
|
|
17
|
+
|
|
18
|
+
export interface IAccount {
|
|
19
|
+
accountId: string;
|
|
20
|
+
name: string;
|
|
21
|
+
accountNumber: string;
|
|
22
|
+
phone: string;
|
|
23
|
+
email: string;
|
|
24
|
+
website: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function attributes(): string {
|
|
28
|
+
return Object.values(ATTR)
|
|
29
|
+
.map((a) => `<attribute name="${a}" />`)
|
|
30
|
+
.join("");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fromDataverse(record: any): IAccount {
|
|
34
|
+
return {
|
|
35
|
+
accountId: record[ATTR.id] ?? "",
|
|
36
|
+
name: record[ATTR.name] ?? "",
|
|
37
|
+
accountNumber: record[ATTR.accountNumber] ?? "",
|
|
38
|
+
phone: record[ATTR.phone] ?? "",
|
|
39
|
+
email: record[ATTR.email] ?? "",
|
|
40
|
+
website: record[ATTR.website] ?? "",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const Account = {
|
|
45
|
+
/**
|
|
46
|
+
* Load a single account. When `id` is provided (production / token mode, e.g.
|
|
47
|
+
* from the custom-page `?id=` parameter) it fetches that record; otherwise it
|
|
48
|
+
* returns the first account (mock mode seeds exactly one).
|
|
49
|
+
*/
|
|
50
|
+
async retrieveOne(api: IApiService, id?: string): Promise<IAccount | null> {
|
|
51
|
+
const filter = id
|
|
52
|
+
? `<filter><condition attribute="${ATTR.id}" operator="eq" value="${id}" /></filter>`
|
|
53
|
+
: "";
|
|
54
|
+
const fetchXml = `<fetch top="1"><entity name="account">${attributes()}${filter}</entity></fetch>`;
|
|
55
|
+
const { entities } = await api.retrieveMultipleRecords(ENTITY_SET, fetchXml);
|
|
56
|
+
if (!entities.length) {
|
|
57
|
+
logger.warn("No account found", {
|
|
58
|
+
source: "Account.retrieveOne",
|
|
59
|
+
data: { id },
|
|
60
|
+
});
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return fromDataverse(entities[0]);
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/** Persist only the changed attributes (already mapped to logical names). */
|
|
67
|
+
update(
|
|
68
|
+
api: IApiService,
|
|
69
|
+
id: string,
|
|
70
|
+
changed: Record<string, unknown>,
|
|
71
|
+
): Promise<any> {
|
|
72
|
+
return api.updateRecord(ENTITY_SET, id, changed);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -1,128 +1,18 @@
|
|
|
1
|
-
import React
|
|
2
|
-
import { createRoot } from
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
(item: DynamicsPivotItemProps) => {
|
|
20
|
-
const newTab = item.itemKey as 'contacts' | 'accounts';
|
|
21
|
-
Logger.userAction(
|
|
22
|
-
'Tab changed in main application',
|
|
23
|
-
{ from: selectedTab, to: newTab },
|
|
24
|
-
'App.handleTabChange'
|
|
25
|
-
);
|
|
26
|
-
setSelectedTab(newTab);
|
|
27
|
-
},
|
|
28
|
-
[selectedTab]
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
const toggleDebugPanel = useCallback(() => {
|
|
32
|
-
setShowDebugPanel((prev) => !prev);
|
|
33
|
-
Logger.userAction(
|
|
34
|
-
showDebugPanel ? 'Debug panel closed' : 'Debug panel opened',
|
|
35
|
-
{},
|
|
36
|
-
'App.toggleDebugPanel'
|
|
37
|
-
);
|
|
38
|
-
}, [showDebugPanel]);
|
|
39
|
-
|
|
40
|
-
const renderContent = () => {
|
|
41
|
-
switch (selectedTab) {
|
|
42
|
-
case 'contacts':
|
|
43
|
-
return <ContactManagement />;
|
|
44
|
-
case 'accounts':
|
|
45
|
-
return <AccountManagement />;
|
|
46
|
-
default:
|
|
47
|
-
return <ContactManagement />;
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
return (
|
|
52
|
-
<LoggingProvider>
|
|
53
|
-
<DynamicsProvider>
|
|
54
|
-
<div className="app">
|
|
55
|
-
<header className="app-header">
|
|
56
|
-
<div className="app-header__content">
|
|
57
|
-
<div className="app-header__title">
|
|
58
|
-
<h1>Dynamics 365 Management Console</h1>
|
|
59
|
-
<p>Built with Dynamics UI Kit - Enhanced Template</p>
|
|
60
|
-
</div>
|
|
61
|
-
<div className="app-header__actions">
|
|
62
|
-
<button
|
|
63
|
-
className="debug-toggle-btn"
|
|
64
|
-
onClick={toggleDebugPanel}
|
|
65
|
-
title="Toggle Debug Panel"
|
|
66
|
-
>
|
|
67
|
-
🐛 Debug
|
|
68
|
-
</button>
|
|
69
|
-
</div>
|
|
70
|
-
</div>
|
|
71
|
-
</header>
|
|
72
|
-
|
|
73
|
-
<nav className="app-navigation">
|
|
74
|
-
<Pivot
|
|
75
|
-
items={[
|
|
76
|
-
{
|
|
77
|
-
headerText: 'Contacts',
|
|
78
|
-
itemKey: 'contacts',
|
|
79
|
-
itemIcon: 'Contact',
|
|
80
|
-
},
|
|
81
|
-
{
|
|
82
|
-
headerText: 'Accounts',
|
|
83
|
-
itemKey: 'accounts',
|
|
84
|
-
itemIcon: 'Building',
|
|
85
|
-
},
|
|
86
|
-
]}
|
|
87
|
-
selectedKey={selectedTab}
|
|
88
|
-
onItemClick={handleTabChange}
|
|
89
|
-
linkFormat="tabs"
|
|
90
|
-
/>
|
|
91
|
-
</nav>
|
|
92
|
-
|
|
93
|
-
<main className="app-main">{renderContent()}</main>
|
|
94
|
-
|
|
95
|
-
<footer className="app-footer">
|
|
96
|
-
<p>
|
|
97
|
-
© 2024 Your Organization. Powered by Dynamics UI Kit v1.0
|
|
98
|
-
</p>
|
|
99
|
-
</footer>
|
|
100
|
-
|
|
101
|
-
{/* Debug Panel */}
|
|
102
|
-
{showDebugPanel && (
|
|
103
|
-
<div className="debug-panel-overlay">
|
|
104
|
-
<LoggingDebugPanel onClose={() => setShowDebugPanel(false)} />
|
|
105
|
-
</div>
|
|
106
|
-
)}
|
|
107
|
-
</div>
|
|
108
|
-
</DynamicsProvider>
|
|
109
|
-
</LoggingProvider>
|
|
110
|
-
);
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
// For development/testing
|
|
114
|
-
if (typeof document !== 'undefined') {
|
|
115
|
-
const container = document.getElementById('root');
|
|
116
|
-
if (container) {
|
|
117
|
-
const root = createRoot(container);
|
|
118
|
-
root.render(<App />);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Export for PCF integration
|
|
123
|
-
export { App, ContactManagement, AccountManagement };
|
|
124
|
-
export * from './components/ContactManagement';
|
|
125
|
-
export * from './components/AccountManagement';
|
|
126
|
-
export * from './providers/DynamicsProvider';
|
|
127
|
-
export * from './pcf/ContactControlWrapper';
|
|
128
|
-
export * from './pcf/MultiEntityControlWrapper';
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { initLogging } from "@khester/reusable-components";
|
|
4
|
+
import { App } from "./App";
|
|
5
|
+
|
|
6
|
+
// Wire up the localhost-only troubleshooting hooks (window.dumpAppLogs()).
|
|
7
|
+
// No-op in a live org. Fluent's initializeIcons() is called inside the component
|
|
8
|
+
// library (via MessageBarComponent) — do NOT call it again here.
|
|
9
|
+
initLogging();
|
|
10
|
+
|
|
11
|
+
const container = document.getElementById("root");
|
|
12
|
+
if (!container) throw new Error('Root element "#root" not found');
|
|
13
|
+
|
|
14
|
+
createRoot(container).render(
|
|
15
|
+
<React.StrictMode>
|
|
16
|
+
<App />
|
|
17
|
+
</React.StrictMode>,
|
|
18
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
// Custom env keys exposed via `define` in vite.config.ts. `tsc` trusts THIS
|
|
4
|
+
// declaration (it does not evaluate `define`), so the type must match the
|
|
5
|
+
// runtime value the config injects (a boolean).
|
|
6
|
+
interface ImportMetaEnv {
|
|
7
|
+
/** True when DYNAMICS_URL is configured (token-proxy dev mode). */
|
|
8
|
+
readonly VITE_USE_PROXY: boolean;
|
|
9
|
+
/** The org URL (not the token) for the dev panel readout; "" in production. */
|
|
10
|
+
readonly VITE_DYNAMICS_URL: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ImportMeta {
|
|
14
|
+
readonly env: ImportMetaEnv;
|
|
15
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build + deploy: upload the single-file `dist/index.html` (from `npm run
|
|
4
|
+
* build:d365`) to Dataverse as an HTML web resource, then publish it.
|
|
5
|
+
*
|
|
6
|
+
* Reads DYNAMICS_URL + DYNAMICS_TOKEN from `.env` (run `npm run auth:token`
|
|
7
|
+
* first, or use `npm run deploy`, which refreshes the token for you).
|
|
8
|
+
*
|
|
9
|
+
* The web-resource unique name MUST start with a publisher prefix that exists in
|
|
10
|
+
* your org (e.g. `cr1a2_` or `new_`). Set it via WEBRESOURCE_NAME in `.env`, or:
|
|
11
|
+
* node tools/deploy/deploy-webresource.cjs --name cr1a2_/myapp/index.html --solution MySolution
|
|
12
|
+
*/
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
|
|
16
|
+
function arg(name) {
|
|
17
|
+
const i = process.argv.indexOf(name);
|
|
18
|
+
return i !== -1 ? process.argv[i + 1] : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readEnv(file) {
|
|
22
|
+
if (!fs.existsSync(file)) return {};
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
25
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
26
|
+
if (m) out[m[1]] = m[2];
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const root = process.cwd();
|
|
32
|
+
const env = readEnv(path.join(root, ".env"));
|
|
33
|
+
const url = (arg("--url") || process.env.DYNAMICS_URL || env.DYNAMICS_URL || "").replace(/\/+$/, "");
|
|
34
|
+
const token = arg("--token") || process.env.DYNAMICS_TOKEN || env.DYNAMICS_TOKEN;
|
|
35
|
+
|
|
36
|
+
let pkgName = "custompage";
|
|
37
|
+
try {
|
|
38
|
+
pkgName = require(path.join(root, "package.json")).name || pkgName;
|
|
39
|
+
} catch {
|
|
40
|
+
/* keep default */
|
|
41
|
+
}
|
|
42
|
+
const name = arg("--name") || process.env.WEBRESOURCE_NAME || env.WEBRESOURCE_NAME || `new_/${pkgName}/index.html`;
|
|
43
|
+
const solution = arg("--solution") || process.env.WEBRESOURCE_SOLUTION || env.WEBRESOURCE_SOLUTION;
|
|
44
|
+
|
|
45
|
+
if (!url || !token) {
|
|
46
|
+
console.error("✗ Missing DYNAMICS_URL / DYNAMICS_TOKEN. Run `npm run auth:token -- --url https://<org>.crm.dynamics.com` first.");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const file = path.join(root, "dist", "index.html");
|
|
51
|
+
if (!fs.existsSync(file)) {
|
|
52
|
+
console.error("✗ dist/index.html not found. Run `npm run build:d365` first.");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
const content = fs.readFileSync(file).toString("base64");
|
|
56
|
+
|
|
57
|
+
const api = `${url}/api/data/v9.2`;
|
|
58
|
+
const headers = {
|
|
59
|
+
Authorization: `Bearer ${token}`,
|
|
60
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
61
|
+
Accept: "application/json",
|
|
62
|
+
"OData-Version": "4.0",
|
|
63
|
+
"OData-MaxVersion": "4.0",
|
|
64
|
+
};
|
|
65
|
+
if (solution) headers["MSCRM.SolutionUniqueName"] = solution;
|
|
66
|
+
|
|
67
|
+
async function req(method, urlPath, body) {
|
|
68
|
+
const res = await fetch(urlPath.startsWith("http") ? urlPath : `${api}/${urlPath}`, {
|
|
69
|
+
method,
|
|
70
|
+
headers,
|
|
71
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const text = await res.text().catch(() => "");
|
|
75
|
+
throw new Error(`${method} ${urlPath} → ${res.status}: ${text}`);
|
|
76
|
+
}
|
|
77
|
+
return res;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function main() {
|
|
81
|
+
console.log(`→ Deploying "${name}" to ${url} ...`);
|
|
82
|
+
|
|
83
|
+
// 1. Upsert the web resource (find by unique name, then PATCH or POST).
|
|
84
|
+
const findRes = await req("GET", `webresourceset?$select=webresourceid&$filter=name eq '${encodeURIComponent(name)}'`);
|
|
85
|
+
const found = (await findRes.json()).value || [];
|
|
86
|
+
let id;
|
|
87
|
+
if (found.length) {
|
|
88
|
+
id = found[0].webresourceid;
|
|
89
|
+
await req("PATCH", `webresourceset(${id})`, { content });
|
|
90
|
+
console.log(`✓ Updated existing web resource (${id}).`);
|
|
91
|
+
} else {
|
|
92
|
+
const createRes = await req("POST", "webresourceset", {
|
|
93
|
+
name,
|
|
94
|
+
displayname: name,
|
|
95
|
+
webresourcetype: 1, // HTML
|
|
96
|
+
content,
|
|
97
|
+
});
|
|
98
|
+
const loc = createRes.headers.get("OData-EntityId") || "";
|
|
99
|
+
const match = /\(([^)]+)\)/.exec(loc);
|
|
100
|
+
id = match ? match[1] : null;
|
|
101
|
+
console.log(`✓ Created web resource (${id}).`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. Publish it.
|
|
105
|
+
const paramXml =
|
|
106
|
+
"<importexportxml><webresources><webresource>{" + id + "}</webresource></webresources></importexportxml>";
|
|
107
|
+
await req("POST", "PublishXml", { ParameterXml: paramXml });
|
|
108
|
+
console.log("✓ Published.");
|
|
109
|
+
|
|
110
|
+
console.log(`\nOpen it: ${url}/WebResources/${name}`);
|
|
111
|
+
console.log("Or host it on a model-driven custom page / open via Xrm.Navigation.navigateTo.");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
main().catch((e) => {
|
|
115
|
+
console.error("✗ Deploy failed:", e.message);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
});
|
|
@@ -1,29 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
|
-
"target": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"allowSyntheticDefaultImports": true,
|
|
9
|
-
"strict": true,
|
|
10
|
-
"forceConsistentCasingInFileNames": true,
|
|
11
|
-
"noFallthroughCasesInSwitch": true,
|
|
12
|
-
"module": "esnext",
|
|
13
|
-
"moduleResolution": "node",
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "bundler",
|
|
14
8
|
"resolveJsonModule": true,
|
|
15
9
|
"isolatedModules": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"strict": true,
|
|
16
14
|
"noEmit": true,
|
|
17
|
-
"jsx": "react-jsx"
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
|
+
"types": ["vite/client", "vitest/globals"]
|
|
18
17
|
},
|
|
19
|
-
"include": ["src
|
|
20
|
-
"exclude": [
|
|
21
|
-
"node_modules",
|
|
22
|
-
"build",
|
|
23
|
-
"dist",
|
|
24
|
-
"tools/**/*.template.ts",
|
|
25
|
-
"tools/**/templates/**/*.ts",
|
|
26
|
-
"**/templates/**/*",
|
|
27
|
-
"**/*.template.ts"
|
|
28
|
-
]
|
|
18
|
+
"include": ["src"]
|
|
29
19
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/// <reference types="vitest/config" />
|
|
2
|
+
import { defineConfig, loadEnv } from "vite";
|
|
3
|
+
import react from "@vitejs/plugin-react";
|
|
4
|
+
import { viteSingleFile } from "vite-plugin-singlefile";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read a single key from .env / .env.local at request time. Re-reading per
|
|
10
|
+
* request (rather than capturing it at config eval) means a refreshed
|
|
11
|
+
* `npm run auth:token` takes effect WITHOUT restarting the dev server.
|
|
12
|
+
*/
|
|
13
|
+
function readEnvValue(key: string): string | undefined {
|
|
14
|
+
for (const file of [".env.local", ".env"]) {
|
|
15
|
+
const p = path.resolve(process.cwd(), file);
|
|
16
|
+
if (!fs.existsSync(p)) continue;
|
|
17
|
+
for (const line of fs.readFileSync(p, "utf8").split(/\r?\n/)) {
|
|
18
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
19
|
+
if (m && m[1] === key) return m[2];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default defineConfig(({ mode }) => {
|
|
26
|
+
// DYNAMICS_URL / DYNAMICS_TOKEN are written to .env by `npm run auth:token`.
|
|
27
|
+
// Empty prefix loads unprefixed keys; we read ONLY the two below by name and
|
|
28
|
+
// never spread `env` into `define` (that would leak the whole environment).
|
|
29
|
+
const env = loadEnv(mode, process.cwd(), "");
|
|
30
|
+
const dynamicsUrl = env.DYNAMICS_URL;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
// Relative base — the built bundle is served from the Dataverse web-resource
|
|
34
|
+
// CDN under a long prefixed path, where absolute /assets URLs would 404.
|
|
35
|
+
base: "./",
|
|
36
|
+
plugins: [react(), ...(mode === "d365" ? [viteSingleFile()] : [])],
|
|
37
|
+
server: {
|
|
38
|
+
port: 3000,
|
|
39
|
+
// Token-proxy mode: forward /api/data/* to Dataverse and inject the bearer
|
|
40
|
+
// SERVER-SIDE, so the token is never read by or bundled into the client.
|
|
41
|
+
proxy: dynamicsUrl
|
|
42
|
+
? {
|
|
43
|
+
"/api/data": {
|
|
44
|
+
target: dynamicsUrl,
|
|
45
|
+
changeOrigin: true,
|
|
46
|
+
secure: true,
|
|
47
|
+
configure: (proxy) => {
|
|
48
|
+
proxy.on("proxyReq", (proxyReq) => {
|
|
49
|
+
const token = readEnvValue("DYNAMICS_TOKEN");
|
|
50
|
+
if (token) proxyReq.setHeader("Authorization", `Bearer ${token}`);
|
|
51
|
+
proxyReq.setHeader("OData-Version", "4.0");
|
|
52
|
+
proxyReq.setHeader("OData-MaxVersion", "4.0");
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
: undefined,
|
|
58
|
+
},
|
|
59
|
+
define: {
|
|
60
|
+
// Client flag for ServiceFactory — a real boolean (Vite stringifies it).
|
|
61
|
+
// True only when a Dataverse URL is configured (token-proxy dev mode).
|
|
62
|
+
"import.meta.env.VITE_USE_PROXY": Boolean(dynamicsUrl),
|
|
63
|
+
// The org URL (NOT the token) for the dev panel's Environment readout. Empty
|
|
64
|
+
// in a production build, so nothing leaks into a deployed web resource.
|
|
65
|
+
"import.meta.env.VITE_DYNAMICS_URL": JSON.stringify(dynamicsUrl ?? ""),
|
|
66
|
+
},
|
|
67
|
+
build: {
|
|
68
|
+
outDir: "dist",
|
|
69
|
+
},
|
|
70
|
+
test: {
|
|
71
|
+
// Pure layers only (diff, mappers) — no DOM needed.
|
|
72
|
+
globals: true,
|
|
73
|
+
include: ["src/**/*.test.ts"],
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
});
|