@khester/create-dynamics-app 2.10.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +28 -3
  2. package/dist/artifacts/registry.d.ts +7 -5
  3. package/dist/artifacts/registry.d.ts.map +1 -1
  4. package/dist/artifacts/registry.js +82 -27
  5. package/dist/artifacts/registry.js.map +1 -1
  6. package/dist/artifacts/types.d.ts +15 -8
  7. package/dist/artifacts/types.d.ts.map +1 -1
  8. package/dist/index.js +7 -5
  9. package/dist/index.js.map +1 -1
  10. package/dist/scaffold.d.ts.map +1 -1
  11. package/dist/scaffold.js +9 -4
  12. package/dist/scaffold.js.map +1 -1
  13. package/dist/templatePlan.d.ts.map +1 -1
  14. package/dist/templatePlan.js +20 -3
  15. package/dist/templatePlan.js.map +1 -1
  16. package/dist/utils/copyTemplate.d.ts +1 -1
  17. package/dist/utils/copyTemplate.d.ts.map +1 -1
  18. package/dist/utils/copyTemplate.js +5 -1
  19. package/dist/utils/copyTemplate.js.map +1 -1
  20. package/package.json +1 -1
  21. package/templates/_shared/dataverse-seam/XrmApiService.ts +26 -5
  22. package/templates/host-swap/README.md +57 -2
  23. package/templates/host-swap/_variants/host.custom-page/src/App.tsx +9 -5
  24. package/templates/host-swap/_variants/host.pcf/ControlManifest.Input.xml +26 -2
  25. package/templates/host-swap/_variants/host.pcf/gitignore +7 -0
  26. package/templates/host-swap/_variants/host.pcf/index.ts +21 -3
  27. package/templates/host-swap/_variants/host.pcf/package.json +20 -4
  28. package/templates/host-swap/_variants/host.pcf/{{componentName}}.pcfproj +67 -0
  29. package/templates/host-swap/_variants/host.web-resource/src/App.tsx +5 -1
  30. package/templates/host-swap/src/Component.tsx +54 -7
  31. package/templates/host-swap/src/useHostWidth.ts +53 -0
  32. package/templates/pcf-dataset/dev/harness/dataverse.ts +82 -0
  33. package/templates/pcf-dataset/dev/index.html +20 -0
  34. package/templates/pcf-dataset/dev/main.tsx +190 -0
  35. package/templates/pcf-dataset/dev/vite-env.d.ts +1 -0
  36. package/templates/pcf-dataset/env.local.example +24 -0
  37. package/templates/pcf-dataset/eslintrc.json +11 -0
  38. package/templates/pcf-dataset/gitignore +4 -0
  39. package/templates/pcf-dataset/package.json +18 -8
  40. package/templates/pcf-dataset/pcfconfig.json +3 -0
  41. package/templates/pcf-dataset/tsconfig.dev.json +15 -0
  42. package/templates/pcf-dataset/tsconfig.json +11 -2
  43. package/templates/pcf-dataset/vite.config.ts +63 -0
  44. package/templates/pcf-dataset/{{componentName}}/ControlManifest.Input.xml +45 -0
  45. package/templates/pcf-dataset/{{componentName}}/{{componentName}}App.tsx +64 -0
  46. package/templates/pcf-dataset/{{{componentName}}Component.tsx → {{componentName}}/{{componentName}}Component.tsx} +9 -46
  47. package/templates/pcf-field/eslintrc.json +11 -0
  48. package/templates/pcf-field/package.json +1 -0
  49. package/templates/pcf-field/pcfconfig.json +3 -0
  50. package/templates/pcf-field/{{componentName}}/ControlManifest.Input.xml +42 -0
  51. package/templates/power-pages-island/README.md +93 -0
  52. package/templates/power-pages-island/package.json +27 -0
  53. package/templates/power-pages-island/public/index.html +13 -0
  54. package/templates/power-pages-island/src/App.tsx +37 -0
  55. package/templates/power-pages-island/src/Component.tsx +136 -0
  56. package/templates/power-pages-island/src/core/services/MockApiService.ts +58 -0
  57. package/templates/power-pages-island/src/core/services/portalSeam.assert.ts +14 -0
  58. package/templates/power-pages-island/src/island-env.d.ts +12 -0
  59. package/templates/power-pages-island/src/islands.tsx +38 -0
  60. package/templates/power-pages-island/src/useHostWidth.ts +53 -0
  61. package/templates/power-pages-island/tsconfig.json +15 -0
  62. package/templates/power-pages-island/webpack.config.js +22 -0
  63. package/templates/react-custom-page/tools/deploy/deploy-webresource.cjs +8 -2
  64. package/templates/pcf-dataset/ControlManifest.Input.xml +0 -16
  65. package/templates/pcf-field/ControlManifest.Input.xml +0 -17
  66. package/templates/power-pages-starter/.env.example +0 -6
  67. package/templates/power-pages-starter/README.md +0 -95
  68. package/templates/power-pages-starter/package.json +0 -43
  69. package/templates/power-pages-starter/public/index.html +0 -15
  70. package/templates/power-pages-starter/src/components/ContactForm.css +0 -84
  71. package/templates/power-pages-starter/src/components/ContactForm.tsx +0 -258
  72. package/templates/power-pages-starter/src/index.tsx +0 -32
  73. package/templates/power-pages-starter/src/providers/PowerPagesProvider.tsx +0 -162
  74. package/templates/power-pages-starter/src/styles/index.css +0 -76
  75. package/templates/power-pages-starter/tsconfig.json +0 -20
  76. package/templates/power-pages-starter/webpack.config.js +0 -57
  77. package/templates/pcf-dataset/{index.ts → {{componentName}}/index.ts} +0 -0
  78. package/templates/pcf-dataset/{strings → {{componentName}}/strings}/{{componentName}}.1033.resx +0 -0
  79. package/templates/pcf-field/{index.ts → {{componentName}}/index.ts} +0 -0
  80. package/templates/pcf-field/{strings → {{componentName}}/strings}/{{componentName}}.1033.resx +0 -0
  81. package/templates/pcf-field/{{{componentName}}Component.tsx → {{componentName}}/{{componentName}}Component.tsx} +0 -0
  82. package/templates/{power-pages-starter → power-pages-island}/gitignore +1 -1
@@ -1,24 +1,18 @@
1
1
  import * as React from 'react';
2
- import { initializeIcons } from '@fluentui/react/lib/Icons';
3
- import { ReadOnlyGrid, createCellRegistry } from '@dataverse-kit/grid-kit';
4
- import type { ColumnDef, GridSortState } from '@dataverse-kit/grid-kit';
5
-
6
- // Initialize Fluent UI icons — the grid toolbar / column-chooser / filter use them.
7
- initializeIcons();
2
+ import type { ColumnDef } from '@dataverse-kit/grid-kit';
3
+ import { {{componentName}}App, type Row } from './{{componentName}}App';
8
4
 
9
5
  export interface I{{componentName}}ComponentProps {
10
6
  dataset: ComponentFramework.PropertyTypes.DataSet;
11
7
  }
12
8
 
13
- interface Row extends Record<string, unknown> {
14
- key: string;
15
- }
16
-
9
+ /**
10
+ * THIN PCF ADAPTER — maps the bound dataset onto the host-agnostic `<{{componentName}}App>`.
11
+ *
12
+ * All grid behaviour (search, sort, toolbar, column chooser) lives in the App so the dev
13
+ * harness renders the exact same component. Keep this file to dataset→props mapping only.
14
+ */
17
15
  export const {{componentName}}Component: React.FC<I{{componentName}}ComponentProps> = ({ dataset }) => {
18
- const registry = React.useMemo(() => createCellRegistry(), []);
19
- const [search, setSearch] = React.useState('');
20
- const [sort, setSort] = React.useState<GridSortState | undefined>(undefined);
21
-
22
16
  // Map the bound dataset's columns → grid-kit ColumnDef[]. Every column renders as
23
17
  // `text` (the formatted Dataverse value), so client sort/filter is lexical on the
24
18
  // displayed string. For type-aware cells + correct numeric/date ordering, map each
@@ -54,36 +48,5 @@ export const {{componentName}}Component: React.FC<I{{componentName}}ComponentPro
54
48
  return row;
55
49
  });
56
50
 
57
- // grid-kit's toolbar `onSearch` only reports the term — the host owns filtering.
58
- // Substring-match across every column's formatted value.
59
- const q = search.trim().toLowerCase();
60
- const filtered = q
61
- ? items.filter((row) =>
62
- columns.some((c) => String(row[c.fieldName] ?? '').toLowerCase().includes(q)),
63
- )
64
- : items;
65
-
66
- // grid-kit sort is controlled — `onSortChange` reports the header click; the host
67
- // reorders nothing. Sort the (filtered) rows by the formatted string.
68
- const sorted = sort
69
- ? [...filtered].sort(
70
- (a, b) =>
71
- String(a[sort.fieldName] ?? '').localeCompare(String(b[sort.fieldName] ?? '')) *
72
- (sort.direction === 'desc' ? -1 : 1),
73
- )
74
- : filtered;
75
-
76
- return (
77
- <ReadOnlyGrid<Row>
78
- items={sorted}
79
- columns={columns}
80
- registry={registry}
81
- getKey={(r) => r.key}
82
- sort={sort}
83
- onSortChange={(fieldName, direction) => setSort({ fieldName, direction })}
84
- columnChooser
85
- filterBuilder
86
- toolbar={{ showSearch: true, searchPlaceholder: 'Search', onSearch: setSearch }}
87
- />
88
- );
51
+ return <{{componentName}}App items={items} columns={columns} />;
89
52
  };
@@ -0,0 +1,11 @@
1
+ {
2
+ "root": true,
3
+ "parser": "@typescript-eslint/parser",
4
+ "parserOptions": {
5
+ "ecmaVersion": 2021,
6
+ "sourceType": "module",
7
+ "ecmaFeatures": { "jsx": true }
8
+ },
9
+ "env": { "browser": true, "es2021": true },
10
+ "ignorePatterns": ["out/**", "node_modules/**", "**/generated/**"]
11
+ }
@@ -20,6 +20,7 @@
20
20
  "@types/node": "^18.16.9",
21
21
  "@types/react": "^16.14.34",
22
22
  "@types/react-dom": "^16.9.17",
23
+ "@typescript-eslint/parser": "^6.21.0",
23
24
  "eslint-plugin-react-hooks": "^4.6.0",
24
25
  "pcf-scripts": "^1",
25
26
  "pcf-start": "^1",
@@ -0,0 +1,3 @@
1
+ {
2
+ "outDir": "./out/controls"
3
+ }
@@ -0,0 +1,42 @@
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="standard">
4
+ <property name="value" display-name-key="Value" description-key="Value_Desc" of-type="{{propertyType}}" usage="bound" required="true" />
5
+ <property name="placeholder" display-name-key="Placeholder" description-key="Placeholder_Desc" of-type="SingleLine.Text" usage="input" required="false" />
6
+ <property name="disabled" display-name-key="Disabled" description-key="Disabled_Desc" of-type="TwoOptions" usage="input" required="false" default-value="false" />
7
+ <resources>
8
+ <code path="index.ts" order="1"/>
9
+ <!--
10
+ ★ DELIBERATELY NO <platform-library> HERE. This is a control-type="standard" control.
11
+
12
+ Microsoft scopes <platform-library> to "React controls & platform libraries", and
13
+ defines that feature as control-type="virtual" PLUS the platform-library children —
14
+ one feature, not two. Declaring them on a standard control is a half-state that
15
+ builds green and dies at runtime:
16
+
17
+ - With pcfReactPlatformLibraries at its shipped default ("off"),
18
+ platformLibrariesHandler externalises `react` but NOT `react-dom`. So the bundle
19
+ ships ReactDOM 16.14 wired to a platform React the model-driven host actually
20
+ loads as 17.0.2 — a cross-major ReactSharedInternals/event-system mismatch.
21
+ - Turning the flag ON instead externalises react-dom to the `ReactDOMv16` global,
22
+ which the platform only contracts to supply for VIRTUAL controls. Strictly worse.
23
+
24
+ So: bundle our own React 16.14 + Fluent 8. Nothing is externalised, therefore
25
+ installed == compiled == runtime and no version skew is possible. The bundle grows
26
+ (~1-1.5 MB minified); the 5 MB cap is enforced in production mode only.
27
+
28
+ If this control is ever converted to control-type="virtual", the versions declared
29
+ must EQUAL a served platform version, not merely fall inside pcf-scripts' matched
30
+ range — an in-range-but-unequal version builds green and cannot be resolved at
31
+ runtime ("Error loading control" in every host). Live-diagnosed 2026-07-26 on
32
+ host-swap: React 18.2.0 + Fluent 8.115.6, 15/15 build gates green, loaded nowhere.
33
+ Supported then: React 16.14.0 | 18.3.1; @fluentui/react 8.29.0 | 8.121.1;
34
+ @fluentui/react-components >=9.4.0 <=9.46.2. See src/__tests__/registry.test.ts.
35
+ -->
36
+ <resx path="strings/{{componentName}}.1033.resx" version="1.0.0" />
37
+ </resources>
38
+ <feature-usage>
39
+ <uses-feature name="Utility" required="true" />
40
+ </feature-usage>
41
+ </control>
42
+ </manifest>
@@ -0,0 +1,93 @@
1
+ # {{projectName}} — Power Pages island
2
+
3
+ A React **island** you mount into an **existing traditional (Liquid) Power Pages portal** — React
4
+ hydrates only the nodes you tag in Liquid, leaving the rest of the server-rendered page untouched.
5
+
6
+ This is the delta Microsoft's Power Pages tooling doesn't cover: the `pac`/plugin **code-site**
7
+ scaffolder builds a whole-page React SPA that *is* the site. It cannot drop a React widget into an
8
+ existing Liquid portal. This template does exactly that.
9
+
10
+ ## Data access — host-portable seam
11
+
12
+ The widget (`src/Component.tsx`) is written against an injected **`IApiService`** (the create-app
13
+ 6-method `dataverse-seam`). In the browser it's given **`PortalSeamService`** from
14
+ [`@dataverse-kit/portal-api-service`](https://www.npmjs.com/package/@dataverse-kit/portal-api-service),
15
+ which talks to the portal Web API (`/_api/`) and handles the anti-forgery token
16
+ (`__RequestVerificationToken`), 403-retry, and idempotency-aware retry for you. On `localhost` it
17
+ falls back to an in-memory `MockApiService`, so it renders with no portal.
18
+
19
+ Because the widget depends only on the seam, **the same `src/Component.tsx` runs unchanged** as a PCF
20
+ control, a Dynamics custom page, or a web resource (`create-dynamics-app … --artifact host-swap`) — the
21
+ only thing that changes is the host wrapper and the injected service.
22
+
23
+ | Path | Role |
24
+ |------|------|
25
+ | `src/islands.tsx` | Entry — mounts React into every `[data-dvkit-island]` node (idempotent) |
26
+ | `src/App.tsx` | Picks `MockApiService` (localhost) or `PortalSeamService` (portal); injects it |
27
+ | `src/Component.tsx` | The host-agnostic widget (an accounts `DetailsList`), depends only on `IApiService` |
28
+ | `src/core/services/*` | The data-access seam — `IApiService` + `Xrm`/`Fetch`/`ServiceFactory` injected from `_shared`; `MockApiService` seed is per-project |
29
+ | `src/core/services/portalSeam.assert.ts` | Compile-time drift guard: `PortalSeamService` stays assignable to the seam |
30
+
31
+ ## Build & preview
32
+
33
+ ```bash
34
+ npm install
35
+ npm run dev # webpack dev server on http://localhost:8080 (renders the island from the mock)
36
+ npm run build # tsc --noEmit + webpack --mode production → dist/{{projectName}}.js
37
+ ```
38
+
39
+ ## Deploy to a traditional portal
40
+
41
+ 1. **Build** → `dist/{{projectName}}.js` (a single, stable-named bundle — no content hash, so the URL
42
+ is fixed).
43
+ 2. **Upload it as a portal Web File** (Power Pages Management app → *Web Files*, or `pac`): set its
44
+ **Parent Page = Home**, give it a partial URL like `{{projectName}}.js`, set the MIME type to
45
+ `text/javascript`, and set **Publishing State = Published**. A web file's URL is its parent page's
46
+ path joined with the partial URL, so parenting to **Home** makes it resolve at the site root:
47
+ `https://<your-portal>/{{projectName}}.js`. (Parent it elsewhere — e.g. under `/documents` — and the
48
+ URL becomes `/documents/{{projectName}}.js`, so the root-relative `<script src>` in step 3 won't find
49
+ it. Either parent to Home, or match the `<script src>` path to the web file's actual URL.)
50
+ 3. **Place the island** on any existing page — paste this into a **Web Template** or **Content Snippet**
51
+ the page renders:
52
+
53
+ ```html
54
+ <div data-dvkit-island="accounts"></div>
55
+ <script src="/{{projectName}}.js"></script>
56
+ ```
57
+
58
+ Add more `<div data-dvkit-island="…">` nodes to mount the widget in several places; the
59
+ `data-dvkit-island` value is a hook for routing multiple widgets later.
60
+
61
+ ## Portal requirements (per table you read/write)
62
+
63
+ The portal Web API is gated per table. For `account` (the sample), configure **Site Settings**:
64
+
65
+ - `Webapi/account/enabled` = `true`
66
+ - `Webapi/account/fields` = the exact columns you read/write (e.g. `name,telephone1` — list the
67
+ `_<lookup>_value` read form too), **mandatory**
68
+ - a **Table Permission** for the signed-in user's Web Role (and, for a `@odata.bind`/associate write,
69
+ **both Append AND AppendTo on both** the source and target permissions)
70
+
71
+ Notes:
72
+ - **Fluent UI v8** — Fluent v9 is broken on Power Pages portals. This template pins v8.
73
+ - **React 18** — pinned (Fluent v8 peers `<19`).
74
+ - Portals expose **no metadata endpoints** — hard-code any option-set labels at dev time.
75
+
76
+ ## Verification status
77
+
78
+ This starter is **build-verified** (`tsc` + webpack bundle) and **localhost-mock-verified**
79
+ (`npm run dev`). Live behavior against a real traditional portal (the web-file upload, the Liquid
80
+ snippet, and `PortalSeamService` against live `/_api/`) is the deploy step above — verify it on your
81
+ own portal.
82
+
83
+ > **The artifact is live-verified (2026-07-26).** This template was deployed to a real traditional
84
+ > (Liquid) Power Pages portal on the **enhanced data model** (`mspp_*` / `powerpagecomponent`): the
85
+ > bundle mounted into a Liquid-placed `[data-dvkit-island]` node and `PortalSeamService` returned
86
+ > **200** from `GET /_api/accounts?fetchXml=…` (the FetchXML read path the MS code-site scaffolder
87
+ > generates none of), rendering live accounts in the DetailsList.
88
+ >
89
+ > **Deploy tip for enhanced-data-model portals:** after uploading the web file + placing the snippet,
90
+ > run **⟳ Purge Cache** (Power Platform admin center → *Resources → Power Pages sites*) — traditional
91
+ > portals serve a precompiled snapshot, so a freshly-added web file **404s** until the cache is purged.
92
+ > The site is private until the signed-in user is a portal contact (first Entra sign-in auto-registers
93
+ > one + attaches the Authenticated Users role).
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "description": "{{projectName}} — React island mounted into an existing traditional Power Pages (Liquid) portal (webpack)",
6
+ "scripts": {
7
+ "dev": "webpack serve --mode development",
8
+ "build": "tsc --noEmit && webpack --mode production",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "@dataverse-kit/portal-api-service": "^0.2.0",
13
+ "@fluentui/react": "^8.120.0",
14
+ "react": "^18.2.0",
15
+ "react-dom": "^18.2.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/react": "^18.2.0",
19
+ "@types/react-dom": "^18.2.0",
20
+ "html-webpack-plugin": "^5.5.3",
21
+ "ts-loader": "^9.5.1",
22
+ "typescript": "^5.3.3",
23
+ "webpack": "^5.89.0",
24
+ "webpack-cli": "^5.1.4",
25
+ "webpack-dev-server": "^4.15.1"
26
+ }
27
+ }
@@ -0,0 +1,13 @@
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" />
6
+ <title>{{projectName}} — Power Pages island (dev preview)</title>
7
+ </head>
8
+ <body>
9
+ <!-- On a real portal this node lives in a Liquid web template / content snippet.
10
+ The bundle mounts React into EVERY [data-dvkit-island] node on the page. -->
11
+ <div data-dvkit-island="accounts"></div>
12
+ </body>
13
+ </html>
@@ -0,0 +1,37 @@
1
+ import * as React from "react";
2
+ import type { IApiService } from "./core/services/IApiService";
3
+ import { MockApiService } from "./core/services/MockApiService";
4
+ import { PortalSeamService } from "@dataverse-kit/portal-api-service";
5
+ import { Component } from "./Component";
6
+ import { useHostWidth } from "./useHostWidth";
7
+
8
+ // A Power Pages island is served from the portal origin, so a same-origin `/_api`
9
+ // fetch is authenticated by the portal session cookie + anti-forgery token —
10
+ // PortalSeamService handles both (token fetch/cache, 403-retry, idempotency-aware
11
+ // retry). On localhost (the webpack dev server) fall back to the in-memory mock so
12
+ // it renders with no portal.
13
+ //
14
+ // PortalSeamService implements the create-app 6-method `dataverse-seam`, so it is
15
+ // structurally assignable to IApiService with NO cast — and THIS assignment is the
16
+ // load-bearing compile-time drift guard: if the seam shapes ever diverge, this line
17
+ // fails `tsc`. (See core/services/portalSeam.assert.ts for the explicit fixture.)
18
+ export const App: React.FC<{ island?: string }> = () => {
19
+ // `island` is the `data-dvkit-island` value — a hook for routing multiple widgets
20
+ // on a page. The starter renders the accounts widget for any island.
21
+ const api = React.useMemo<IApiService>(() => {
22
+ const onLocalhost = /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
23
+ return onLocalhost ? new MockApiService() : new PortalSeamService();
24
+ }, []);
25
+
26
+ // An island is mounted into a slot on a portal page, NOT a full-page iframe, so the
27
+ // viewport is the whole portal and would overstate the space available. Measure the
28
+ // island's own box instead — portals are heavily mobile, so this matters here most.
29
+ const hostRef = React.useRef<HTMLDivElement>(null);
30
+ const width = useHostWidth(hostRef);
31
+
32
+ return (
33
+ <div ref={hostRef}>
34
+ <Component api={api} width={width} />
35
+ </div>
36
+ );
37
+ };
@@ -0,0 +1,136 @@
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` and a plain number for its width — no PCF `context`, no Xrm, no
21
+ * lifecycle, no bundler globals — so the SAME source renders unchanged inside a
22
+ * PCF control, a web resource, and a model-driven custom page. Each host's thin
23
+ * wrapper builds the right `IApiService` and supplies the width.
24
+ */
25
+ export interface ComponentProps {
26
+ api: IApiService;
27
+ /**
28
+ * Width available to the control, in px. Hosts that can measure it should pass it
29
+ * (the PCF wrapper passes `context.mode.allocatedWidth`); when omitted the layout
30
+ * falls back to the roomy variant.
31
+ *
32
+ * ★ Deliberately a NUMBER, not a form factor. Do NOT branch on
33
+ * `context.client.getFormFactor()` / `getClient()`: verified live 2026-07-25 on a
34
+ * physical iPhone in the native Power Apps app, a custom page reports
35
+ * client "Web" and formFactor 1 (Desktop). Branching on those silently gives every
36
+ * phone the desktop layout, and passes every test not run on a real device.
37
+ */
38
+ width?: number;
39
+ }
40
+
41
+ /**
42
+ * Below this the Fluent `DetailsList` stops being usable — columns collapse and
43
+ * horizontal scrolling takes over — so narrow hosts get a stacked card list instead.
44
+ */
45
+ const NARROW_BREAKPOINT_PX = 480;
46
+
47
+ const ACCOUNTS_FETCHXML = [
48
+ '<fetch top="25">',
49
+ ' <entity name="account">',
50
+ ' <attribute name="accountid" />',
51
+ ' <attribute name="name" />',
52
+ ' <attribute name="telephone1" />',
53
+ ' <order attribute="name" />',
54
+ " </entity>",
55
+ "</fetch>",
56
+ ].join("\n");
57
+
58
+ interface AccountRow {
59
+ accountid: string;
60
+ name?: string;
61
+ telephone1?: string;
62
+ }
63
+
64
+ const COLUMNS: IColumn[] = [
65
+ { key: "name", name: "Account", fieldName: "name", minWidth: 180, maxWidth: 320, isResizable: true },
66
+ { key: "telephone1", name: "Phone", fieldName: "telephone1", minWidth: 140, isResizable: true },
67
+ ];
68
+
69
+ export const Component: React.FC<ComponentProps> = ({ api, width }) => {
70
+ const [rows, setRows] = React.useState<AccountRow[] | null>(null);
71
+ const [error, setError] = React.useState<string | null>(null);
72
+
73
+ // `width === undefined` means the host cannot measure itself; prefer the roomy
74
+ // layout rather than guessing narrow.
75
+ const isNarrow = width !== undefined && width < NARROW_BREAKPOINT_PX;
76
+
77
+ React.useEffect(() => {
78
+ let cancelled = false;
79
+ api
80
+ .retrieveMultipleRecords("accounts", ACCOUNTS_FETCHXML)
81
+ .then((res) => {
82
+ if (!cancelled) setRows(res.entities as AccountRow[]);
83
+ })
84
+ .catch((e) => {
85
+ if (!cancelled) setError(e instanceof Error ? e.message : String(e));
86
+ });
87
+ return () => {
88
+ cancelled = true;
89
+ };
90
+ }, [api]);
91
+
92
+ return (
93
+ <Stack tokens={{ childrenGap: 12 }} styles={{ root: { padding: 16 } }}>
94
+ <Text variant="xLarge">Accounts</Text>
95
+ <Text variant="small">
96
+ One host-agnostic component — the same source renders in a PCF control, a web
97
+ resource and a model-driven custom page; only the thin host wrapper changes.
98
+ {width !== undefined && ` (${isNarrow ? "narrow" : "wide"} layout · ${width}px)`}
99
+ </Text>
100
+ {error && <MessageBar messageBarType={MessageBarType.error}>{error}</MessageBar>}
101
+ {!rows && !error && <Spinner label="Loading accounts…" />}
102
+ {rows && !isNarrow && (
103
+ <DetailsList
104
+ items={rows}
105
+ columns={COLUMNS}
106
+ setKey="accounts"
107
+ getKey={(item: AccountRow) => item.accountid}
108
+ selectionMode={SelectionMode.none}
109
+ />
110
+ )}
111
+ {rows && isNarrow && (
112
+ <Stack tokens={{ childrenGap: 8 }}>
113
+ {rows.map((row) => (
114
+ <Stack
115
+ key={row.accountid}
116
+ tokens={{ childrenGap: 2 }}
117
+ styles={{
118
+ root: {
119
+ padding: 12,
120
+ border: "1px solid #edebe9",
121
+ borderRadius: 4,
122
+ // Long account names must wrap rather than force the card wider
123
+ // than the phone — the failure mode DetailsList has here.
124
+ wordBreak: "break-word",
125
+ },
126
+ }}
127
+ >
128
+ <Text variant="mediumPlus">{row.name ?? "(no name)"}</Text>
129
+ <Text variant="small">{row.telephone1 ?? "—"}</Text>
130
+ </Stack>
131
+ ))}
132
+ </Stack>
133
+ )}
134
+ </Stack>
135
+ );
136
+ };
@@ -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
+ }
@@ -0,0 +1,14 @@
1
+ // Compile-time drift guard (typechecked by `npm run build` → `tsc --noEmit`, and by
2
+ // the create-dynamics-app power-pages-island template build e2e). `PortalSeamService`
3
+ // — the create-app 6-method `dataverse-seam` adapter shipped in
4
+ // `@dataverse-kit/portal-api-service` — MUST stay structurally assignable to THIS
5
+ // template's local seam `IApiService`, so `App.tsx` can inject it with no cast. That
6
+ // package can't import the create-app template interface, so the assertion lives here,
7
+ // where both types resolve. Type-only — no runtime, tree-shaken from the bundle.
8
+ import type { IApiService } from "./IApiService";
9
+ import type { PortalSeamService } from "@dataverse-kit/portal-api-service";
10
+
11
+ // If PortalSeamService drifts from the seam shape, `true` is no longer assignable to
12
+ // `never` and this fails to compile.
13
+ type PortalSeamSatisfiesSeam = PortalSeamService extends IApiService ? true : never;
14
+ export const _portalSeamDriftGuard: PortalSeamSatisfiesSeam = true;
@@ -0,0 +1,12 @@
1
+ // Webpack/ts-loader 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. This Power Pages
4
+ // island does NOT use ServiceFactory (App.tsx builds MockApiService / PortalSeamService
5
+ // directly), so it is tree-shaken from the bundle — but `tsc` / ts-loader still
6
+ // typecheck it. This minimal ambient declaration lets that unused file compile here.
7
+ //
8
+ // NOTE: this file MUST live under src/ — the tsconfig is `include: ["src"]`, so a
9
+ // project-root *.d.ts would be excluded and the shim would be missed.
10
+ interface ImportMeta {
11
+ readonly env: Record<string, unknown>;
12
+ }
@@ -0,0 +1,38 @@
1
+ import * as 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 in a
7
+ // live portal.
8
+ initLogging();
9
+
10
+ // A traditional Power Pages portal is server-rendered (Liquid). This bundle is an
11
+ // ISLAND: it hydrates React ONLY into the nodes a maker tags in Liquid, leaving the
12
+ // rest of the page untouched — the delta the MS code-site scaffolder (whole-page SPA)
13
+ // can't do. Tag a mount point with `data-dvkit-island="<name>"` (the name is a hook
14
+ // for future per-widget routing) and reference this web file:
15
+ //
16
+ // <div data-dvkit-island="accounts"></div>
17
+ // <script src="/{{projectName}}.js"></script>
18
+ //
19
+ const SELECTOR = "[data-dvkit-island]";
20
+
21
+ function mountIslands(): void {
22
+ document.querySelectorAll<HTMLElement>(SELECTOR).forEach((node) => {
23
+ // Idempotent — a partial-page refresh or a duplicated include must not double-mount.
24
+ if (node.dataset.dvkitMounted === "true") return;
25
+ node.dataset.dvkitMounted = "true";
26
+ createRoot(node).render(
27
+ <React.StrictMode>
28
+ <App island={node.dataset.dvkitIsland} />
29
+ </React.StrictMode>,
30
+ );
31
+ });
32
+ }
33
+
34
+ if (document.readyState === "loading") {
35
+ document.addEventListener("DOMContentLoaded", mountIslands);
36
+ } else {
37
+ mountIslands();
38
+ }
@@ -0,0 +1,53 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * Width available to the component, in px, kept live as the host resizes.
5
+ *
6
+ * Two modes, because the browser hosts are not all shaped the same:
7
+ *
8
+ * - **Pass a ref** when the component is mounted into an element on a larger page
9
+ * (a Power Pages island). The viewport is then the whole portal page, which may be
10
+ * far wider than the slot the island occupies, so it must measure its own box.
11
+ * - **Pass nothing** when the component owns a full-page iframe (web resource /
12
+ * Vite host). There the viewport *is* the control's width.
13
+ *
14
+ * The PCF host uses neither — it gets a properly scoped measurement from
15
+ * `context.mode.allocatedWidth` after opting in with `trackContainerResize(true)`.
16
+ *
17
+ * ★ Why measure at all rather than ask the platform what device this is: verified
18
+ * live 2026-07-25 on a physical iPhone in the native Power Apps app, a model-driven
19
+ * custom page reports `client: "Web"` and `formFactor: 1` (Desktop). Form-factor
20
+ * branching therefore hands every phone the desktop layout — and passes every test
21
+ * that is not run on a real device.
22
+ */
23
+ export function useHostWidth(ref?: React.RefObject<HTMLElement | null>): number {
24
+ const [width, setWidth] = React.useState<number>(() =>
25
+ typeof window === "undefined" ? 0 : window.innerWidth,
26
+ );
27
+
28
+ React.useEffect(() => {
29
+ const el = ref?.current;
30
+
31
+ if (el && typeof ResizeObserver !== "undefined") {
32
+ const ro = new ResizeObserver((entries) => {
33
+ for (const entry of entries) setWidth(entry.contentRect.width);
34
+ });
35
+ ro.observe(el);
36
+ setWidth(el.getBoundingClientRect().width);
37
+ return () => ro.disconnect();
38
+ }
39
+
40
+ // Viewport fallback: no ref, or an environment without ResizeObserver.
41
+ const onResize = () => setWidth(window.innerWidth);
42
+ window.addEventListener("resize", onResize);
43
+ // iOS Safari does not reliably fire `resize` on rotation, so listen for both.
44
+ window.addEventListener("orientationchange", onResize);
45
+ onResize();
46
+ return () => {
47
+ window.removeEventListener("resize", onResize);
48
+ window.removeEventListener("orientationchange", onResize);
49
+ };
50
+ }, [ref]);
51
+
52
+ return width;
53
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "node",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "skipLibCheck": true,
11
+ "lib": ["DOM", "DOM.Iterable", "ES2020"],
12
+ "types": []
13
+ },
14
+ "include": ["src"]
15
+ }