@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.
- package/dist/artifacts/registry.d.ts +4 -3
- package/dist/artifacts/registry.d.ts.map +1 -1
- package/dist/artifacts/registry.js +121 -11
- package/dist/artifacts/registry.js.map +1 -1
- package/dist/artifacts/types.d.ts +1 -1
- package/dist/artifacts/types.d.ts.map +1 -1
- package/dist/index.js +2 -1
- 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 +1 -0
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +3 -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,122 @@
|
|
|
1
|
+
# Grid Starter (Dataverse custom page)
|
|
2
|
+
|
|
3
|
+
A production-shaped starter for a React app mounted as a **Dynamics 365 custom
|
|
4
|
+
page**, centered on a reusable **grid**. `src/grid/GridPage.tsx` renders an
|
|
5
|
+
[`@dataverse-kit/grid-kit`](../../../grid-kit) `<DataGrid>` over the
|
|
6
|
+
`ServiceFactory` data seam: mock data on localhost, live Dataverse in production.
|
|
7
|
+
|
|
8
|
+
The grid is **registry-driven** — every column is a `ColumnDef` with a
|
|
9
|
+
`rendererType` (text, currency, `coloredCell` status badge, rating, …). The same
|
|
10
|
+
registry works in a PCF grid and a Grid Customizer. To add or change a cell:
|
|
11
|
+
edit the `columns` array in `GridPage.tsx`; to add a bespoke cell type, call
|
|
12
|
+
`registry.register('myType', { read: MyCell })`.
|
|
13
|
+
|
|
14
|
+
The data-access seam (`src/core/services`) is owned by this app.
|
|
15
|
+
|
|
16
|
+
## Commands
|
|
17
|
+
|
|
18
|
+
| Command | What it does |
|
|
19
|
+
|---------|--------------|
|
|
20
|
+
| `npm install` | Install dependencies |
|
|
21
|
+
| `npm run dev` | Run in **mock** mode — seeded data, no org needed |
|
|
22
|
+
| `npm run dev:token` | Run against a **live org** — refreshes the token + starts Vite |
|
|
23
|
+
| `npm run auth:token -- --url https://<org>.crm.dynamics.com` | Acquire/refresh a Dataverse token into `.env` (needs `az login`) |
|
|
24
|
+
| `npm run build` | Production build → `dist/` (multi-file) |
|
|
25
|
+
| `npm run build:d365` | Single self-contained `dist/index.html` (the web resource) |
|
|
26
|
+
| `npm run deploy` | Build + upload + **publish** the web resource to your org |
|
|
27
|
+
| `npm test` | Run Vitest (no tests ship by default — add your own; passes with none) |
|
|
28
|
+
| `npm run typecheck` | Type-check (`tsc --noEmit`) |
|
|
29
|
+
| `npm install @khester/reusable-components@latest` | Update the shared component library |
|
|
30
|
+
|
|
31
|
+
> Requires **Node 18+**. For `dev:token` / `deploy` you also need `az login` and
|
|
32
|
+
> `DYNAMICS_URL` in `.env` (set once via `npm run auth:token -- --url …`).
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install
|
|
38
|
+
npm run dev # mock mode — a seeded account, no org needed
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Open http://localhost:3000. The grid lists the seeded accounts; type in the
|
|
42
|
+
toolbar search to filter, click **Refresh** to reload. Each load logs a
|
|
43
|
+
`[CRUD] READ accounts ok` line; on localhost `window.dumpAppLogs()` in the
|
|
44
|
+
browser console prints the structured log buffer.
|
|
45
|
+
|
|
46
|
+
A floating **🔧 Dev Tools** panel (bottom-right, localhost only) shows the active
|
|
47
|
+
mode (Mock / Token-proxy / Production) and the `[CRUD]` log buffer. It renders
|
|
48
|
+
nothing once deployed.
|
|
49
|
+
|
|
50
|
+
## Three runtime modes
|
|
51
|
+
|
|
52
|
+
`src/core/services/ServiceFactory.ts` picks the data service per environment:
|
|
53
|
+
|
|
54
|
+
| Mode | When | Service |
|
|
55
|
+
|------|------|---------|
|
|
56
|
+
| **Mock** | `localhost`, no `DYNAMICS_URL` | `MockApiService` (in-memory, seeds a few accounts) |
|
|
57
|
+
| **Token** | `localhost` + `DYNAMICS_URL` set | `FetchApiService` → Vite proxy → real Dataverse |
|
|
58
|
+
| **Production** | deployed in a model-driven app | `XrmApiService` (Web API, session auth) |
|
|
59
|
+
|
|
60
|
+
### Token mode — test against a real org locally
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm run auth:token -- --url https://<org>.crm.dynamics.com # writes .env via Azure CLI
|
|
64
|
+
npm run dev
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The Vite dev server proxies `/api/data/*` to the org and injects the bearer token
|
|
68
|
+
**server-side** — the token never enters the browser bundle. Tokens last ~60 min;
|
|
69
|
+
re-run `npm run auth:token` to refresh (no dev-server restart needed). Requires
|
|
70
|
+
`az login` first. See `env.example` for the variables.
|
|
71
|
+
|
|
72
|
+
Once `DYNAMICS_URL` is in `.env`, **`npm run dev:token`** refreshes the token and
|
|
73
|
+
starts the dev server in one step:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm run dev:token
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Build & deploy
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm run build # standard multi-file build → dist/
|
|
83
|
+
npm run build:d365 # single self-contained dist/index.html (vite-plugin-singlefile)
|
|
84
|
+
npm run deploy # build:d365 + upload as an HTML web resource + publish
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**`npm run deploy`** refreshes the token, builds the single-file bundle, and
|
|
88
|
+
upserts + publishes it as an HTML web resource via the Dataverse Web API (no PAC
|
|
89
|
+
CLI needed). Set the web-resource name in `.env` first — the prefix must match a
|
|
90
|
+
publisher that exists in your org:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# .env
|
|
94
|
+
WEBRESOURCE_NAME=cr1a2_/myapp/index.html # use YOUR publisher prefix
|
|
95
|
+
# WEBRESOURCE_SOLUTION=MySolution # optional: add it to an unmanaged solution
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Then host it on a model-driven **custom page**, or open it via
|
|
99
|
+
`Xrm.Navigation.navigateTo({ pageType: "webresource", webresourceName: "<name>" })`.
|
|
100
|
+
The page reads an optional record id from the URL (`?id=<guid>`); in production it
|
|
101
|
+
resolves `Xrm` from `window.parent`.
|
|
102
|
+
|
|
103
|
+
> Some orgs enforce a Content-Security-Policy that blocks inline `<script>`. The
|
|
104
|
+
> single-file bundle is one inline script — if your environment blocks it, use the
|
|
105
|
+
> multi-file `npm run build` output instead.
|
|
106
|
+
|
|
107
|
+
## Make it your own
|
|
108
|
+
|
|
109
|
+
The grid lives in `src/grid/GridPage.tsx`:
|
|
110
|
+
|
|
111
|
+
1. **Change columns** — edit the `columns: ColumnDef[]` array. Each column has a
|
|
112
|
+
`rendererType` (text, currency, coloredCell, rating, optionset, progress, date,
|
|
113
|
+
link, toggle, …) plus optional `rendererConfig` and `editorType`.
|
|
114
|
+
2. **Custom cell** — `registry.register('myType', { read: MyCell })`, then set a
|
|
115
|
+
column's `rendererType` to `'myType'`.
|
|
116
|
+
3. **Map your entity** — adjust `mapAccount` and the column `fieldName`s to your
|
|
117
|
+
Dataverse attributes, and seed rows in `src/core/services/MockApiService.ts`
|
|
118
|
+
so `npm run dev` works offline.
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npm run typecheck # tsc --noEmit
|
|
122
|
+
```
|
|
@@ -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>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "grid-starter-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Dataverse React custom page with a grid-kit <DataGrid> (Vite + @dataverse-kit/grid-kit)",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
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",
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"test": "vitest run --passWithNoTests",
|
|
19
|
+
"clean": "rimraf dist"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@dataverse-kit/grid-kit": "^0.1.0",
|
|
23
|
+
"@khester/dynamics-cell-renderers": "^1.1.0",
|
|
24
|
+
"@khester/reusable-components": "^0.1.4",
|
|
25
|
+
"@fluentui/react": "^8.110.10",
|
|
26
|
+
"react": "^18.2.0",
|
|
27
|
+
"react-dom": "^18.2.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/react": "^18.2.0",
|
|
31
|
+
"@types/react-dom": "^18.2.0",
|
|
32
|
+
"@vitejs/plugin-react": "^4.2.1",
|
|
33
|
+
"rimraf": "^5.0.5",
|
|
34
|
+
"typescript": "^5.3.3",
|
|
35
|
+
"vite": "^5.4.21",
|
|
36
|
+
"vite-plugin-singlefile": "^2.0.3",
|
|
37
|
+
"vitest": "^1.6.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ServiceFactory } from "./core/services/ServiceFactory";
|
|
3
|
+
import { GridPage } from "./grid/GridPage";
|
|
4
|
+
import { DevPanel } from "./dev-tools/DevPanel";
|
|
5
|
+
|
|
6
|
+
// Domain-free shell. The Xrm context exists only when this bundle is hosted
|
|
7
|
+
// inside a model-driven app (the custom page runs in an iframe, so Xrm lives on
|
|
8
|
+
// window.parent). ServiceFactory uses it for production mode; on localhost it is
|
|
9
|
+
// undefined and the factory returns the mock or token-proxy service instead.
|
|
10
|
+
const Xrm: any = (window as any).parent?.Xrm ?? (window as any).Xrm;
|
|
11
|
+
|
|
12
|
+
export const App: React.FC = () => {
|
|
13
|
+
const api = React.useMemo(() => ServiceFactory.createApiService(Xrm), []);
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<GridPage api={api} />
|
|
17
|
+
{/* Localhost-only floating dev tools (renders nothing once deployed). */}
|
|
18
|
+
<DevPanel />
|
|
19
|
+
</>
|
|
20
|
+
);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default App;
|
|
@@ -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,72 @@
|
|
|
1
|
+
import { IApiService } from "./IApiService";
|
|
2
|
+
import { logCrud } from "./crudLogging";
|
|
3
|
+
|
|
4
|
+
// A few seeded in-memory accounts so `npm run dev` renders the grid with no org
|
|
5
|
+
// or token. Each carries the extra demo fields the grid columns show (revenue,
|
|
6
|
+
// statuscode, rating). Replace this seed as your page grows.
|
|
7
|
+
const SEED_ACCOUNTS = [
|
|
8
|
+
{ accountid: "00000000-0000-0000-0000-000000000001", name: "Contoso Ltd", accountnumber: "ACC-1001", telephone1: "+1 (425) 555-0100", emailaddress1: "info@contoso.example", websiteurl: "https://contoso.example", revenue: 1500000, statuscode: "Active", rating: 5 },
|
|
9
|
+
{ accountid: "00000000-0000-0000-0000-000000000002", name: "Fabrikam Inc", accountnumber: "ACC-1002", telephone1: "+1 (206) 555-0140", emailaddress1: "hello@fabrikam.example", websiteurl: "https://fabrikam.example", revenue: 220000, statuscode: "On hold", rating: 3 },
|
|
10
|
+
{ accountid: "00000000-0000-0000-0000-000000000003", name: "Adventure Works", accountnumber: "ACC-1003", telephone1: "+1 (312) 555-0190", emailaddress1: "sales@adventure.example", websiteurl: "https://adventure.example", revenue: 85000, statuscode: "Inactive", rating: 2 },
|
|
11
|
+
{ accountid: "00000000-0000-0000-0000-000000000004", name: "Wingtip Toys", accountnumber: "ACC-1004", telephone1: "+1 (646) 555-0177", emailaddress1: "contact@wingtip.example", websiteurl: "https://wingtip.example", revenue: 640000, statuscode: "Active", rating: 4 },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Default mock used on localhost when no token URL is configured. Operations are
|
|
16
|
+
* routed through `logCrud`, so a starter page shows the same `[CRUD] …` console
|
|
17
|
+
* lines (and populates `window.dumpAppLogs()`) as a real service.
|
|
18
|
+
*/
|
|
19
|
+
export class MockApiService implements IApiService {
|
|
20
|
+
private accounts: Record<string, any> = Object.fromEntries(
|
|
21
|
+
SEED_ACCOUNTS.map((a) => [a.accountid, { ...a }]),
|
|
22
|
+
);
|
|
23
|
+
private nextId = SEED_ACCOUNTS.length + 1;
|
|
24
|
+
|
|
25
|
+
retrieveMultipleRecords(entity: string): Promise<{ entities: any[] }> {
|
|
26
|
+
return logCrud(
|
|
27
|
+
{ op: "READ", entity, resultCount: (r) => r?.entities?.length },
|
|
28
|
+
async () => ({
|
|
29
|
+
entities: entity === "accounts" ? Object.values(this.accounts) : [],
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
updateRecord(entity: string, id: string, record: any): Promise<any> {
|
|
35
|
+
return logCrud({ op: "UPDATE", entity, id }, async () => {
|
|
36
|
+
const key = id.replace(/[{}]/g, "");
|
|
37
|
+
if (this.accounts[key]) {
|
|
38
|
+
this.accounts[key] = { ...this.accounts[key], ...record };
|
|
39
|
+
}
|
|
40
|
+
return { success: true };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
createRecord(entity: string, record: any): Promise<any> {
|
|
45
|
+
return logCrud({ op: "CREATE", entity, resultId: (r) => r?.id }, async () => {
|
|
46
|
+
const id = `00000000-0000-0000-0000-${String(this.nextId++).padStart(12, "0")}`;
|
|
47
|
+
if (entity === "accounts") this.accounts[id] = { accountid: id, ...record };
|
|
48
|
+
return { id };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
deleteRecord(entity: string, id: string): Promise<void> {
|
|
53
|
+
return logCrud({ op: "DELETE", entity, id }, async () => {
|
|
54
|
+
delete this.accounts[id.replace(/[{}]/g, "")];
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
executeRequest(requestName: string): Promise<any> {
|
|
59
|
+
return logCrud({ op: "EXECUTE", entity: requestName }, async () => ({}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async uploadFile(): Promise<string> {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
associateRecord(entityName: string, entityId: string): Promise<void> {
|
|
67
|
+
return logCrud(
|
|
68
|
+
{ op: "ASSOCIATE", entity: entityName, id: entityId },
|
|
69
|
+
async () => undefined,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -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
|
+
}
|