@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,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=
|
|
@@ -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
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"build
|
|
10
|
-
"build:d365": "
|
|
11
|
-
"
|
|
12
|
-
"
|
|
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
|
-
"
|
|
16
|
-
"
|
|
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
|
-
"@
|
|
32
|
-
"@khester/
|
|
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
|
-
"
|
|
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
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
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
|
+
}
|