@khester/create-dynamics-app 2.1.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 (121) hide show
  1. package/dist/artifacts/registry.d.ts +4 -3
  2. package/dist/artifacts/registry.d.ts.map +1 -1
  3. package/dist/artifacts/registry.js +121 -11
  4. package/dist/artifacts/registry.js.map +1 -1
  5. package/dist/artifacts/types.d.ts +1 -1
  6. package/dist/artifacts/types.d.ts.map +1 -1
  7. package/dist/index.js +2 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/injectDevTools.d.ts.map +1 -1
  10. package/dist/injectDevTools.js +4 -2
  11. package/dist/injectDevTools.js.map +1 -1
  12. package/dist/scaffold.d.ts +1 -0
  13. package/dist/scaffold.d.ts.map +1 -1
  14. package/dist/scaffold.js +3 -1
  15. package/dist/scaffold.js.map +1 -1
  16. package/package.json +3 -2
  17. package/templates/grid-starter/ARCHITECTURE.md +66 -0
  18. package/templates/grid-starter/README.md +122 -0
  19. package/templates/grid-starter/env.example +16 -0
  20. package/templates/grid-starter/gitignore +6 -0
  21. package/templates/grid-starter/index.html +16 -0
  22. package/templates/grid-starter/package.json +39 -0
  23. package/templates/grid-starter/src/App.tsx +23 -0
  24. package/templates/grid-starter/src/core/services/FetchApiService.ts +117 -0
  25. package/templates/grid-starter/src/core/services/IApiService.ts +37 -0
  26. package/templates/grid-starter/src/core/services/MockApiService.ts +72 -0
  27. package/templates/grid-starter/src/core/services/ServiceFactory.ts +58 -0
  28. package/templates/grid-starter/src/core/services/XrmApiService.ts +135 -0
  29. package/templates/grid-starter/src/core/services/crudLogging.ts +52 -0
  30. package/templates/grid-starter/src/dev-tools/DevPanel.tsx +239 -0
  31. package/templates/grid-starter/src/grid/GridPage.tsx +119 -0
  32. package/templates/grid-starter/src/index.tsx +18 -0
  33. package/templates/grid-starter/src/vite-env.d.ts +15 -0
  34. package/templates/grid-starter/tools/deploy/deploy-webresource.cjs +117 -0
  35. package/templates/grid-starter/tsconfig.json +19 -0
  36. package/templates/grid-starter/vite.config.ts +76 -0
  37. package/templates/pcf-field/_variants/ValueInput.boolean.tsx +2 -0
  38. package/templates/pcf-field/_variants/ValueInput.date.tsx +2 -0
  39. package/templates/pcf-field/_variants/ValueInput.number.tsx +2 -0
  40. package/templates/pcf-field/_variants/ValueInput.optionset.tsx +77 -0
  41. package/templates/pcf-field/_variants/ValueInput.text.tsx +2 -0
  42. package/templates/pcf-field/index.ts +1 -1
  43. package/templates/pcf-field/package.json +3 -1
  44. package/templates/pcf-field/{{componentName}}Component.tsx +2 -0
  45. package/templates/react-custom-page/ARCHITECTURE.md +75 -0
  46. package/templates/react-custom-page/README.md +74 -568
  47. package/templates/react-custom-page/env.example +16 -0
  48. package/templates/react-custom-page/gitignore +1 -0
  49. package/templates/react-custom-page/index.html +16 -0
  50. package/templates/react-custom-page/package.json +21 -49
  51. package/templates/react-custom-page/src/App.tsx +26 -0
  52. package/templates/react-custom-page/src/core/recordContext.test.ts +30 -0
  53. package/templates/react-custom-page/src/core/recordContext.ts +51 -0
  54. package/templates/react-custom-page/src/core/services/FetchApiService.ts +117 -0
  55. package/templates/react-custom-page/src/core/services/IApiService.ts +37 -0
  56. package/templates/react-custom-page/src/core/services/MockApiService.ts +73 -0
  57. package/templates/react-custom-page/src/core/services/ServiceFactory.ts +58 -0
  58. package/templates/react-custom-page/src/core/services/XrmApiService.ts +135 -0
  59. package/templates/react-custom-page/src/core/services/crudLogging.ts +52 -0
  60. package/templates/react-custom-page/src/dev-tools/DevPanel.tsx +238 -0
  61. package/templates/react-custom-page/src/domain/diff.test.ts +87 -0
  62. package/templates/react-custom-page/src/domain/diff.ts +38 -0
  63. package/templates/react-custom-page/src/example/ExamplePage.tsx +140 -0
  64. package/templates/react-custom-page/src/example/exampleError.ts +36 -0
  65. package/templates/react-custom-page/src/example/hooks/useExampleData.ts +40 -0
  66. package/templates/react-custom-page/src/example/hooks/useExampleForm.ts +99 -0
  67. package/templates/react-custom-page/src/example/mappers/accountMapper.test.ts +38 -0
  68. package/templates/react-custom-page/src/example/mappers/accountMapper.ts +55 -0
  69. package/templates/react-custom-page/src/example/models/Account.ts +74 -0
  70. package/templates/react-custom-page/src/index.tsx +18 -128
  71. package/templates/react-custom-page/src/vite-env.d.ts +15 -0
  72. package/templates/react-custom-page/tools/deploy/deploy-webresource.cjs +117 -0
  73. package/templates/react-custom-page/tsconfig.json +12 -22
  74. package/templates/react-custom-page/vite.config.ts +76 -0
  75. package/templates/starter-page/README.md +38 -0
  76. package/templates/starter-page/_variants/App.dashboard.v8.tsx +46 -0
  77. package/templates/starter-page/_variants/App.form.v8.tsx +59 -0
  78. package/templates/starter-page/_variants/App.master-detail.v8.tsx +61 -0
  79. package/templates/starter-page/_variants/App.panel.v8.tsx +99 -0
  80. package/templates/starter-page/gitignore +5 -0
  81. package/templates/starter-page/package.json +27 -0
  82. package/templates/starter-page/public/index.html +11 -0
  83. package/templates/starter-page/src/index.tsx +10 -0
  84. package/templates/starter-page/src/services/dataverse.ts +30 -0
  85. package/templates/starter-page/tsconfig.json +15 -0
  86. package/templates/starter-page/webpack.config.js +17 -0
  87. package/templates/react-custom-page/deployment/README.md +0 -484
  88. package/templates/react-custom-page/docs/ARCHITECTURE_OVERVIEW.md +0 -506
  89. package/templates/react-custom-page/docs/BEST_PRACTICES.md +0 -723
  90. package/templates/react-custom-page/docs/MIGRATION_GUIDE.md +0 -447
  91. package/templates/react-custom-page/public/index.html +0 -15
  92. package/templates/react-custom-page/scripts/custom-build.js +0 -255
  93. package/templates/react-custom-page/src/components/AccountForm.css +0 -71
  94. package/templates/react-custom-page/src/components/AccountForm.tsx +0 -541
  95. package/templates/react-custom-page/src/components/AccountManagement.css +0 -86
  96. package/templates/react-custom-page/src/components/AccountManagement.tsx +0 -370
  97. package/templates/react-custom-page/src/components/ContactForm.css +0 -48
  98. package/templates/react-custom-page/src/components/ContactForm.tsx +0 -327
  99. package/templates/react-custom-page/src/components/ContactManagement.css +0 -86
  100. package/templates/react-custom-page/src/components/ContactManagement.tsx +0 -357
  101. package/templates/react-custom-page/src/components/Logging/LogDialog.tsx +0 -291
  102. package/templates/react-custom-page/src/components/Logging/LoggingContext.tsx +0 -166
  103. package/templates/react-custom-page/src/components/Logging/LoggingDebugPanel.css +0 -192
  104. package/templates/react-custom-page/src/components/Logging/LoggingDebugPanel.tsx +0 -177
  105. package/templates/react-custom-page/src/components/Logging/LoggingProvider.tsx +0 -3
  106. package/templates/react-custom-page/src/components/Logging/logger.ts +0 -193
  107. package/templates/react-custom-page/src/constants/account.ts +0 -410
  108. package/templates/react-custom-page/src/constants/contact.ts +0 -362
  109. package/templates/react-custom-page/src/models/Account.ts +0 -480
  110. package/templates/react-custom-page/src/models/BaseEntity.ts +0 -204
  111. package/templates/react-custom-page/src/models/Contact.ts +0 -580
  112. package/templates/react-custom-page/src/pcf/ContactControlWrapper.tsx +0 -107
  113. package/templates/react-custom-page/src/pcf/MultiEntityControlWrapper.tsx +0 -205
  114. package/templates/react-custom-page/src/providers/DynamicsProvider.tsx +0 -353
  115. package/templates/react-custom-page/src/services/MockApiService.ts +0 -260
  116. package/templates/react-custom-page/src/services/ServiceFactory.ts +0 -65
  117. package/templates/react-custom-page/src/services/XrmApiService.ts +0 -213
  118. package/templates/react-custom-page/src/styles/index.css +0 -171
  119. package/templates/react-custom-page/tools/metadata-sync/index.js +0 -152
  120. package/templates/react-custom-page/webpack.config.js +0 -57
  121. /package/templates/_shared/dev-tools/auth/{get-token.js → get-token.cjs} +0 -0
@@ -0,0 +1,16 @@
1
+ # Token-proxy dev mode — run `npm run dev` against a real Dataverse org.
2
+ #
3
+ # Preferred: run `npm run auth:token -- --url https://<org>.crm.dynamics.com`,
4
+ # which acquires a token via the Azure CLI and writes both values to .env.
5
+ # Or copy this file to .env and fill them in manually.
6
+ #
7
+ # When DYNAMICS_URL is set, the Vite dev server proxies /api/data/* to the org
8
+ # and injects DYNAMICS_TOKEN as the bearer (server-side — never in the bundle).
9
+ DYNAMICS_URL=https://your-org.crm.dynamics.com
10
+ DYNAMICS_TOKEN=
11
+
12
+ # `npm run deploy` — the HTML web-resource unique name. The prefix (before `_`)
13
+ # MUST be a publisher prefix that exists in your org (e.g. cr1a2_ / new_).
14
+ WEBRESOURCE_NAME=new_/your-app/index.html
15
+ # Optional: add the web resource to this unmanaged solution (unique name).
16
+ WEBRESOURCE_SOLUTION=
@@ -3,3 +3,4 @@ dist/
3
3
  *.log
4
4
  .env
5
5
  .env.local
6
+ .env.*.local
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Dynamics Custom Page</title>
7
+ <style>
8
+ /* App canvas behind the form — matches the Dynamics model-driven background. */
9
+ body { margin: 0; min-height: 100vh; background: #fafafa; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/index.tsx"></script>
15
+ </body>
16
+ </html>
@@ -1,65 +1,37 @@
1
1
  {
2
2
  "name": "react-custom-page-app",
3
- "version": "1.0.0",
4
- "description": "Dynamics 365 React Custom Page app built with Dynamics UI Kit",
5
- "main": "dist/index.js",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Minimal Dataverse React custom page (Vite + @khester/reusable-components)",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=18"
9
+ },
6
10
  "scripts": {
7
- "build": "webpack --mode=production",
8
- "build:dev": "node scripts/custom-build.js --dev",
9
- "build:prod": "node scripts/custom-build.js",
10
- "build:d365": "node scripts/custom-build.js",
11
- "dev": "webpack serve --mode=development",
12
- "start": "webpack serve --mode=development",
13
- "serve": "serve -s dist -l 62874",
11
+ "dev": "vite",
12
+ "dev:token": "npm run auth:token && npm run dev",
13
+ "build": "tsc && vite build",
14
+ "build:d365": "tsc && vite build --mode d365",
15
+ "deploy": "npm run auth:token && npm run build:d365 && node tools/deploy/deploy-webresource.cjs",
16
+ "preview": "vite preview",
14
17
  "typecheck": "tsc --noEmit",
15
- "clean": "rimraf dist",
16
- "lint": "eslint src --ext .ts,.tsx",
17
- "lint:fix": "eslint src --ext .ts,.tsx --fix",
18
- "quality": "npm run lint && npm run typecheck",
19
- "quality:fix": "npm run lint:fix && npm run typecheck",
20
- "validate": "npm run quality && npm run build:prod",
21
- "test:build": "npm run clean && npm run build:dev && npm run build:prod",
22
- "test:serve": "npm run build:prod && npm run serve",
23
- "precommit": "npm run quality",
24
- "prepublishOnly": "npm run validate",
25
- "metadata:pull": "node tools/metadata-sync/index.js pull",
26
- "metadata:generate": "node tools/metadata-sync/index.js generate",
27
- "metadata:validate": "node tools/metadata-sync/index.js validate",
28
- "metadata:sync": "node tools/metadata-sync/index.js sync"
18
+ "test": "vitest run",
19
+ "clean": "rimraf dist"
29
20
  },
30
21
  "dependencies": {
31
- "@khester/dynamics-ui-api-client": "^1.0.0",
32
- "@khester/dynamics-ui-components": "^1.0.0",
22
+ "@fluentui/react": "^8.110.10",
23
+ "@khester/reusable-components": "0.1.3",
33
24
  "react": "^18.2.0",
34
25
  "react-dom": "^18.2.0"
35
26
  },
36
27
  "devDependencies": {
37
- "@dataverse-kit/dataverse-codegen": "^0.1.0",
38
28
  "@types/react": "^18.2.0",
39
29
  "@types/react-dom": "^18.2.0",
40
- "commander": "^11.1.0",
41
- "css-loader": "^6.8.1",
42
- "eslint": "^8.57.0",
43
- "html-webpack-plugin": "^5.5.3",
30
+ "@vitejs/plugin-react": "^4.2.1",
44
31
  "rimraf": "^5.0.5",
45
- "serve": "^14.2.1",
46
- "style-loader": "^3.3.3",
47
- "ts-loader": "^9.5.1",
48
32
  "typescript": "^5.3.3",
49
- "webpack": "^5.89.0",
50
- "webpack-cli": "^5.1.4",
51
- "webpack-dev-server": "^4.15.1"
52
- },
53
- "browserslist": {
54
- "production": [
55
- ">0.2%",
56
- "not dead",
57
- "not op_mini all"
58
- ],
59
- "development": [
60
- "last 1 chrome version",
61
- "last 1 firefox version",
62
- "last 1 safari version"
63
- ]
33
+ "vite": "^5.4.21",
34
+ "vite-plugin-singlefile": "^2.0.3",
35
+ "vitest": "^1.6.0"
64
36
  }
65
37
  }
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ import { ServiceFactory } from "./core/services/ServiceFactory";
3
+ import { resolveRecordId } from "./core/recordContext";
4
+ import { ExamplePage } from "./example/ExamplePage";
5
+ import { DevPanel } from "./dev-tools/DevPanel";
6
+
7
+ // Domain-free shell. The Xrm context exists only when this bundle is hosted
8
+ // inside a model-driven app (the custom page runs in an iframe, so Xrm lives on
9
+ // window.parent). ServiceFactory uses it for production mode; on localhost it is
10
+ // undefined and the factory returns the mock or token-proxy service instead.
11
+ const Xrm: any = (window as any).parent?.Xrm ?? (window as any).Xrm;
12
+
13
+ export const App: React.FC = () => {
14
+ const api = React.useMemo(() => ServiceFactory.createApiService(Xrm), []);
15
+ // Which record to load — from ?id= / ?data= / the hosting form's record.
16
+ const recordId = React.useMemo(() => resolveRecordId(Xrm), []);
17
+ return (
18
+ <>
19
+ <ExamplePage api={api} recordId={recordId} />
20
+ {/* Localhost-only floating dev tools (renders nothing once deployed). */}
21
+ <DevPanel />
22
+ </>
23
+ );
24
+ };
25
+
26
+ export default App;
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { idFromDataParam } from "./recordContext";
3
+
4
+ const GUID = "8025328c-a265-f111-a826-000d3a37b308";
5
+
6
+ describe("idFromDataParam", () => {
7
+ it("reads a bare guid", () => {
8
+ expect(idFromDataParam(GUID)).toBe(GUID);
9
+ });
10
+
11
+ it("reads an `id=<guid>` query string", () => {
12
+ expect(idFromDataParam(`id=${GUID}`)).toBe(GUID);
13
+ });
14
+
15
+ it("reads JSON { id }", () => {
16
+ expect(idFromDataParam(JSON.stringify({ id: GUID }))).toBe(GUID);
17
+ });
18
+
19
+ it("reads JSON { selectedItems: [id] } (import_weights/navigateTo shape)", () => {
20
+ expect(idFromDataParam(JSON.stringify({ selectedItems: [GUID] }))).toBe(GUID);
21
+ });
22
+
23
+ it("reads a bare JSON string", () => {
24
+ expect(idFromDataParam(JSON.stringify(GUID))).toBe(GUID);
25
+ });
26
+
27
+ it("returns undefined for unrecognized data", () => {
28
+ expect(idFromDataParam("foo=bar")).toBeUndefined();
29
+ });
30
+ });
@@ -0,0 +1,51 @@
1
+ // Resolves which record this page should load, from however it was opened:
2
+ // 1. ?id=<guid> — a direct link / the dev-tools panel
3
+ // 2. ?data=<payload> — the Xrm.Navigation.navigateTo `data` arg
4
+ // (JSON {id}/{selectedItems:[id]}, "id=<guid>", or a bare guid)
5
+ // 3. Xrm.Page.data.entity.getId() — the form the web resource is hosted on
6
+ // Returns undefined when none apply (mock mode just serves its seeded record).
7
+
8
+ const stripBraces = (s: string) => s.replace(/[{}]/g, "").trim();
9
+
10
+ /** Pull a record id out of a navigateTo `data` parameter value. Pure/testable. */
11
+ export function idFromDataParam(data: string): string | undefined {
12
+ try {
13
+ const parsed = JSON.parse(data);
14
+ if (typeof parsed === "string") return parsed;
15
+ if (parsed && typeof parsed.id === "string") return parsed.id;
16
+ if (Array.isArray(parsed?.selectedItems) && parsed.selectedItems.length) {
17
+ return String(parsed.selectedItems[0]);
18
+ }
19
+ } catch {
20
+ // Not JSON — fall through to query-string / bare-guid handling.
21
+ }
22
+ const inner = new URLSearchParams(data).get("id");
23
+ if (inner) return inner;
24
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(stripBraces(data))) {
25
+ return stripBraces(data);
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ /** Resolve the record id from the URL or the hosting form context. */
31
+ export function resolveRecordId(Xrm?: any): string | undefined {
32
+ const params = new URLSearchParams(window.location.search);
33
+
34
+ const direct = params.get("id");
35
+ if (direct) return stripBraces(direct);
36
+
37
+ const data = params.get("data");
38
+ if (data) {
39
+ const fromData = idFromDataParam(data);
40
+ if (fromData) return stripBraces(fromData);
41
+ }
42
+
43
+ try {
44
+ const id = Xrm?.Page?.data?.entity?.getId?.();
45
+ if (id) return stripBraces(id);
46
+ } catch {
47
+ // No form context (e.g. opened standalone) — leave undefined.
48
+ }
49
+
50
+ return undefined;
51
+ }
@@ -0,0 +1,117 @@
1
+ import { IApiService } from "./IApiService";
2
+ import { logCrud } from "./crudLogging";
3
+ import { logger } from "@khester/reusable-components";
4
+
5
+ /**
6
+ * Dev API service for token-proxy mode (`npm run dev` with DYNAMICS_URL set).
7
+ * Requests go to the SAME-ORIGIN path /api/data/v9.2/... and the Vite dev proxy
8
+ * (vite.config.ts) injects the `Authorization: Bearer` header server-side — the
9
+ * token is never read by, or bundled into, the client.
10
+ */
11
+ export class FetchApiService implements IApiService {
12
+ constructor(private baseUrl = "") {}
13
+
14
+ private headers(): HeadersInit {
15
+ return {
16
+ "Content-Type": "application/json; charset=utf-8",
17
+ Accept: "application/json",
18
+ "OData-Version": "4.0",
19
+ "OData-MaxVersion": "4.0",
20
+ Prefer: 'odata.include-annotations="*"',
21
+ };
22
+ }
23
+
24
+ retrieveMultipleRecords(
25
+ entity: string,
26
+ fetchXml: string,
27
+ ): Promise<{ entities: any[] }> {
28
+ return logCrud(
29
+ { op: "READ", entity, resultCount: (r) => r?.entities?.length },
30
+ async () => {
31
+ const url = `${this.baseUrl}/api/data/v9.2/${entity}?fetchXml=${encodeURIComponent(fetchXml)}`;
32
+ const response = await fetch(url, { method: "GET", headers: this.headers() });
33
+ if (!response.ok) {
34
+ const body = await response.text().catch(() => "");
35
+ throw new Error(`API error ${response.status}: ${response.statusText}${body ? ` — ${body}` : ""}`);
36
+ }
37
+ const data = await response.json();
38
+ return { entities: data.value ?? [] };
39
+ },
40
+ );
41
+ }
42
+
43
+ createRecord(entity: string, record: any): Promise<any> {
44
+ return logCrud({ op: "CREATE", entity, resultId: (r) => r?.id }, async () => {
45
+ const url = `${this.baseUrl}/api/data/v9.2/${entity}`;
46
+ const response = await fetch(url, {
47
+ method: "POST",
48
+ headers: this.headers(),
49
+ body: JSON.stringify(record),
50
+ });
51
+ if (!response.ok) {
52
+ const body = await response.text().catch(() => "");
53
+ throw new Error(`Create failed ${response.status}: ${body}`);
54
+ }
55
+ const entityId = response.headers.get("OData-EntityId");
56
+ const match = entityId ? /\(([^)]+)\)/.exec(entityId) : null;
57
+ return match ? { id: match[1] } : response.json().catch(() => ({}));
58
+ });
59
+ }
60
+
61
+ updateRecord(entity: string, id: string, record: any): Promise<any> {
62
+ return logCrud({ op: "UPDATE", entity, id }, async () => {
63
+ const cleanId = id.replace(/[{}]/g, "");
64
+ const url = `${this.baseUrl}/api/data/v9.2/${entity}(${cleanId})`;
65
+ const response = await fetch(url, {
66
+ method: "PATCH",
67
+ headers: this.headers(),
68
+ body: JSON.stringify(record),
69
+ });
70
+ if (!response.ok) {
71
+ const body = await response.text().catch(() => "");
72
+ throw new Error(`Update failed ${response.status}: ${body}`);
73
+ }
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.baseUrl}/api/data/v9.2/${entity}(${cleanId})`;
82
+ const response = await fetch(url, { method: "DELETE", headers: this.headers() });
83
+ if (!response.ok) {
84
+ const body = await response.text().catch(() => "");
85
+ throw new Error(`Delete failed ${response.status}: ${body}`);
86
+ }
87
+ });
88
+ }
89
+
90
+ executeRequest(requestName: string, requestData: any): Promise<any> {
91
+ return logCrud({ op: "EXECUTE", entity: requestName }, async () => {
92
+ const url = `${this.baseUrl}/api/data/v9.2/${requestName}`;
93
+ const response = await fetch(url, {
94
+ method: "POST",
95
+ headers: this.headers(),
96
+ body: JSON.stringify(requestData),
97
+ });
98
+ if (!response.ok) {
99
+ const body = await response.text().catch(() => "");
100
+ throw new Error(`Execute failed ${response.status}: ${body}`);
101
+ }
102
+ return response.json().catch(() => ({}));
103
+ });
104
+ }
105
+
106
+ async uploadFile(file: File): Promise<string> {
107
+ logger.warn("uploadFile not implemented", {
108
+ source: "FetchApiService",
109
+ data: { file: file.name },
110
+ });
111
+ return "";
112
+ }
113
+
114
+ async associateRecord(): Promise<void> {
115
+ logger.warn("associateRecord not implemented", { source: "FetchApiService" });
116
+ }
117
+ }
@@ -0,0 +1,37 @@
1
+ // The single data-access contract for the page. Three implementations satisfy
2
+ // it — MockApiService (offline), FetchApiService (token-proxy dev), and
3
+ // XrmApiService (production) — and ServiceFactory picks one per environment.
4
+ //
5
+ // Entities are addressed by their Web API ENTITY SET name (plural, e.g.
6
+ // "accounts"), used consistently for reads and writes across all three services.
7
+ export interface IApiService {
8
+ /** Update a record. `record` contains only the changed attributes. */
9
+ updateRecord(entity: string, id: string, record: any): Promise<any>;
10
+
11
+ /** Create a record; resolves to `{ id }`. */
12
+ createRecord(entity: string, record: any): Promise<any>;
13
+
14
+ /** Delete a record. */
15
+ deleteRecord(entity: string, id: string): Promise<void>;
16
+
17
+ /** Retrieve records via a FetchXML query. */
18
+ retrieveMultipleRecords(
19
+ entity: string,
20
+ fetchXml: string,
21
+ ): Promise<{ entities: any[] }>;
22
+
23
+ /** Execute a custom API / unbound function or action. */
24
+ executeRequest(requestName: string, requestData: any): Promise<any>;
25
+
26
+ /** Upload a file and return its URL. */
27
+ uploadFile(file: File): Promise<string>;
28
+
29
+ /** Associate two records via a relationship. */
30
+ associateRecord(
31
+ entityName: string,
32
+ entityId: string,
33
+ relationshipName: string,
34
+ relatedEntityName: string,
35
+ relatedEntityId: string,
36
+ ): Promise<void>;
37
+ }
@@ -0,0 +1,73 @@
1
+ import { IApiService } from "./IApiService";
2
+ import { logCrud } from "./crudLogging";
3
+
4
+ // One seeded in-memory account so `npm run dev` renders and saves with no org
5
+ // or token. Replace this seed (and add more entity sets) as your page grows.
6
+ const SEED_ACCOUNT = {
7
+ accountid: "00000000-0000-0000-0000-000000000001",
8
+ name: "Contoso Ltd",
9
+ accountnumber: "ACC-1001",
10
+ telephone1: "+1 (425) 555-0100",
11
+ emailaddress1: "info@contoso.example",
12
+ websiteurl: "https://contoso.example",
13
+ };
14
+
15
+ /**
16
+ * Default mock used on localhost when no token URL is configured. Operations are
17
+ * routed through `logCrud`, so a starter page shows the same `[CRUD] …` console
18
+ * lines (and populates `window.dumpAppLogs()`) as a real service.
19
+ */
20
+ export class MockApiService implements IApiService {
21
+ private accounts: Record<string, any> = {
22
+ [SEED_ACCOUNT.accountid]: { ...SEED_ACCOUNT },
23
+ };
24
+ private nextId = 2;
25
+
26
+ retrieveMultipleRecords(entity: string): Promise<{ entities: any[] }> {
27
+ return logCrud(
28
+ { op: "READ", entity, resultCount: (r) => r?.entities?.length },
29
+ async () => ({
30
+ entities: entity === "accounts" ? Object.values(this.accounts) : [],
31
+ }),
32
+ );
33
+ }
34
+
35
+ updateRecord(entity: string, id: string, record: any): Promise<any> {
36
+ return logCrud({ op: "UPDATE", entity, id }, async () => {
37
+ const key = id.replace(/[{}]/g, "");
38
+ if (this.accounts[key]) {
39
+ this.accounts[key] = { ...this.accounts[key], ...record };
40
+ }
41
+ return { success: true };
42
+ });
43
+ }
44
+
45
+ createRecord(entity: string, record: any): Promise<any> {
46
+ return logCrud({ op: "CREATE", entity, resultId: (r) => r?.id }, async () => {
47
+ const id = `00000000-0000-0000-0000-${String(this.nextId++).padStart(12, "0")}`;
48
+ if (entity === "accounts") this.accounts[id] = { accountid: id, ...record };
49
+ return { id };
50
+ });
51
+ }
52
+
53
+ deleteRecord(entity: string, id: string): Promise<void> {
54
+ return logCrud({ op: "DELETE", entity, id }, async () => {
55
+ delete this.accounts[id.replace(/[{}]/g, "")];
56
+ });
57
+ }
58
+
59
+ executeRequest(requestName: string): Promise<any> {
60
+ return logCrud({ op: "EXECUTE", entity: requestName }, async () => ({}));
61
+ }
62
+
63
+ async uploadFile(): Promise<string> {
64
+ return "";
65
+ }
66
+
67
+ associateRecord(entityName: string, entityId: string): Promise<void> {
68
+ return logCrud(
69
+ { op: "ASSOCIATE", entity: entityName, id: entityId },
70
+ async () => undefined,
71
+ );
72
+ }
73
+ }
@@ -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
+ }