@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,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build + deploy: upload the single-file `dist/index.html` (from `npm run
|
|
4
|
+
* build:d365`) to Dataverse as an HTML web resource, then publish it.
|
|
5
|
+
*
|
|
6
|
+
* Reads DYNAMICS_URL + DYNAMICS_TOKEN from `.env` (run `npm run auth:token`
|
|
7
|
+
* first, or use `npm run deploy`, which refreshes the token for you).
|
|
8
|
+
*
|
|
9
|
+
* The web-resource unique name MUST start with a publisher prefix that exists in
|
|
10
|
+
* your org (e.g. `cr1a2_` or `new_`). Set it via WEBRESOURCE_NAME in `.env`, or:
|
|
11
|
+
* node tools/deploy/deploy-webresource.cjs --name cr1a2_/myapp/index.html --solution MySolution
|
|
12
|
+
*/
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
|
|
16
|
+
function arg(name) {
|
|
17
|
+
const i = process.argv.indexOf(name);
|
|
18
|
+
return i !== -1 ? process.argv[i + 1] : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readEnv(file) {
|
|
22
|
+
if (!fs.existsSync(file)) return {};
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
25
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
26
|
+
if (m) out[m[1]] = m[2];
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const root = process.cwd();
|
|
32
|
+
const env = readEnv(path.join(root, ".env"));
|
|
33
|
+
const url = (arg("--url") || process.env.DYNAMICS_URL || env.DYNAMICS_URL || "").replace(/\/+$/, "");
|
|
34
|
+
const token = arg("--token") || process.env.DYNAMICS_TOKEN || env.DYNAMICS_TOKEN;
|
|
35
|
+
|
|
36
|
+
let pkgName = "custompage";
|
|
37
|
+
try {
|
|
38
|
+
pkgName = require(path.join(root, "package.json")).name || pkgName;
|
|
39
|
+
} catch {
|
|
40
|
+
/* keep default */
|
|
41
|
+
}
|
|
42
|
+
const name = arg("--name") || process.env.WEBRESOURCE_NAME || env.WEBRESOURCE_NAME || `new_/${pkgName}/index.html`;
|
|
43
|
+
const solution = arg("--solution") || process.env.WEBRESOURCE_SOLUTION || env.WEBRESOURCE_SOLUTION;
|
|
44
|
+
|
|
45
|
+
if (!url || !token) {
|
|
46
|
+
console.error("✗ Missing DYNAMICS_URL / DYNAMICS_TOKEN. Run `npm run auth:token -- --url https://<org>.crm.dynamics.com` first.");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const file = path.join(root, "dist", "index.html");
|
|
51
|
+
if (!fs.existsSync(file)) {
|
|
52
|
+
console.error("✗ dist/index.html not found. Run `npm run build:d365` first.");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
const content = fs.readFileSync(file).toString("base64");
|
|
56
|
+
|
|
57
|
+
const api = `${url}/api/data/v9.2`;
|
|
58
|
+
const headers = {
|
|
59
|
+
Authorization: `Bearer ${token}`,
|
|
60
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
61
|
+
Accept: "application/json",
|
|
62
|
+
"OData-Version": "4.0",
|
|
63
|
+
"OData-MaxVersion": "4.0",
|
|
64
|
+
};
|
|
65
|
+
if (solution) headers["MSCRM.SolutionUniqueName"] = solution;
|
|
66
|
+
|
|
67
|
+
async function req(method, urlPath, body) {
|
|
68
|
+
const res = await fetch(urlPath.startsWith("http") ? urlPath : `${api}/${urlPath}`, {
|
|
69
|
+
method,
|
|
70
|
+
headers,
|
|
71
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const text = await res.text().catch(() => "");
|
|
75
|
+
throw new Error(`${method} ${urlPath} → ${res.status}: ${text}`);
|
|
76
|
+
}
|
|
77
|
+
return res;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function main() {
|
|
81
|
+
console.log(`→ Deploying "${name}" to ${url} ...`);
|
|
82
|
+
|
|
83
|
+
// 1. Upsert the web resource (find by unique name, then PATCH or POST).
|
|
84
|
+
const findRes = await req("GET", `webresourceset?$select=webresourceid&$filter=name eq '${encodeURIComponent(name)}'`);
|
|
85
|
+
const found = (await findRes.json()).value || [];
|
|
86
|
+
let id;
|
|
87
|
+
if (found.length) {
|
|
88
|
+
id = found[0].webresourceid;
|
|
89
|
+
await req("PATCH", `webresourceset(${id})`, { content });
|
|
90
|
+
console.log(`✓ Updated existing web resource (${id}).`);
|
|
91
|
+
} else {
|
|
92
|
+
const createRes = await req("POST", "webresourceset", {
|
|
93
|
+
name,
|
|
94
|
+
displayname: name,
|
|
95
|
+
webresourcetype: 1, // HTML
|
|
96
|
+
content,
|
|
97
|
+
});
|
|
98
|
+
const loc = createRes.headers.get("OData-EntityId") || "";
|
|
99
|
+
const match = /\(([^)]+)\)/.exec(loc);
|
|
100
|
+
id = match ? match[1] : null;
|
|
101
|
+
console.log(`✓ Created web resource (${id}).`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. Publish it.
|
|
105
|
+
const paramXml =
|
|
106
|
+
"<importexportxml><webresources><webresource>{" + id + "}</webresource></webresources></importexportxml>";
|
|
107
|
+
await req("POST", "PublishXml", { ParameterXml: paramXml });
|
|
108
|
+
console.log("✓ Published.");
|
|
109
|
+
|
|
110
|
+
console.log(`\nOpen it: ${url}/WebResources/${name}`);
|
|
111
|
+
console.log("Or host it on a model-driven custom page / open via Xrm.Navigation.navigateTo.");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
main().catch((e) => {
|
|
115
|
+
console.error("✗ Deploy failed:", e.message);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"resolveJsonModule": true,
|
|
9
|
+
"isolatedModules": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"strict": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
|
+
"types": ["vite/client", "vitest/globals"]
|
|
17
|
+
},
|
|
18
|
+
"include": ["src"]
|
|
19
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/// <reference types="vitest/config" />
|
|
2
|
+
import { defineConfig, loadEnv } from "vite";
|
|
3
|
+
import react from "@vitejs/plugin-react";
|
|
4
|
+
import { viteSingleFile } from "vite-plugin-singlefile";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read a single key from .env / .env.local at request time. Re-reading per
|
|
10
|
+
* request (rather than capturing it at config eval) means a refreshed
|
|
11
|
+
* `npm run auth:token` takes effect WITHOUT restarting the dev server.
|
|
12
|
+
*/
|
|
13
|
+
function readEnvValue(key: string): string | undefined {
|
|
14
|
+
for (const file of [".env.local", ".env"]) {
|
|
15
|
+
const p = path.resolve(process.cwd(), file);
|
|
16
|
+
if (!fs.existsSync(p)) continue;
|
|
17
|
+
for (const line of fs.readFileSync(p, "utf8").split(/\r?\n/)) {
|
|
18
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
19
|
+
if (m && m[1] === key) return m[2];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default defineConfig(({ mode }) => {
|
|
26
|
+
// DYNAMICS_URL / DYNAMICS_TOKEN are written to .env by `npm run auth:token`.
|
|
27
|
+
// Empty prefix loads unprefixed keys; we read ONLY the two below by name and
|
|
28
|
+
// never spread `env` into `define` (that would leak the whole environment).
|
|
29
|
+
const env = loadEnv(mode, process.cwd(), "");
|
|
30
|
+
const dynamicsUrl = env.DYNAMICS_URL;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
// Relative base — the built bundle is served from the Dataverse web-resource
|
|
34
|
+
// CDN under a long prefixed path, where absolute /assets URLs would 404.
|
|
35
|
+
base: "./",
|
|
36
|
+
plugins: [react(), ...(mode === "d365" ? [viteSingleFile()] : [])],
|
|
37
|
+
server: {
|
|
38
|
+
port: 3000,
|
|
39
|
+
// Token-proxy mode: forward /api/data/* to Dataverse and inject the bearer
|
|
40
|
+
// SERVER-SIDE, so the token is never read by or bundled into the client.
|
|
41
|
+
proxy: dynamicsUrl
|
|
42
|
+
? {
|
|
43
|
+
"/api/data": {
|
|
44
|
+
target: dynamicsUrl,
|
|
45
|
+
changeOrigin: true,
|
|
46
|
+
secure: true,
|
|
47
|
+
configure: (proxy) => {
|
|
48
|
+
proxy.on("proxyReq", (proxyReq) => {
|
|
49
|
+
const token = readEnvValue("DYNAMICS_TOKEN");
|
|
50
|
+
if (token) proxyReq.setHeader("Authorization", `Bearer ${token}`);
|
|
51
|
+
proxyReq.setHeader("OData-Version", "4.0");
|
|
52
|
+
proxyReq.setHeader("OData-MaxVersion", "4.0");
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
: undefined,
|
|
58
|
+
},
|
|
59
|
+
define: {
|
|
60
|
+
// Client flag for ServiceFactory — a real boolean (Vite stringifies it).
|
|
61
|
+
// True only when a Dataverse URL is configured (token-proxy dev mode).
|
|
62
|
+
"import.meta.env.VITE_USE_PROXY": Boolean(dynamicsUrl),
|
|
63
|
+
// The org URL (NOT the token) for the dev panel's Environment readout. Empty
|
|
64
|
+
// in a production build, so nothing leaks into a deployed web resource.
|
|
65
|
+
"import.meta.env.VITE_DYNAMICS_URL": JSON.stringify(dynamicsUrl ?? ""),
|
|
66
|
+
},
|
|
67
|
+
build: {
|
|
68
|
+
outDir: "dist",
|
|
69
|
+
},
|
|
70
|
+
test: {
|
|
71
|
+
// Pure layers only (diff, mappers) — no DOM needed.
|
|
72
|
+
globals: true,
|
|
73
|
+
include: ["src/**/*.test.ts"],
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
});
|
|
@@ -7,6 +7,8 @@ export interface ValueInputProps {
|
|
|
7
7
|
disabled?: boolean;
|
|
8
8
|
onChange: (value: boolean) => void;
|
|
9
9
|
onBlur: () => void;
|
|
10
|
+
/** Optional PCF context (forwarded uniformly; only the optionset variant reads it). */
|
|
11
|
+
context?: ComponentFramework.Context<any>;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export const ValueInput: React.FC<ValueInputProps> = ({
|
|
@@ -7,6 +7,8 @@ export interface ValueInputProps {
|
|
|
7
7
|
disabled?: boolean;
|
|
8
8
|
onChange: (value: Date | null) => void;
|
|
9
9
|
onBlur: () => void;
|
|
10
|
+
/** Optional PCF context (forwarded uniformly; only the optionset variant reads it). */
|
|
11
|
+
context?: ComponentFramework.Context<any>;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export const ValueInput: React.FC<ValueInputProps> = ({
|
|
@@ -7,6 +7,8 @@ export interface ValueInputProps {
|
|
|
7
7
|
disabled?: boolean;
|
|
8
8
|
onChange: (value: number | null) => void;
|
|
9
9
|
onBlur: () => void;
|
|
10
|
+
/** Optional PCF context (forwarded uniformly; only the optionset variant reads it). */
|
|
11
|
+
context?: ComponentFramework.Context<any>;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export const ValueInput: React.FC<ValueInputProps> = ({
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Dropdown, IDropdownOption } from '@fluentui/react/lib/Dropdown';
|
|
3
|
+
import { TextField } from '@fluentui/react/lib/TextField';
|
|
4
|
+
|
|
5
|
+
export interface ValueInputProps {
|
|
6
|
+
value: number | null;
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
onChange: (value: number | null) => void;
|
|
10
|
+
onBlur: () => void;
|
|
11
|
+
/**
|
|
12
|
+
* The bound PCF context. OptionSet choices are read at runtime from the bound
|
|
13
|
+
* column's metadata: `context.parameters.value.attributes.Options` is the
|
|
14
|
+
* platform-supplied `Array<{ Value: number; Label: string }>` (note the
|
|
15
|
+
* UPPERCASE field names — this is the live PCF API, not the form-builder's
|
|
16
|
+
* design-time config). `context` is typed `<any>`, so this access is
|
|
17
|
+
* unchecked: keep `Options` / `Value` / `Label` spelled exactly.
|
|
18
|
+
*/
|
|
19
|
+
context?: ComponentFramework.Context<any>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const ValueInput: React.FC<ValueInputProps> = ({
|
|
23
|
+
value,
|
|
24
|
+
placeholder,
|
|
25
|
+
disabled,
|
|
26
|
+
onChange,
|
|
27
|
+
onBlur,
|
|
28
|
+
context,
|
|
29
|
+
}) => {
|
|
30
|
+
const rawOptions = context?.parameters?.value?.attributes?.Options as
|
|
31
|
+
| Array<{ Value: number; Label: string }>
|
|
32
|
+
| undefined;
|
|
33
|
+
const options: IDropdownOption[] = (rawOptions ?? []).map(
|
|
34
|
+
(o: { Value: number; Label: string }) => ({ key: o.Value, text: o.Label })
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Degrade to a numeric input when no choice metadata is available — e.g. an
|
|
38
|
+
// unbound control or a test harness. An empty dropdown is unusable, so we fall
|
|
39
|
+
// back to a raw option-value number entry (mirrors the form-builder's
|
|
40
|
+
// optionset → number convention). NOTE: an empty `Options` is ambiguous — it
|
|
41
|
+
// means either "genuinely no choices" or "wrong metadata path"; the unit test
|
|
42
|
+
// pins the path so only the legitimate case reaches here.
|
|
43
|
+
if (options.length === 0) {
|
|
44
|
+
return (
|
|
45
|
+
<TextField
|
|
46
|
+
type="number"
|
|
47
|
+
value={value === null ? '' : String(value)}
|
|
48
|
+
placeholder={placeholder}
|
|
49
|
+
disabled={disabled}
|
|
50
|
+
onChange={(_, v) => {
|
|
51
|
+
if (!v) {
|
|
52
|
+
onChange(null);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const n = Number(v);
|
|
56
|
+
onChange(Number.isNaN(n) ? null : n);
|
|
57
|
+
}}
|
|
58
|
+
onBlur={onBlur}
|
|
59
|
+
styles={{ root: { width: '100%' } }}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Dropdown
|
|
66
|
+
selectedKey={value ?? undefined}
|
|
67
|
+
placeholder={placeholder}
|
|
68
|
+
disabled={disabled}
|
|
69
|
+
options={options}
|
|
70
|
+
onChange={(_, option) =>
|
|
71
|
+
onChange(typeof option?.key === 'number' ? option.key : null)
|
|
72
|
+
}
|
|
73
|
+
onBlur={onBlur}
|
|
74
|
+
styles={{ root: { width: '100%' } }}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
};
|
|
@@ -7,6 +7,8 @@ export interface ValueInputProps {
|
|
|
7
7
|
disabled?: boolean;
|
|
8
8
|
onChange: (value: string) => void;
|
|
9
9
|
onBlur: () => void;
|
|
10
|
+
/** Optional PCF context (forwarded uniformly; only the optionset variant reads it). */
|
|
11
|
+
context?: ComponentFramework.Context<any>;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export const ValueInput: React.FC<ValueInputProps> = ({
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@fluentui/react": "^8.110.10",
|
|
14
|
-
"@types/powerapps-component-framework": "^1.3.4"
|
|
14
|
+
"@types/powerapps-component-framework": "^1.3.4",
|
|
15
|
+
"react": "^16.14.0",
|
|
16
|
+
"react-dom": "^16.14.0"
|
|
15
17
|
},
|
|
16
18
|
"devDependencies": {
|
|
17
19
|
"@microsoft/eslint-config-spfx": "^1.18.2",
|
|
@@ -20,6 +20,7 @@ export const {{componentName}}Component: React.FC<I{{componentName}}ComponentPro
|
|
|
20
20
|
disabled,
|
|
21
21
|
onChange,
|
|
22
22
|
onBlur,
|
|
23
|
+
context,
|
|
23
24
|
}) => {
|
|
24
25
|
return (
|
|
25
26
|
<div style={{ width: '100%', height: '100%' }}>
|
|
@@ -29,6 +30,7 @@ export const {{componentName}}Component: React.FC<I{{componentName}}ComponentPro
|
|
|
29
30
|
disabled={disabled}
|
|
30
31
|
onChange={onChange}
|
|
31
32
|
onBlur={onBlur}
|
|
33
|
+
context={context}
|
|
32
34
|
/>
|
|
33
35
|
</div>
|
|
34
36
|
);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
A small, layered custom page. The goal is that **business logic lives in pure,
|
|
4
|
+
testable functions** and the React component stays thin.
|
|
5
|
+
|
|
6
|
+
## Layers
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
@khester/reusable-components UI primitives + theme + logging (npm dependency)
|
|
10
|
+
│ (imported)
|
|
11
|
+
▼
|
|
12
|
+
src/example/ ExamplePage.tsx view — JSX only, no CRUD
|
|
13
|
+
hooks/ view-models — state, handlers, dirty tracking
|
|
14
|
+
mappers/ PURE entity <-> form translation (unit-tested)
|
|
15
|
+
models/Account.ts where CRUD lives (calls IApiService)
|
|
16
|
+
│
|
|
17
|
+
▼
|
|
18
|
+
src/domain/ diff.ts PURE change detection (unit-tested)
|
|
19
|
+
│
|
|
20
|
+
▼
|
|
21
|
+
src/core/services/ IApiService the data-access contract
|
|
22
|
+
ServiceFactory picks one impl per environment (the seam)
|
|
23
|
+
Mock / Fetch / Xrm three implementations
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Dependency rule (one direction):** view → hook → pure domain/mapper → model →
|
|
27
|
+
`IApiService`. Nothing below imports from `example/`; `core/` imports nothing app-
|
|
28
|
+
specific. UI/theme/logging are consumed from the package, never reached into.
|
|
29
|
+
|
|
30
|
+
## The thin-form pattern
|
|
31
|
+
|
|
32
|
+
| Concern | Lives in | Why |
|
|
33
|
+
|---------|----------|-----|
|
|
34
|
+
| Rendering | `ExamplePage.tsx` | swap freely; no logic to retest |
|
|
35
|
+
| State + handlers | `hooks/useExampleForm` | view-model, not the view |
|
|
36
|
+
| Load | `hooks/useExampleData` | data fetching isolated from form state |
|
|
37
|
+
| Field ↔ attribute mapping | `mappers/accountMapper` | pure → cheap to unit-test |
|
|
38
|
+
| Change detection | `domain/diff` | pure; sends only changed fields to Dataverse |
|
|
39
|
+
| CRUD | `models/Account` | one place that talks to `IApiService` |
|
|
40
|
+
|
|
41
|
+
The component calls two hooks and renders. It never builds a Dataverse payload or
|
|
42
|
+
calls a service directly.
|
|
43
|
+
|
|
44
|
+
## The ServiceFactory seam
|
|
45
|
+
|
|
46
|
+
`ServiceFactory.createApiService(Xrm)` is the single decision point:
|
|
47
|
+
|
|
48
|
+
- **Mock** (`localhost`, no `DYNAMICS_URL`) → `MockApiService`, in-memory.
|
|
49
|
+
- **Token** (`localhost` + `DYNAMICS_URL`) → `FetchApiService`. `vite.config.ts`
|
|
50
|
+
sets `import.meta.env.VITE_USE_PROXY` (a boolean via `define`) and proxies
|
|
51
|
+
`/api/data/*` to the org, injecting the bearer **server-side**. The token never
|
|
52
|
+
reaches the client bundle — an intentional improvement over inlining it.
|
|
53
|
+
- **Production** (deployed) → `XrmApiService`, hitting the Web API on the same
|
|
54
|
+
origin (session auth). `Xrm` is resolved from `window.parent` in `App.tsx`.
|
|
55
|
+
|
|
56
|
+
All three implement the same `IApiService` and address entities by their Web API
|
|
57
|
+
**set name** (e.g. `accounts`), so reads and writes use one consistent identifier.
|
|
58
|
+
|
|
59
|
+
## Logging
|
|
60
|
+
|
|
61
|
+
`logger` / `withCrudLog` / `initLogging` come from the component library.
|
|
62
|
+
`core/services/crudLogging.ts` binds `withCrudLog` to the Dataverse error shapes
|
|
63
|
+
so every service method emits one structured `[CRUD] …` line. Console output is
|
|
64
|
+
**localhost-gated** (errors always surface); on localhost the buffer is exposed as
|
|
65
|
+
`window.__APP_LOGS__` + `window.dumpAppLogs(filter?)`.
|
|
66
|
+
|
|
67
|
+
## Testing
|
|
68
|
+
|
|
69
|
+
Unit-test the **pure layers** — they hold the rules and need no DOM:
|
|
70
|
+
|
|
71
|
+
- `src/domain/diff.test.ts`
|
|
72
|
+
- `src/example/mappers/accountMapper.test.ts`
|
|
73
|
+
|
|
74
|
+
The view and the `IApiService` implementations are best covered by running the app
|
|
75
|
+
against the mock seam (`npm run dev`). Run `npm run test` (Vitest).
|