@khester/create-dynamics-app 2.6.2 → 2.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/artifacts/registry.d.ts.map +1 -1
  2. package/dist/artifacts/registry.js +70 -0
  3. package/dist/artifacts/registry.js.map +1 -1
  4. package/dist/artifacts/types.d.ts +17 -1
  5. package/dist/artifacts/types.d.ts.map +1 -1
  6. package/dist/index.js +3 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/injectSharedModules.d.ts +21 -0
  9. package/dist/injectSharedModules.d.ts.map +1 -0
  10. package/dist/injectSharedModules.js +84 -0
  11. package/dist/injectSharedModules.js.map +1 -0
  12. package/dist/scaffold.d.ts +1 -0
  13. package/dist/scaffold.d.ts.map +1 -1
  14. package/dist/scaffold.js +2 -0
  15. package/dist/scaffold.js.map +1 -1
  16. package/package.json +1 -1
  17. package/templates/{grid-starter/src/core/services → _shared/dataverse-seam}/FetchApiService.ts +3 -8
  18. package/templates/{grid-starter/src/core/services → _shared/dataverse-seam}/IApiService.ts +16 -8
  19. package/templates/{react-custom-page/src/core/services → _shared/dataverse-seam}/XrmApiService.ts +6 -17
  20. package/templates/grid-starter/src/core/services/MockApiService.ts +0 -4
  21. package/templates/host-swap/README.md +35 -0
  22. package/templates/host-swap/_variants/host.custom-page/env.example +5 -0
  23. package/templates/host-swap/_variants/host.custom-page/gitignore +5 -0
  24. package/templates/host-swap/_variants/host.custom-page/index.html +12 -0
  25. package/templates/host-swap/_variants/host.custom-page/package.json +31 -0
  26. package/templates/host-swap/_variants/host.custom-page/src/App.tsx +14 -0
  27. package/templates/host-swap/_variants/host.custom-page/src/index.tsx +17 -0
  28. package/templates/host-swap/_variants/host.custom-page/tsconfig.json +19 -0
  29. package/templates/host-swap/_variants/host.custom-page/vite.config.ts +63 -0
  30. package/templates/host-swap/_variants/host.pcf/ControlManifest.Input.xml +17 -0
  31. package/templates/host-swap/_variants/host.pcf/featureconfig.json +3 -0
  32. package/templates/host-swap/_variants/host.pcf/gitignore +5 -0
  33. package/templates/host-swap/_variants/host.pcf/index.ts +44 -0
  34. package/templates/host-swap/_variants/host.pcf/package.json +31 -0
  35. package/templates/host-swap/_variants/host.pcf/pcf-env.d.ts +9 -0
  36. package/templates/host-swap/_variants/host.pcf/strings/{{componentName}}.1033.resx +31 -0
  37. package/templates/host-swap/_variants/host.pcf/tsconfig.json +9 -0
  38. package/templates/host-swap/src/Component.tsx +89 -0
  39. package/templates/host-swap/src/core/services/MockApiService.ts +58 -0
  40. package/templates/react-custom-page/src/core/services/MockApiService.ts +0 -4
  41. package/templates/grid-starter/src/core/services/XrmApiService.ts +0 -135
  42. package/templates/react-custom-page/src/core/services/FetchApiService.ts +0 -117
  43. package/templates/react-custom-page/src/core/services/IApiService.ts +0 -37
  44. package/templates/react-custom-page/src/core/services/ServiceFactory.ts +0 -58
  45. package/templates/react-custom-page/src/core/services/crudLogging.ts +0 -52
  46. /package/templates/{grid-starter/src/core/services → _shared/dataverse-seam}/ServiceFactory.ts +0 -0
  47. /package/templates/{grid-starter/src/core/services → _shared/dataverse-seam}/crudLogging.ts +0 -0
@@ -0,0 +1,12 @@
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>{{projectName}}</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/index.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "{{projectName}} — host-swap component hosted as a Dynamics custom page (Vite)",
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
+ "preview": "vite preview",
15
+ "typecheck": "tsc --noEmit",
16
+ "clean": "rimraf dist"
17
+ },
18
+ "dependencies": {
19
+ "@fluentui/react": "^8.110.10",
20
+ "react": "^18.2.0",
21
+ "react-dom": "^18.2.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/react": "^18.2.0",
25
+ "@types/react-dom": "^18.2.0",
26
+ "@vitejs/plugin-react": "^4.2.1",
27
+ "rimraf": "^5.0.5",
28
+ "typescript": "^5.3.3",
29
+ "vite": "^5.4.21"
30
+ }
31
+ }
@@ -0,0 +1,14 @@
1
+ import * as React from "react";
2
+ import { ServiceFactory } from "./core/services/ServiceFactory";
3
+ import { Component } from "./Component";
4
+
5
+ // Resolve the Xrm context when hosted as a Dynamics custom page (the app runs in
6
+ // an iframe → window.parent.Xrm, or window.Xrm). ServiceFactory then picks the
7
+ // IApiService per environment: Mock on localhost, the token-proxy in dev, or the
8
+ // production XrmApiService when deployed.
9
+ const Xrm = (window.parent as any)?.Xrm ?? (window as any).Xrm;
10
+
11
+ export const App: React.FC = () => {
12
+ const api = React.useMemo(() => ServiceFactory.createApiService(Xrm), []);
13
+ return <Component api={api} />;
14
+ };
@@ -0,0 +1,17 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { initLogging } from "@khester/reusable-components";
4
+ import { App } from "./App";
5
+
6
+ // Wire up the localhost-only troubleshooting hooks (window.dumpAppLogs()); no-op
7
+ // in a live org.
8
+ initLogging();
9
+
10
+ const container = document.getElementById("root");
11
+ if (!container) throw new Error('Root element "#root" not found');
12
+
13
+ createRoot(container).render(
14
+ <React.StrictMode>
15
+ <App />
16
+ </React.StrictMode>,
17
+ );
@@ -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"]
17
+ },
18
+ "include": ["src"]
19
+ }
@@ -0,0 +1,63 @@
1
+ import { defineConfig, loadEnv } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ /**
7
+ * Read a single key from .env / .env.local at request time, so a refreshed
8
+ * `npm run auth:token` takes effect without restarting the dev server.
9
+ */
10
+ function readEnvValue(key: string): string | undefined {
11
+ for (const file of [".env.local", ".env"]) {
12
+ const p = path.resolve(process.cwd(), file);
13
+ if (!fs.existsSync(p)) continue;
14
+ for (const line of fs.readFileSync(p, "utf8").split(/\r?\n/)) {
15
+ const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
16
+ if (m && m[1] === key) return m[2];
17
+ }
18
+ }
19
+ return undefined;
20
+ }
21
+
22
+ export default defineConfig(({ mode }) => {
23
+ // DYNAMICS_URL / DYNAMICS_TOKEN are written to .env by `npm run auth:token`.
24
+ const env = loadEnv(mode, process.cwd(), "");
25
+ const dynamicsUrl = env.DYNAMICS_URL;
26
+
27
+ return {
28
+ // Relative base — the built bundle is served from the Dataverse web-resource
29
+ // CDN under a long prefixed path, where absolute /assets URLs would 404.
30
+ base: "./",
31
+ plugins: [react()],
32
+ server: {
33
+ port: 3000,
34
+ // Token-proxy mode: forward /api/data/* to Dataverse and inject the bearer
35
+ // SERVER-SIDE, so the token is never read by or bundled into the client.
36
+ proxy: dynamicsUrl
37
+ ? {
38
+ "/api/data": {
39
+ target: dynamicsUrl,
40
+ changeOrigin: true,
41
+ secure: true,
42
+ configure: (proxy) => {
43
+ proxy.on("proxyReq", (proxyReq) => {
44
+ const token = readEnvValue("DYNAMICS_TOKEN");
45
+ if (token) proxyReq.setHeader("Authorization", `Bearer ${token}`);
46
+ proxyReq.setHeader("OData-Version", "4.0");
47
+ proxyReq.setHeader("OData-MaxVersion", "4.0");
48
+ });
49
+ },
50
+ },
51
+ }
52
+ : undefined,
53
+ },
54
+ define: {
55
+ // Client flag for ServiceFactory — a real boolean (Vite stringifies it).
56
+ // True only when a Dataverse URL is configured (token-proxy dev mode).
57
+ "import.meta.env.VITE_USE_PROXY": Boolean(dynamicsUrl),
58
+ },
59
+ build: {
60
+ outDir: "dist",
61
+ },
62
+ };
63
+ });
@@ -0,0 +1,17 @@
1
+ <?xml version="1.0" encoding="utf-8" ?>
2
+ <manifest>
3
+ <control namespace="{{namespace}}" constructor="{{componentName}}" version="1.0.0" display-name-key="{{namespace}}_{{componentName}}" description-key="{{namespace}}_{{componentName}}_Desc" control-type="virtual">
4
+ <!-- A bound property so the control can be placed on a form field. The
5
+ component ignores it — it fetches its own data via the Web API. -->
6
+ <property name="value" display-name-key="value_Display_Key" description-key="value_Desc_Key" of-type="SingleLine.Text" usage="bound" required="false" />
7
+ <resources>
8
+ <code path="index.ts" order="1"/>
9
+ <platform-library name="React" version="18.2.0" />
10
+ <platform-library name="Fluent" version="8.115.6" />
11
+ <resx path="strings/{{componentName}}.1033.resx" version="1.0.0" />
12
+ </resources>
13
+ <feature-usage>
14
+ <uses-feature name="WebAPI" required="true" />
15
+ </feature-usage>
16
+ </control>
17
+ </manifest>
@@ -0,0 +1,3 @@
1
+ {
2
+ "pcfReactPlatformLibraries": "on"
3
+ }
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ out/
3
+ generated/
4
+ *.log
5
+ .env
@@ -0,0 +1,44 @@
1
+ import { IInputs, IOutputs } from "./generated/ManifestTypes";
2
+ import * as React from "react";
3
+ import { Component } from "./src/Component";
4
+ import type { IApiService } from "./src/core/services/IApiService";
5
+ import { FetchApiService } from "./src/core/services/FetchApiService";
6
+ import { MockApiService } from "./src/core/services/MockApiService";
7
+
8
+ /**
9
+ * PCF host wrapper for the shared Component. A React (virtual) control: the
10
+ * platform owns the React root, so updateView returns the element.
11
+ *
12
+ * It builds the IApiService DIRECTLY rather than via the Vite-coupled
13
+ * ServiceFactory: in a model-driven host the control is served from the org
14
+ * origin, so a same-origin relative `/api/data/v9.2` fetch is authenticated by
15
+ * the session cookie — no Xrm or clientUrl needed. On localhost (the pcf-scripts
16
+ * test harness) it falls back to the in-memory mock so the control renders with
17
+ * no org.
18
+ */
19
+ export class {{componentName}} implements ComponentFramework.ReactControl<IInputs, IOutputs> {
20
+ private api!: IApiService;
21
+
22
+ public init(
23
+ _context: ComponentFramework.Context<IInputs>,
24
+ _notifyOutputChanged: () => void,
25
+ _state: ComponentFramework.Dictionary,
26
+ ): void {
27
+ const onLocalhost =
28
+ typeof window !== "undefined" &&
29
+ /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
30
+ this.api = onLocalhost ? new MockApiService() : new FetchApiService("");
31
+ }
32
+
33
+ public updateView(_context: ComponentFramework.Context<IInputs>): React.ReactElement {
34
+ return React.createElement(Component, { api: this.api });
35
+ }
36
+
37
+ public getOutputs(): IOutputs {
38
+ return {};
39
+ }
40
+
41
+ public destroy(): void {
42
+ // React root teardown is handled by the platform for virtual controls.
43
+ }
44
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "version": "1.0.0",
4
+ "description": "{{componentName}} — host-swap component hosted as a PCF control",
5
+ "scripts": {
6
+ "build": "pcf-scripts build",
7
+ "clean": "pcf-scripts clean",
8
+ "rebuild": "pcf-scripts rebuild",
9
+ "start": "pcf-scripts start",
10
+ "refreshTypes": "pcf-scripts refreshTypes"
11
+ },
12
+ "dependencies": {
13
+ "@fluentui/react": "^8.120.0",
14
+ "@types/powerapps-component-framework": "^1.3.18",
15
+ "react": "^18.2.0",
16
+ "react-dom": "^18.2.0"
17
+ },
18
+ "devDependencies": {
19
+ "@microsoft/eslint-config-spfx": "^1.18.2",
20
+ "@types/node": "^18.16.9",
21
+ "@types/react": "^18.2.0",
22
+ "@types/react-dom": "^18.2.0",
23
+ "eslint-plugin-react-hooks": "^4.6.0",
24
+ "pcf-scripts": "^1",
25
+ "pcf-start": "^1",
26
+ "typescript": "^5.3.0"
27
+ },
28
+ "browserslist": [
29
+ "last 2 versions"
30
+ ]
31
+ }
@@ -0,0 +1,9 @@
1
+ // PCF builds have no `vite/client` types. The shared data-access seam
2
+ // (templates/_shared/dataverse-seam) includes ServiceFactory, which reads
3
+ // `import.meta.env.VITE_USE_PROXY` for the Vite custom-page host. The PCF wrapper
4
+ // does NOT use ServiceFactory (it builds FetchApiService/MockApiService directly),
5
+ // so the file is tree-shaken out of the bundle — but pcf-scripts still typechecks
6
+ // it. This minimal ambient declaration lets that unused file compile here.
7
+ interface ImportMeta {
8
+ readonly env: Record<string, unknown>;
9
+ }
@@ -0,0 +1,31 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <root>
3
+ <resheader name="resmimetype">
4
+ <value>text/microsoft-resx</value>
5
+ </resheader>
6
+ <resheader name="version">
7
+ <value>2.0</value>
8
+ </resheader>
9
+ <resheader name="reader">
10
+ <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
11
+ </resheader>
12
+ <resheader name="writer">
13
+ <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
14
+ </resheader>
15
+ <data name="{{namespace}}_{{componentName}}" xml:space="preserve">
16
+ <value>{{componentName}}</value>
17
+ <comment>Name of the control</comment>
18
+ </data>
19
+ <data name="{{namespace}}_{{componentName}}_Desc" xml:space="preserve">
20
+ <value>{{componentName}} host-swap control built with Dynamics UI Kit</value>
21
+ <comment>Description of the control</comment>
22
+ </data>
23
+ <data name="value_Display_Key" xml:space="preserve">
24
+ <value>Value</value>
25
+ <comment>Display name for the bound property</comment>
26
+ </data>
27
+ <data name="value_Desc_Key" xml:space="preserve">
28
+ <value>Bound field (ignored — the component fetches its own data)</value>
29
+ <comment>Description for the bound property</comment>
30
+ </data>
31
+ </root>
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "./node_modules/pcf-scripts/tsconfig_base.json",
3
+ "compilerOptions": {
4
+ "typeRoots": ["node_modules/@types"],
5
+ "jsx": "react",
6
+ "esModuleInterop": true,
7
+ "experimentalDecorators": true
8
+ }
9
+ }
@@ -0,0 +1,89 @@
1
+ import * as React from "react";
2
+ import {
3
+ DetailsList,
4
+ SelectionMode,
5
+ MessageBar,
6
+ MessageBarType,
7
+ Spinner,
8
+ Stack,
9
+ Text,
10
+ type IColumn,
11
+ } from "@fluentui/react";
12
+ import { initializeIcons } from "@fluentui/react/lib/Icons";
13
+ import type { IApiService } from "./core/services/IApiService";
14
+
15
+ // Initialize Fluent UI icons once (DetailsList sort/column glyphs).
16
+ initializeIcons();
17
+
18
+ /**
19
+ * The ONE host-agnostic component. It depends on nothing but an injected
20
+ * `IApiService` — no PCF `context`, no Xrm, no lifecycle, no bundler globals — so
21
+ * the SAME source renders unchanged inside a PCF control and a Dynamics custom
22
+ * page. Each host's thin wrapper builds the right `IApiService` and mounts this.
23
+ */
24
+ export interface ComponentProps {
25
+ api: IApiService;
26
+ }
27
+
28
+ const ACCOUNTS_FETCHXML = [
29
+ '<fetch top="25">',
30
+ ' <entity name="account">',
31
+ ' <attribute name="accountid" />',
32
+ ' <attribute name="name" />',
33
+ ' <attribute name="telephone1" />',
34
+ ' <order attribute="name" />',
35
+ " </entity>",
36
+ "</fetch>",
37
+ ].join("\n");
38
+
39
+ interface AccountRow {
40
+ accountid: string;
41
+ name?: string;
42
+ telephone1?: string;
43
+ }
44
+
45
+ const COLUMNS: IColumn[] = [
46
+ { key: "name", name: "Account", fieldName: "name", minWidth: 180, maxWidth: 320, isResizable: true },
47
+ { key: "telephone1", name: "Phone", fieldName: "telephone1", minWidth: 140, isResizable: true },
48
+ ];
49
+
50
+ export const Component: React.FC<ComponentProps> = ({ api }) => {
51
+ const [rows, setRows] = React.useState<AccountRow[] | null>(null);
52
+ const [error, setError] = React.useState<string | null>(null);
53
+
54
+ React.useEffect(() => {
55
+ let cancelled = false;
56
+ api
57
+ .retrieveMultipleRecords("accounts", ACCOUNTS_FETCHXML)
58
+ .then((res) => {
59
+ if (!cancelled) setRows(res.entities as AccountRow[]);
60
+ })
61
+ .catch((e) => {
62
+ if (!cancelled) setError(e instanceof Error ? e.message : String(e));
63
+ });
64
+ return () => {
65
+ cancelled = true;
66
+ };
67
+ }, [api]);
68
+
69
+ return (
70
+ <Stack tokens={{ childrenGap: 12 }} styles={{ root: { padding: 16 } }}>
71
+ <Text variant="xLarge">Accounts</Text>
72
+ <Text variant="small">
73
+ One host-agnostic component — the same source renders in a PCF control and a
74
+ Dynamics custom page; only the thin host wrapper changes.
75
+ </Text>
76
+ {error && <MessageBar messageBarType={MessageBarType.error}>{error}</MessageBar>}
77
+ {!rows && !error && <Spinner label="Loading accounts…" />}
78
+ {rows && (
79
+ <DetailsList
80
+ items={rows}
81
+ columns={COLUMNS}
82
+ setKey="accounts"
83
+ getKey={(item: AccountRow) => item.accountid}
84
+ selectionMode={SelectionMode.none}
85
+ />
86
+ )}
87
+ </Stack>
88
+ );
89
+ };
@@ -0,0 +1,58 @@
1
+ import { IApiService } from "./IApiService";
2
+ import { logCrud } from "./crudLogging";
3
+
4
+ // A few seeded in-memory accounts so the control/page renders with no org or
5
+ // token (the PCF test harness and `npm run dev` both land here). Replace this
6
+ // seed as your component grows. The other IApiService impls (Xrm/Fetch) and the
7
+ // shared contract are pulled from templates/_shared/dataverse-seam at scaffold.
8
+ const SEED_ACCOUNTS = [
9
+ { accountid: "00000000-0000-0000-0000-000000000001", name: "Contoso Ltd", telephone1: "+1 (425) 555-0100" },
10
+ { accountid: "00000000-0000-0000-0000-000000000002", name: "Fabrikam Inc", telephone1: "+1 (206) 555-0140" },
11
+ { accountid: "00000000-0000-0000-0000-000000000003", name: "Adventure Works", telephone1: "+1 (312) 555-0190" },
12
+ ];
13
+
14
+ export class MockApiService implements IApiService {
15
+ private accounts: Record<string, any> = Object.fromEntries(
16
+ SEED_ACCOUNTS.map((a) => [a.accountid, { ...a }]),
17
+ );
18
+ private nextId = SEED_ACCOUNTS.length + 1;
19
+
20
+ retrieveMultipleRecords(entity: string): Promise<{ entities: any[] }> {
21
+ return logCrud(
22
+ { op: "READ", entity, resultCount: (r) => r?.entities?.length },
23
+ async () => ({
24
+ entities: entity === "accounts" ? Object.values(this.accounts) : [],
25
+ }),
26
+ );
27
+ }
28
+
29
+ createRecord(entity: string, record: any): Promise<any> {
30
+ return logCrud({ op: "CREATE", entity, resultId: (r) => r?.id }, async () => {
31
+ const id = `00000000-0000-0000-0000-${String(this.nextId++).padStart(12, "0")}`;
32
+ if (entity === "accounts") this.accounts[id] = { accountid: id, ...record };
33
+ return { id };
34
+ });
35
+ }
36
+
37
+ updateRecord(entity: string, id: string, record: any): Promise<any> {
38
+ return logCrud({ op: "UPDATE", entity, id }, async () => {
39
+ const key = id.replace(/[{}]/g, "");
40
+ if (this.accounts[key]) this.accounts[key] = { ...this.accounts[key], ...record };
41
+ return { success: true };
42
+ });
43
+ }
44
+
45
+ deleteRecord(entity: string, id: string): Promise<void> {
46
+ return logCrud({ op: "DELETE", entity, id }, async () => {
47
+ delete this.accounts[id.replace(/[{}]/g, "")];
48
+ });
49
+ }
50
+
51
+ executeRequest(requestName: string): Promise<any> {
52
+ return logCrud({ op: "EXECUTE", entity: requestName }, async () => ({}));
53
+ }
54
+
55
+ associateRecord(entity: string, id: string): Promise<void> {
56
+ return logCrud({ op: "ASSOCIATE", entity, id }, async () => undefined);
57
+ }
58
+ }
@@ -60,10 +60,6 @@ export class MockApiService implements IApiService {
60
60
  return logCrud({ op: "EXECUTE", entity: requestName }, async () => ({}));
61
61
  }
62
62
 
63
- async uploadFile(): Promise<string> {
64
- return "";
65
- }
66
-
67
63
  associateRecord(entityName: string, entityId: string): Promise<void> {
68
64
  return logCrud(
69
65
  { op: "ASSOCIATE", entity: entityName, id: entityId },
@@ -1,135 +0,0 @@
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
- }