@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.
Files changed (122) hide show
  1. package/README.md +28 -0
  2. package/dist/artifacts/registry.d.ts +4 -3
  3. package/dist/artifacts/registry.d.ts.map +1 -1
  4. package/dist/artifacts/registry.js +145 -11
  5. package/dist/artifacts/registry.js.map +1 -1
  6. package/dist/artifacts/types.d.ts +10 -1
  7. package/dist/artifacts/types.d.ts.map +1 -1
  8. package/dist/index.js +19 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/injectDevTools.d.ts.map +1 -1
  11. package/dist/injectDevTools.js +4 -2
  12. package/dist/injectDevTools.js.map +1 -1
  13. package/dist/scaffold.d.ts +23 -1
  14. package/dist/scaffold.d.ts.map +1 -1
  15. package/dist/scaffold.js +27 -1
  16. package/dist/scaffold.js.map +1 -1
  17. package/package.json +3 -2
  18. package/templates/grid-starter/ARCHITECTURE.md +66 -0
  19. package/templates/grid-starter/README.md +122 -0
  20. package/templates/grid-starter/env.example +16 -0
  21. package/templates/grid-starter/gitignore +6 -0
  22. package/templates/grid-starter/index.html +16 -0
  23. package/templates/grid-starter/package.json +39 -0
  24. package/templates/grid-starter/src/App.tsx +23 -0
  25. package/templates/grid-starter/src/core/services/FetchApiService.ts +117 -0
  26. package/templates/grid-starter/src/core/services/IApiService.ts +37 -0
  27. package/templates/grid-starter/src/core/services/MockApiService.ts +72 -0
  28. package/templates/grid-starter/src/core/services/ServiceFactory.ts +58 -0
  29. package/templates/grid-starter/src/core/services/XrmApiService.ts +135 -0
  30. package/templates/grid-starter/src/core/services/crudLogging.ts +52 -0
  31. package/templates/grid-starter/src/dev-tools/DevPanel.tsx +239 -0
  32. package/templates/grid-starter/src/grid/GridPage.tsx +119 -0
  33. package/templates/grid-starter/src/index.tsx +18 -0
  34. package/templates/grid-starter/src/vite-env.d.ts +15 -0
  35. package/templates/grid-starter/tools/deploy/deploy-webresource.cjs +117 -0
  36. package/templates/grid-starter/tsconfig.json +19 -0
  37. package/templates/grid-starter/vite.config.ts +76 -0
  38. package/templates/pcf-field/_variants/ValueInput.boolean.tsx +2 -0
  39. package/templates/pcf-field/_variants/ValueInput.date.tsx +2 -0
  40. package/templates/pcf-field/_variants/ValueInput.number.tsx +2 -0
  41. package/templates/pcf-field/_variants/ValueInput.optionset.tsx +77 -0
  42. package/templates/pcf-field/_variants/ValueInput.text.tsx +2 -0
  43. package/templates/pcf-field/index.ts +1 -1
  44. package/templates/pcf-field/package.json +3 -1
  45. package/templates/pcf-field/{{componentName}}Component.tsx +2 -0
  46. package/templates/react-custom-page/ARCHITECTURE.md +75 -0
  47. package/templates/react-custom-page/README.md +74 -568
  48. package/templates/react-custom-page/env.example +16 -0
  49. package/templates/react-custom-page/gitignore +1 -0
  50. package/templates/react-custom-page/index.html +16 -0
  51. package/templates/react-custom-page/package.json +21 -49
  52. package/templates/react-custom-page/src/App.tsx +26 -0
  53. package/templates/react-custom-page/src/core/recordContext.test.ts +30 -0
  54. package/templates/react-custom-page/src/core/recordContext.ts +51 -0
  55. package/templates/react-custom-page/src/core/services/FetchApiService.ts +117 -0
  56. package/templates/react-custom-page/src/core/services/IApiService.ts +37 -0
  57. package/templates/react-custom-page/src/core/services/MockApiService.ts +73 -0
  58. package/templates/react-custom-page/src/core/services/ServiceFactory.ts +58 -0
  59. package/templates/react-custom-page/src/core/services/XrmApiService.ts +135 -0
  60. package/templates/react-custom-page/src/core/services/crudLogging.ts +52 -0
  61. package/templates/react-custom-page/src/dev-tools/DevPanel.tsx +238 -0
  62. package/templates/react-custom-page/src/domain/diff.test.ts +87 -0
  63. package/templates/react-custom-page/src/domain/diff.ts +38 -0
  64. package/templates/react-custom-page/src/example/ExamplePage.tsx +140 -0
  65. package/templates/react-custom-page/src/example/exampleError.ts +36 -0
  66. package/templates/react-custom-page/src/example/hooks/useExampleData.ts +40 -0
  67. package/templates/react-custom-page/src/example/hooks/useExampleForm.ts +99 -0
  68. package/templates/react-custom-page/src/example/mappers/accountMapper.test.ts +38 -0
  69. package/templates/react-custom-page/src/example/mappers/accountMapper.ts +55 -0
  70. package/templates/react-custom-page/src/example/models/Account.ts +74 -0
  71. package/templates/react-custom-page/src/index.tsx +18 -128
  72. package/templates/react-custom-page/src/vite-env.d.ts +15 -0
  73. package/templates/react-custom-page/tools/deploy/deploy-webresource.cjs +117 -0
  74. package/templates/react-custom-page/tsconfig.json +12 -22
  75. package/templates/react-custom-page/vite.config.ts +76 -0
  76. package/templates/starter-page/README.md +38 -0
  77. package/templates/starter-page/_variants/App.dashboard.v8.tsx +46 -0
  78. package/templates/starter-page/_variants/App.form.v8.tsx +59 -0
  79. package/templates/starter-page/_variants/App.master-detail.v8.tsx +61 -0
  80. package/templates/starter-page/_variants/App.panel.v8.tsx +99 -0
  81. package/templates/starter-page/gitignore +5 -0
  82. package/templates/starter-page/package.json +27 -0
  83. package/templates/starter-page/public/index.html +11 -0
  84. package/templates/starter-page/src/index.tsx +10 -0
  85. package/templates/starter-page/src/services/dataverse.ts +30 -0
  86. package/templates/starter-page/tsconfig.json +15 -0
  87. package/templates/starter-page/webpack.config.js +17 -0
  88. package/templates/react-custom-page/deployment/README.md +0 -484
  89. package/templates/react-custom-page/docs/ARCHITECTURE_OVERVIEW.md +0 -506
  90. package/templates/react-custom-page/docs/BEST_PRACTICES.md +0 -723
  91. package/templates/react-custom-page/docs/MIGRATION_GUIDE.md +0 -447
  92. package/templates/react-custom-page/public/index.html +0 -15
  93. package/templates/react-custom-page/scripts/custom-build.js +0 -255
  94. package/templates/react-custom-page/src/components/AccountForm.css +0 -71
  95. package/templates/react-custom-page/src/components/AccountForm.tsx +0 -541
  96. package/templates/react-custom-page/src/components/AccountManagement.css +0 -86
  97. package/templates/react-custom-page/src/components/AccountManagement.tsx +0 -370
  98. package/templates/react-custom-page/src/components/ContactForm.css +0 -48
  99. package/templates/react-custom-page/src/components/ContactForm.tsx +0 -327
  100. package/templates/react-custom-page/src/components/ContactManagement.css +0 -86
  101. package/templates/react-custom-page/src/components/ContactManagement.tsx +0 -357
  102. package/templates/react-custom-page/src/components/Logging/LogDialog.tsx +0 -291
  103. package/templates/react-custom-page/src/components/Logging/LoggingContext.tsx +0 -166
  104. package/templates/react-custom-page/src/components/Logging/LoggingDebugPanel.css +0 -192
  105. package/templates/react-custom-page/src/components/Logging/LoggingDebugPanel.tsx +0 -177
  106. package/templates/react-custom-page/src/components/Logging/LoggingProvider.tsx +0 -3
  107. package/templates/react-custom-page/src/components/Logging/logger.ts +0 -193
  108. package/templates/react-custom-page/src/constants/account.ts +0 -410
  109. package/templates/react-custom-page/src/constants/contact.ts +0 -362
  110. package/templates/react-custom-page/src/models/Account.ts +0 -480
  111. package/templates/react-custom-page/src/models/BaseEntity.ts +0 -204
  112. package/templates/react-custom-page/src/models/Contact.ts +0 -580
  113. package/templates/react-custom-page/src/pcf/ContactControlWrapper.tsx +0 -107
  114. package/templates/react-custom-page/src/pcf/MultiEntityControlWrapper.tsx +0 -205
  115. package/templates/react-custom-page/src/providers/DynamicsProvider.tsx +0 -353
  116. package/templates/react-custom-page/src/services/MockApiService.ts +0 -260
  117. package/templates/react-custom-page/src/services/ServiceFactory.ts +0 -65
  118. package/templates/react-custom-page/src/services/XrmApiService.ts +0 -213
  119. package/templates/react-custom-page/src/styles/index.css +0 -171
  120. package/templates/react-custom-page/tools/metadata-sync/index.js +0 -152
  121. package/templates/react-custom-page/webpack.config.js +0 -57
  122. /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
+ }