@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,58 @@
|
|
|
1
|
+
import { IApiService } from "./IApiService";
|
|
2
|
+
import { MockApiService } from "./MockApiService";
|
|
3
|
+
import { XrmApiService } from "./XrmApiService";
|
|
4
|
+
import { FetchApiService } from "./FetchApiService";
|
|
5
|
+
import { logger } from "@khester/reusable-components";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Single decision point for which IApiService the page talks to.
|
|
9
|
+
*
|
|
10
|
+
* Priority:
|
|
11
|
+
* 1. localhost + VITE_USE_PROXY → FetchApiService (real Dataverse via the Vite proxy)
|
|
12
|
+
* 2. localhost (no proxy) → injected mock, or MockApiService by default
|
|
13
|
+
* 3. deployed + Xrm context → XrmApiService
|
|
14
|
+
*/
|
|
15
|
+
export class ServiceFactory {
|
|
16
|
+
public static isMockEnvironment: boolean =
|
|
17
|
+
window.location.hostname === "localhost" ||
|
|
18
|
+
window.location.hostname === "127.0.0.1";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Token-proxy dev mode. `vite.config.ts` sets `import.meta.env.VITE_USE_PROXY`
|
|
22
|
+
* to a boolean — true when DYNAMICS_URL is configured — and proxies
|
|
23
|
+
* /api/data/* to Dataverse with a server-side bearer token. Truthiness check
|
|
24
|
+
* (NOT `=== 'true'`): the `define` value is a real boolean, not a string.
|
|
25
|
+
*/
|
|
26
|
+
public static isTokenMode = !!import.meta.env.VITE_USE_PROXY;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param Xrm The Xrm context (required when deployed). Resolve it from
|
|
30
|
+
* `window.parent?.Xrm ?? window.Xrm` in App.tsx.
|
|
31
|
+
* @param createMock Optional factory for a domain-specific mock. Used ONLY in
|
|
32
|
+
* mock mode (after the token-mode check), so a token session
|
|
33
|
+
* still talks to the real org. Defaults to MockApiService.
|
|
34
|
+
*/
|
|
35
|
+
public static createApiService(
|
|
36
|
+
Xrm?: any,
|
|
37
|
+
createMock?: () => IApiService,
|
|
38
|
+
): IApiService {
|
|
39
|
+
if (ServiceFactory.isMockEnvironment && ServiceFactory.isTokenMode) {
|
|
40
|
+
logger.info("Token mode — using FetchApiService via proxy", {
|
|
41
|
+
source: "ServiceFactory",
|
|
42
|
+
});
|
|
43
|
+
return new FetchApiService();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (ServiceFactory.isMockEnvironment) {
|
|
47
|
+
logger.info("Mock mode — using MockApiService", {
|
|
48
|
+
source: "ServiceFactory",
|
|
49
|
+
});
|
|
50
|
+
return (createMock ?? (() => new MockApiService()))();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!Xrm) {
|
|
54
|
+
throw new Error("Xrm object is required in a non-mock environment.");
|
|
55
|
+
}
|
|
56
|
+
return new XrmApiService(Xrm);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { IApiService } from "./IApiService";
|
|
2
|
+
import { logCrud } from "./crudLogging";
|
|
3
|
+
import { logger } from "@khester/reusable-components";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Production API service for a model-driven custom page. Every call hits the
|
|
7
|
+
* Dataverse Web API on the same origin (the hosting org authenticates the
|
|
8
|
+
* request via its session), addressing entities by their SET name (e.g.
|
|
9
|
+
* "accounts") so reads and writes use one consistent identifier.
|
|
10
|
+
*/
|
|
11
|
+
export class XrmApiService implements IApiService {
|
|
12
|
+
private Xrm: any;
|
|
13
|
+
|
|
14
|
+
constructor(Xrm: any) {
|
|
15
|
+
if (!Xrm) throw new Error("Xrm object is required");
|
|
16
|
+
this.Xrm = Xrm;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private clientUrl(): string {
|
|
20
|
+
return this.Xrm.Page.context.getClientUrl();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private headers(): HeadersInit {
|
|
24
|
+
return {
|
|
25
|
+
"OData-MaxVersion": "4.0",
|
|
26
|
+
"OData-Version": "4.0",
|
|
27
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
28
|
+
Accept: "application/json",
|
|
29
|
+
Prefer: 'odata.include-annotations="*"',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
retrieveMultipleRecords(
|
|
34
|
+
entity: string,
|
|
35
|
+
fetchXml: string,
|
|
36
|
+
): Promise<{ entities: any[] }> {
|
|
37
|
+
return logCrud(
|
|
38
|
+
{ op: "READ", entity, resultCount: (r) => r?.entities?.length },
|
|
39
|
+
async () => {
|
|
40
|
+
const url = `${this.clientUrl()}/api/data/v9.2/${entity}?fetchXml=${encodeURIComponent(fetchXml)}`;
|
|
41
|
+
const response = await fetch(url, { method: "GET", headers: this.headers() });
|
|
42
|
+
if (!response.ok) throw await response.json();
|
|
43
|
+
const data = await response.json();
|
|
44
|
+
return { entities: data.value ?? [] };
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
createRecord(entity: string, record: any): Promise<any> {
|
|
50
|
+
return logCrud({ op: "CREATE", entity, resultId: (r) => r?.id }, async () => {
|
|
51
|
+
const url = `${this.clientUrl()}/api/data/v9.2/${entity}`;
|
|
52
|
+
const response = await fetch(url, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: this.headers(),
|
|
55
|
+
body: JSON.stringify(record),
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) throw await response.json();
|
|
58
|
+
const uri = response.headers.get("OData-EntityId");
|
|
59
|
+
const match = uri ? /\(([^)]+)\)/.exec(uri) : null;
|
|
60
|
+
return { id: match ? match[1] : null };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
updateRecord(entity: string, id: string, record: any): Promise<any> {
|
|
65
|
+
return logCrud({ op: "UPDATE", entity, id }, async () => {
|
|
66
|
+
const cleanId = id.replace(/[{}]/g, "");
|
|
67
|
+
const url = `${this.clientUrl()}/api/data/v9.2/${entity}(${cleanId})`;
|
|
68
|
+
const response = await fetch(url, {
|
|
69
|
+
method: "PATCH",
|
|
70
|
+
headers: this.headers(),
|
|
71
|
+
body: JSON.stringify(record),
|
|
72
|
+
});
|
|
73
|
+
if (!response.ok) throw await response.json();
|
|
74
|
+
return { success: true };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
deleteRecord(entity: string, id: string): Promise<void> {
|
|
79
|
+
return logCrud({ op: "DELETE", entity, id }, async () => {
|
|
80
|
+
const cleanId = id.replace(/[{}]/g, "");
|
|
81
|
+
const url = `${this.clientUrl()}/api/data/v9.2/${entity}(${cleanId})`;
|
|
82
|
+
const response = await fetch(url, { method: "DELETE", headers: this.headers() });
|
|
83
|
+
if (!response.ok) throw await response.json();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
executeRequest(requestName: string, requestData: any): Promise<any> {
|
|
88
|
+
return logCrud({ op: "EXECUTE", entity: requestName }, async () => {
|
|
89
|
+
const { getMetadata: _getMetadata, ...payload } = requestData ?? {};
|
|
90
|
+
const url = `${this.clientUrl()}/api/data/v9.2/${requestName}`;
|
|
91
|
+
const response = await fetch(url, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: this.headers(),
|
|
94
|
+
body: JSON.stringify(payload),
|
|
95
|
+
});
|
|
96
|
+
if (!response.ok) throw await response.json();
|
|
97
|
+
const text = await response.text();
|
|
98
|
+
return text ? JSON.parse(text) : null;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async associateRecord(
|
|
103
|
+
entityName: string,
|
|
104
|
+
entityId: string,
|
|
105
|
+
relationshipName: string,
|
|
106
|
+
relatedEntityName: string,
|
|
107
|
+
relatedEntityId: string,
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
return logCrud(
|
|
110
|
+
{ op: "ASSOCIATE", entity: entityName, id: entityId },
|
|
111
|
+
async () => {
|
|
112
|
+
const url = `${this.clientUrl()}/api/data/v9.2/${entityName}(${entityId})/${relationshipName}/$ref`;
|
|
113
|
+
const response = await fetch(url, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: this.headers(),
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
"@odata.id": `${this.clientUrl()}/api/data/v9.2/${relatedEntityName}(${relatedEntityId})`,
|
|
118
|
+
}),
|
|
119
|
+
});
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
throw new Error(`associateRecord failed (${response.status})`);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async uploadFile(file: File): Promise<string> {
|
|
128
|
+
// Stub — wire up to your file/notes/blob strategy as needed.
|
|
129
|
+
logger.warn("uploadFile not implemented", {
|
|
130
|
+
source: "XrmApiService",
|
|
131
|
+
data: { file: file.name },
|
|
132
|
+
});
|
|
133
|
+
return "";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Binds the generic `withCrudLog` wrapper (from @khester/reusable-components) to
|
|
2
|
+
// the Dataverse error shapes thrown by the IApiService implementations, so each
|
|
3
|
+
// service method can be instrumented with a single `logCrud(meta, fn)` call.
|
|
4
|
+
//
|
|
5
|
+
// `parseServiceError` lives here (app/template code), not in the library — the
|
|
6
|
+
// library's logger stays backend-agnostic and the app injects this parser.
|
|
7
|
+
|
|
8
|
+
import { withCrudLog, type CrudMeta } from "@khester/reusable-components";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extract a numeric status + human message from the error shapes the services
|
|
12
|
+
* throw: an `Error("… 400: <body>")` whose body may embed a Dataverse error
|
|
13
|
+
* JSON blob, or a plain `json.error` object with a CDS InnerError message.
|
|
14
|
+
*/
|
|
15
|
+
function parseServiceError(err: unknown): { status?: number; message?: string } {
|
|
16
|
+
if (err && typeof err === "object" && !(err instanceof Error)) {
|
|
17
|
+
const obj = err as Record<string, unknown>;
|
|
18
|
+
const inner = obj["@Microsoft.PowerApps.CDS.InnerError.Message"];
|
|
19
|
+
const message =
|
|
20
|
+
(typeof inner === "string" && inner) ||
|
|
21
|
+
(typeof obj.message === "string" && obj.message) ||
|
|
22
|
+
undefined;
|
|
23
|
+
const status = typeof obj.status === "number" ? obj.status : undefined;
|
|
24
|
+
return { status, message: message || undefined };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (err instanceof Error) {
|
|
28
|
+
const statusMatch = err.message.match(/\b(\d{3})\b/);
|
|
29
|
+
const status = statusMatch ? Number(statusMatch[1]) : undefined;
|
|
30
|
+
|
|
31
|
+
const jsonStart = err.message.indexOf("{");
|
|
32
|
+
if (jsonStart !== -1) {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(err.message.substring(jsonStart));
|
|
35
|
+
const inner =
|
|
36
|
+
parsed?.error?.["@Microsoft.PowerApps.CDS.InnerError.Message"];
|
|
37
|
+
const message = inner || parsed?.error?.message || err.message;
|
|
38
|
+
return { status, message };
|
|
39
|
+
} catch {
|
|
40
|
+
// not JSON — fall through to the raw message
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { status, message: err.message };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { message: String(err) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Run a CRUD operation, emitting one structured `[CRUD] …` log line for it. */
|
|
50
|
+
export function logCrud<T>(meta: CrudMeta, fn: () => Promise<T>): Promise<T> {
|
|
51
|
+
return withCrudLog(meta, fn, { parseError: parseServiceError });
|
|
52
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Panel,
|
|
4
|
+
PanelType,
|
|
5
|
+
TextField,
|
|
6
|
+
PrimaryButton,
|
|
7
|
+
DefaultButton,
|
|
8
|
+
Stack,
|
|
9
|
+
Text,
|
|
10
|
+
Separator,
|
|
11
|
+
MessageBar,
|
|
12
|
+
MessageBarType,
|
|
13
|
+
Link,
|
|
14
|
+
Icon,
|
|
15
|
+
} from "@fluentui/react";
|
|
16
|
+
import { logger } from "@khester/reusable-components";
|
|
17
|
+
|
|
18
|
+
// Floating dev-tools panel for local development — a 🔧 FAB (bottom-right) that
|
|
19
|
+
// opens a Fluent Panel. Localhost-gated: it renders nothing once deployed, so a
|
|
20
|
+
// live custom page never shows it. Modeled on the original import_weights dev tool,
|
|
21
|
+
// adapted to Vite + this minimal Account example.
|
|
22
|
+
//
|
|
23
|
+
// It does NOT authenticate — "live data" is still the token-proxy env
|
|
24
|
+
// (npm run auth:token + DYNAMICS_URL). The panel surfaces the active mode and lets
|
|
25
|
+
// you set a record GUID in the URL (?id= + reload). The grid starter loads the full
|
|
26
|
+
// list and ignores ?id=; wire it into GridPage if you add a record-detail view.
|
|
27
|
+
|
|
28
|
+
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
29
|
+
const RECENT_KEY = "cda-dev-panel-recent";
|
|
30
|
+
|
|
31
|
+
const stripBraces = (s: string) => s.replace(/[{}]/g, "").trim();
|
|
32
|
+
|
|
33
|
+
function isLocalhost(): boolean {
|
|
34
|
+
return (
|
|
35
|
+
typeof window !== "undefined" &&
|
|
36
|
+
(window.location.hostname === "localhost" ||
|
|
37
|
+
window.location.hostname === "127.0.0.1")
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getRecent(): string[] {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function addRecent(id: string): string[] {
|
|
50
|
+
const next = [id, ...getRecent().filter((x) => x !== id)].slice(0, 5);
|
|
51
|
+
try {
|
|
52
|
+
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
|
|
53
|
+
} catch {
|
|
54
|
+
/* ignore */
|
|
55
|
+
}
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const fabStyle: React.CSSProperties = {
|
|
60
|
+
position: "fixed",
|
|
61
|
+
bottom: 20,
|
|
62
|
+
right: 20,
|
|
63
|
+
zIndex: 2147483000,
|
|
64
|
+
width: 48,
|
|
65
|
+
height: 48,
|
|
66
|
+
borderRadius: "50%",
|
|
67
|
+
backgroundColor: "#0078d4",
|
|
68
|
+
color: "#ffffff",
|
|
69
|
+
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.26)",
|
|
70
|
+
display: "flex",
|
|
71
|
+
alignItems: "center",
|
|
72
|
+
justifyContent: "center",
|
|
73
|
+
border: "none",
|
|
74
|
+
cursor: "pointer",
|
|
75
|
+
fontSize: 20,
|
|
76
|
+
padding: 0,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const EnvRow: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
|
80
|
+
<div
|
|
81
|
+
style={{
|
|
82
|
+
display: "flex",
|
|
83
|
+
justifyContent: "space-between",
|
|
84
|
+
gap: 12,
|
|
85
|
+
padding: "2px 0",
|
|
86
|
+
fontSize: 12,
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<span style={{ color: "#605e5c", fontWeight: 600 }}>{label}</span>
|
|
90
|
+
<span
|
|
91
|
+
title={value}
|
|
92
|
+
style={{
|
|
93
|
+
color: "#323130",
|
|
94
|
+
maxWidth: 210,
|
|
95
|
+
overflow: "hidden",
|
|
96
|
+
textOverflow: "ellipsis",
|
|
97
|
+
whiteSpace: "nowrap",
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{value}
|
|
101
|
+
</span>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
export const DevPanel: React.FC = () => {
|
|
106
|
+
const [open, setOpen] = useState(false);
|
|
107
|
+
const [recordId, setRecordId] = useState(
|
|
108
|
+
() => new URLSearchParams(window.location.search).get("id") || "",
|
|
109
|
+
);
|
|
110
|
+
const [guidError, setGuidError] = useState<string | null>(null);
|
|
111
|
+
const [recent, setRecent] = useState<string[]>(getRecent);
|
|
112
|
+
const [logs, setLogs] = useState<string | null>(null);
|
|
113
|
+
|
|
114
|
+
const mode = useMemo(
|
|
115
|
+
() =>
|
|
116
|
+
import.meta.env.VITE_USE_PROXY
|
|
117
|
+
? "Token-proxy (live Dataverse)"
|
|
118
|
+
: "Mock (in-memory)",
|
|
119
|
+
[],
|
|
120
|
+
);
|
|
121
|
+
const targetUrl = import.meta.env.VITE_DYNAMICS_URL || "";
|
|
122
|
+
|
|
123
|
+
const openRecord = useCallback((id: string) => {
|
|
124
|
+
const clean = stripBraces(id);
|
|
125
|
+
if (!GUID_RE.test(clean)) {
|
|
126
|
+
setGuidError("Invalid GUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
setRecent(addRecent(clean));
|
|
130
|
+
const url = new URL(window.location.href);
|
|
131
|
+
url.searchParams.set("id", clean);
|
|
132
|
+
window.location.href = url.toString(); // reload with ?id= (consume it in GridPage if needed)
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
// Localhost-only. (Hooks run unconditionally above this guard.)
|
|
136
|
+
if (!isLocalhost()) return null;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<>
|
|
140
|
+
<button
|
|
141
|
+
style={fabStyle}
|
|
142
|
+
title="Dev Tools"
|
|
143
|
+
aria-label="Open dev tools panel"
|
|
144
|
+
onClick={() => setOpen(true)}
|
|
145
|
+
>
|
|
146
|
+
🔧
|
|
147
|
+
</button>
|
|
148
|
+
<Panel
|
|
149
|
+
isOpen={open}
|
|
150
|
+
onDismiss={() => setOpen(false)}
|
|
151
|
+
type={PanelType.smallFixedFar}
|
|
152
|
+
headerText="Dev Tools"
|
|
153
|
+
isLightDismiss
|
|
154
|
+
styles={{ main: { maxWidth: 360 } }}
|
|
155
|
+
>
|
|
156
|
+
<Stack tokens={{ childrenGap: 16 }} styles={{ root: { paddingTop: 12 } }}>
|
|
157
|
+
<Stack tokens={{ childrenGap: 8 }}>
|
|
158
|
+
<Text variant="smallPlus" styles={{ root: { fontWeight: 600, color: "#605e5c" } }}>
|
|
159
|
+
Open record
|
|
160
|
+
</Text>
|
|
161
|
+
<TextField
|
|
162
|
+
label="Record ID (GUID)"
|
|
163
|
+
placeholder="00000000-0000-0000-0000-000000000000"
|
|
164
|
+
value={recordId}
|
|
165
|
+
onChange={(_, v) => {
|
|
166
|
+
setRecordId(v || "");
|
|
167
|
+
setGuidError(null);
|
|
168
|
+
}}
|
|
169
|
+
errorMessage={guidError || undefined}
|
|
170
|
+
/>
|
|
171
|
+
<PrimaryButton
|
|
172
|
+
text="Open"
|
|
173
|
+
disabled={!recordId.trim()}
|
|
174
|
+
onClick={() => openRecord(recordId)}
|
|
175
|
+
/>
|
|
176
|
+
<Text variant="tiny" styles={{ root: { color: "#a19f9d" } }}>
|
|
177
|
+
Adds ?id= and reloads. In mock mode the seeded record is always returned.
|
|
178
|
+
</Text>
|
|
179
|
+
</Stack>
|
|
180
|
+
|
|
181
|
+
{recent.length > 0 && (
|
|
182
|
+
<>
|
|
183
|
+
<Separator />
|
|
184
|
+
<Stack tokens={{ childrenGap: 4 }}>
|
|
185
|
+
<Text variant="smallPlus" styles={{ root: { fontWeight: 600, color: "#605e5c" } }}>
|
|
186
|
+
Recent
|
|
187
|
+
</Text>
|
|
188
|
+
{recent.map((id) => (
|
|
189
|
+
<Link key={id} title={id} onClick={() => openRecord(id)}>
|
|
190
|
+
<Icon iconName="OpenFile" styles={{ root: { fontSize: 12, marginRight: 6 } }} />
|
|
191
|
+
{id.substring(0, 8)}…
|
|
192
|
+
</Link>
|
|
193
|
+
))}
|
|
194
|
+
</Stack>
|
|
195
|
+
</>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
<Separator />
|
|
199
|
+
<Stack tokens={{ childrenGap: 2 }}>
|
|
200
|
+
<Text variant="smallPlus" styles={{ root: { fontWeight: 600, color: "#605e5c" } }}>
|
|
201
|
+
Environment
|
|
202
|
+
</Text>
|
|
203
|
+
<EnvRow label="Mode" value={mode} />
|
|
204
|
+
{targetUrl && <EnvRow label="Target" value={targetUrl} />}
|
|
205
|
+
<EnvRow
|
|
206
|
+
label="Token"
|
|
207
|
+
value={import.meta.env.VITE_USE_PROXY ? "Configured (server-side)" : "Not set"}
|
|
208
|
+
/>
|
|
209
|
+
<MessageBar messageBarType={MessageBarType.info} isMultiline styles={{ root: { marginTop: 8 } }}>
|
|
210
|
+
For live data: run <strong>npm run auth:token -- --url https://your-org.crm.dynamics.com</strong>, then restart <strong>npm run dev</strong>.
|
|
211
|
+
</MessageBar>
|
|
212
|
+
</Stack>
|
|
213
|
+
|
|
214
|
+
<Separator />
|
|
215
|
+
<Stack tokens={{ childrenGap: 6 }}>
|
|
216
|
+
<Text variant="smallPlus" styles={{ root: { fontWeight: 600, color: "#605e5c" } }}>
|
|
217
|
+
Logs
|
|
218
|
+
</Text>
|
|
219
|
+
<Stack horizontal tokens={{ childrenGap: 8 }}>
|
|
220
|
+
<DefaultButton text="Refresh" onClick={() => setLogs(logger.dump())} />
|
|
221
|
+
<DefaultButton
|
|
222
|
+
text="Clear"
|
|
223
|
+
onClick={() => {
|
|
224
|
+
logger.clear();
|
|
225
|
+
setLogs("");
|
|
226
|
+
}}
|
|
227
|
+
/>
|
|
228
|
+
</Stack>
|
|
229
|
+
{logs !== null && (
|
|
230
|
+
<TextField multiline rows={6} readOnly value={logs || "(no entries)"} />
|
|
231
|
+
)}
|
|
232
|
+
</Stack>
|
|
233
|
+
</Stack>
|
|
234
|
+
</Panel>
|
|
235
|
+
</>
|
|
236
|
+
);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
export default DevPanel;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
initializeIcons,
|
|
4
|
+
MessageBar,
|
|
5
|
+
MessageBarType,
|
|
6
|
+
Spinner,
|
|
7
|
+
Text,
|
|
8
|
+
} from "@fluentui/react";
|
|
9
|
+
import { DataGrid, createCellRegistry } from "@dataverse-kit/grid-kit";
|
|
10
|
+
import type { ColumnDef } from "@dataverse-kit/grid-kit";
|
|
11
|
+
import type { IApiService } from "../core/services/IApiService";
|
|
12
|
+
|
|
13
|
+
initializeIcons();
|
|
14
|
+
|
|
15
|
+
/** Display row shape — map your Dataverse attributes to this in `mapAccount`. */
|
|
16
|
+
interface AccountRow extends Record<string, unknown> {
|
|
17
|
+
key: string;
|
|
18
|
+
name: string;
|
|
19
|
+
accountnumber: string;
|
|
20
|
+
telephone1: string;
|
|
21
|
+
revenue: number;
|
|
22
|
+
statuscode: string;
|
|
23
|
+
rating: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
27
|
+
Active: "#107c10",
|
|
28
|
+
"On hold": "#f7e600",
|
|
29
|
+
Inactive: "#d13438",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// grid-kit columns. Swap `rendererType` / `rendererConfig` to change a cell's
|
|
33
|
+
// look; add `editorType` to make a column inline-editable.
|
|
34
|
+
const columns: ColumnDef<AccountRow>[] = [
|
|
35
|
+
{ key: "name", fieldName: "name", name: "Account", rendererType: "text", editorType: "text", minWidth: 180, isSortable: true },
|
|
36
|
+
{ key: "accountnumber", fieldName: "accountnumber", name: "Number", rendererType: "text", minWidth: 110 },
|
|
37
|
+
{ key: "telephone1", fieldName: "telephone1", name: "Phone", rendererType: "text", minWidth: 150 },
|
|
38
|
+
{ key: "revenue", fieldName: "revenue", name: "Revenue", rendererType: "currency", minWidth: 130, isSortable: true, rendererConfig: { currencyCode: "USD" } },
|
|
39
|
+
{ key: "statuscode", fieldName: "statuscode", name: "Status", rendererType: "coloredCell", minWidth: 110, rendererConfig: { colorMap: STATUS_COLORS } },
|
|
40
|
+
{ key: "rating", fieldName: "rating", name: "Rating", rendererType: "rating", minWidth: 120, rendererConfig: { max: 5 } },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
44
|
+
function mapAccount(e: any): AccountRow {
|
|
45
|
+
return {
|
|
46
|
+
key: e.accountid,
|
|
47
|
+
name: e.name ?? "",
|
|
48
|
+
accountnumber: e.accountnumber ?? "",
|
|
49
|
+
telephone1: e.telephone1 ?? "",
|
|
50
|
+
revenue: Number(e.revenue ?? 0),
|
|
51
|
+
// NOTE: the mock seeds a display string. Real Dataverse `statuscode` is an
|
|
52
|
+
// int optionset — its label is in the `statuscode@OData.Community.Display.V1.FormattedValue`
|
|
53
|
+
// sibling key. In production prefer that label (or switch this column to
|
|
54
|
+
// rendererType 'optionset' with options) so STATUS_COLORS resolves.
|
|
55
|
+
statuscode: e["statuscode@OData.Community.Display.V1.FormattedValue"] ?? e.statuscode ?? "Active",
|
|
56
|
+
rating: Number(e.rating ?? 0),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
60
|
+
|
|
61
|
+
export const GridPage: React.FC<{ api: IApiService }> = ({ api }) => {
|
|
62
|
+
const [rows, setRows] = useState<AccountRow[]>([]);
|
|
63
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
64
|
+
const [error, setError] = useState<string | null>(null);
|
|
65
|
+
const [search, setSearch] = useState("");
|
|
66
|
+
const registry = useMemo(() => createCellRegistry(), []);
|
|
67
|
+
|
|
68
|
+
const filteredRows = useMemo(() => {
|
|
69
|
+
const q = search.trim().toLowerCase();
|
|
70
|
+
if (!q) return rows;
|
|
71
|
+
return rows.filter((r) =>
|
|
72
|
+
[r.name, r.accountnumber, r.telephone1, r.statuscode].some((v) =>
|
|
73
|
+
String(v).toLowerCase().includes(q),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
}, [rows, search]);
|
|
77
|
+
|
|
78
|
+
const load = useCallback(() => {
|
|
79
|
+
setIsLoading(true);
|
|
80
|
+
setError(null);
|
|
81
|
+
api
|
|
82
|
+
.retrieveMultipleRecords("accounts", "")
|
|
83
|
+
.then((res) => setRows(res.entities.map(mapAccount)))
|
|
84
|
+
.catch((e) => setError(e?.message ?? String(e)))
|
|
85
|
+
.finally(() => setIsLoading(false));
|
|
86
|
+
}, [api]);
|
|
87
|
+
|
|
88
|
+
useEffect(load, [load]);
|
|
89
|
+
|
|
90
|
+
if (isLoading) return <Spinner label="Loading accounts…" />;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div style={{ maxWidth: 1100, margin: "16px auto" }}>
|
|
94
|
+
<Text variant="xLarge" block styles={{ root: { marginBottom: 12 } }}>
|
|
95
|
+
Accounts
|
|
96
|
+
</Text>
|
|
97
|
+
{error && (
|
|
98
|
+
<MessageBar messageBarType={MessageBarType.error} styles={{ root: { marginBottom: 12 } }}>
|
|
99
|
+
{error}
|
|
100
|
+
</MessageBar>
|
|
101
|
+
)}
|
|
102
|
+
<DataGrid<AccountRow>
|
|
103
|
+
items={filteredRows}
|
|
104
|
+
columns={columns}
|
|
105
|
+
registry={registry}
|
|
106
|
+
selectionMode="multiple"
|
|
107
|
+
getKey={(r) => r.key}
|
|
108
|
+
toolbar={{
|
|
109
|
+
showSearch: true,
|
|
110
|
+
searchPlaceholder: "Search accounts",
|
|
111
|
+
onSearch: setSearch,
|
|
112
|
+
commands: [{ key: "refresh", text: "Refresh", iconName: "Refresh", onClick: load }],
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export default GridPage;
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
}
|