@khester/create-dynamics-app 3.0.0 → 3.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.
Files changed (93) hide show
  1. package/README.md +2 -2
  2. package/dist/artifacts/registry.d.ts.map +1 -1
  3. package/dist/artifacts/registry.js +76 -20
  4. package/dist/artifacts/registry.js.map +1 -1
  5. package/dist/index.js +8 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/templatePlan.d.ts.map +1 -1
  8. package/dist/templatePlan.js +20 -3
  9. package/dist/templatePlan.js.map +1 -1
  10. package/dist/utils/copyTemplate.d.ts.map +1 -1
  11. package/dist/utils/copyTemplate.js +4 -0
  12. package/dist/utils/copyTemplate.js.map +1 -1
  13. package/package.json +1 -1
  14. package/templates/_shared/dataverse-seam/XrmApiService.ts +26 -5
  15. package/templates/grid-customizer/README.md +105 -15
  16. package/templates/grid-customizer/dev/harness/MockGrid.tsx +165 -0
  17. package/templates/grid-customizer/dev/harness/dataverse.ts +168 -0
  18. package/templates/grid-customizer/dev/index.html +20 -0
  19. package/templates/grid-customizer/dev/main.tsx +260 -0
  20. package/templates/grid-customizer/dev/vite-env.d.ts +1 -0
  21. package/templates/grid-customizer/env.local.example +24 -0
  22. package/templates/grid-customizer/eslintrc.json +11 -0
  23. package/templates/grid-customizer/featureconfig.json +3 -0
  24. package/templates/grid-customizer/gitignore +3 -0
  25. package/templates/grid-customizer/package.json +30 -16
  26. package/templates/grid-customizer/pcfconfig.json +3 -0
  27. package/templates/grid-customizer/tsconfig.dev.json +16 -0
  28. package/templates/grid-customizer/tsconfig.json +17 -10
  29. package/templates/grid-customizer/vite.config.ts +64 -0
  30. package/templates/grid-customizer/{{componentName}}/ControlManifest.Input.xml +63 -0
  31. package/templates/grid-customizer/{{componentName}}/customizers/CellEditorOverrides.tsx +38 -0
  32. package/templates/grid-customizer/{{componentName}}/customizers/CellRendererOverrides.tsx +109 -0
  33. package/templates/grid-customizer/{{componentName}}/customizers/cells.tsx +137 -0
  34. package/templates/grid-customizer/{{componentName}}/customizers/config.ts +84 -0
  35. package/templates/grid-customizer/{{componentName}}/index.ts +81 -0
  36. package/templates/grid-customizer/{{componentName}}/strings/{{componentName}}.1033.resx +56 -0
  37. package/templates/grid-customizer/{{componentName}}/types.ts +168 -0
  38. package/templates/host-swap/README.md +57 -2
  39. package/templates/host-swap/_variants/host.custom-page/src/App.tsx +9 -5
  40. package/templates/host-swap/_variants/host.pcf/ControlManifest.Input.xml +26 -2
  41. package/templates/host-swap/_variants/host.pcf/gitignore +7 -0
  42. package/templates/host-swap/_variants/host.pcf/index.ts +21 -3
  43. package/templates/host-swap/_variants/host.pcf/package.json +20 -4
  44. package/templates/host-swap/_variants/host.pcf/{{componentName}}.pcfproj +67 -0
  45. package/templates/host-swap/_variants/host.web-resource/src/App.tsx +5 -1
  46. package/templates/host-swap/src/Component.tsx +54 -7
  47. package/templates/host-swap/src/useHostWidth.ts +53 -0
  48. package/templates/pcf-dataset/dev/harness/dataverse.ts +82 -0
  49. package/templates/pcf-dataset/dev/index.html +20 -0
  50. package/templates/pcf-dataset/dev/main.tsx +194 -0
  51. package/templates/pcf-dataset/dev/vite-env.d.ts +1 -0
  52. package/templates/pcf-dataset/env.local.example +24 -0
  53. package/templates/pcf-dataset/eslintrc.json +11 -0
  54. package/templates/pcf-dataset/gitignore +4 -0
  55. package/templates/pcf-dataset/package.json +18 -8
  56. package/templates/pcf-dataset/pcfconfig.json +3 -0
  57. package/templates/pcf-dataset/tsconfig.dev.json +15 -0
  58. package/templates/pcf-dataset/tsconfig.json +11 -2
  59. package/templates/pcf-dataset/vite.config.ts +63 -0
  60. package/templates/pcf-dataset/{{componentName}}/ControlManifest.Input.xml +52 -0
  61. package/templates/pcf-dataset/{{componentName}}/{{componentName}}App.tsx +64 -0
  62. package/templates/pcf-dataset/{{{componentName}}Component.tsx → {{componentName}}/{{componentName}}Component.tsx} +9 -46
  63. package/templates/pcf-field/dev/harness/dataverse.ts +71 -0
  64. package/templates/pcf-field/dev/index.html +20 -0
  65. package/templates/pcf-field/dev/main.tsx +212 -0
  66. package/templates/pcf-field/dev/vite-env.d.ts +1 -0
  67. package/templates/pcf-field/env.local.example +24 -0
  68. package/templates/pcf-field/eslintrc.json +11 -0
  69. package/templates/pcf-field/gitignore +4 -0
  70. package/templates/pcf-field/package.json +11 -2
  71. package/templates/pcf-field/pcfconfig.json +3 -0
  72. package/templates/pcf-field/tsconfig.dev.json +15 -0
  73. package/templates/pcf-field/tsconfig.json +20 -4
  74. package/templates/pcf-field/vite.config.ts +60 -0
  75. package/templates/pcf-field/{{componentName}}/ControlManifest.Input.xml +42 -0
  76. package/templates/pcf-field/{{{componentName}}Component.tsx → {{componentName}}/{{componentName}}Component.tsx} +6 -1
  77. package/templates/power-pages-island/README.md +12 -0
  78. package/templates/power-pages-island/src/App.tsx +13 -1
  79. package/templates/power-pages-island/src/Component.tsx +54 -7
  80. package/templates/power-pages-island/src/useHostWidth.ts +53 -0
  81. package/templates/react-custom-page/tools/deploy/deploy-webresource.cjs +8 -2
  82. package/templates/grid-customizer/src/GridCustomizer.ts +0 -28
  83. package/templates/grid-customizer/src/cell-renderers.tsx +0 -35
  84. package/templates/grid-customizer/src/index.ts +0 -4
  85. package/templates/grid-customizer/src/types/grid-types.ts +0 -30
  86. package/templates/grid-customizer/src/utils/color-utils.ts +0 -24
  87. package/templates/grid-customizer/webpack.config.js +0 -17
  88. package/templates/pcf-dataset/ControlManifest.Input.xml +0 -16
  89. package/templates/pcf-field/ControlManifest.Input.xml +0 -17
  90. /package/templates/pcf-dataset/{index.ts → {{componentName}}/index.ts} +0 -0
  91. /package/templates/pcf-dataset/{strings → {{componentName}}/strings}/{{componentName}}.1033.resx +0 -0
  92. /package/templates/pcf-field/{index.ts → {{componentName}}/index.ts} +0 -0
  93. /package/templates/pcf-field/{strings → {{componentName}}/strings}/{{componentName}}.1033.resx +0 -0
@@ -1,7 +1,9 @@
1
1
  # {{projectName}} — host-swap component
2
2
 
3
3
  One React component, written once against an injected `IApiService`, that you can
4
- host as a **PCF control**, a **Dynamics 365 custom page**, or a **web resource**.
4
+ host as a **PCF control** or as a **web resource** (in two flavours — webpack, or Vite under the
5
+ legacy `custom-page` host name; ★ despite the name that host emits an HTML web resource, NOT a
6
+ Power Apps custom page — for a real one, use `--host pcf` and drop the control on the page).
5
7
  You chose the host at scaffold time (`--host pcf`, `--host custom-page`, or
6
8
  `--host web-resource`); to retarget, scaffold again with another host and copy your
7
9
  `src/Component.tsx` across — the component never changes, only the thin wrapper does.
@@ -17,7 +19,7 @@ You chose the host at scaffold time (`--host pcf`, `--host custom-page`, or
17
19
 
18
20
  ## How each host supplies the data service
19
21
 
20
- - **Custom page (Vite):** `App.tsx` resolves the Xrm context and `ServiceFactory`
22
+ - **`custom-page` host (Vite web resource):** `App.tsx` resolves the Xrm context and `ServiceFactory`
21
23
  picks Mock (localhost), the token-proxy (`npm run dev` with `DYNAMICS_URL`), or
22
24
  the production `XrmApiService` (deployed).
23
25
  - **PCF control (pcf-scripts):** the control builds the service directly — a
@@ -36,3 +38,56 @@ npm install
36
38
  # pcf: npm run build (pcf-scripts build) | npm start (PCF test harness)
37
39
  # web-resource: npm run build (webpack --mode production) | npm run dev (webpack dev server)
38
40
  ```
41
+
42
+ > ★ **`pcf-scripts build` exits 0 even when the build FAILS.** If you script this in CI, grep the
43
+ > log for `[build] Succeeded` and for the absence of `compiled with N errors` — don't trust `$?`.
44
+
45
+ ## Deploy
46
+
47
+ The three hosts are **not** symmetric here. Only the PCF host has a first-class deploy path.
48
+
49
+ ### PCF
50
+
51
+ ```bash
52
+ pac pcf push --publisher-prefix <your-prefix>
53
+ ```
54
+
55
+ Builds, wraps the control in a temporary `PowerAppsToolsTemp_<prefix>` solution, imports it and
56
+ publishes. This needs the `.pcfproj` this template ships; its `ProjectGuid` is generated per
57
+ scaffold, so two controls from this template never collide inside one solution.
58
+
59
+ To ship versioned **managed + unmanaged** solution zips instead of a temp solution, wrap the
60
+ control in a solution project and build that:
61
+
62
+ ```bash
63
+ pac solution init --publisher-name <you> --publisher-prefix <p>
64
+ pac solution add-reference --path .
65
+ dotnet build -c Release -p:SolutionPackageType=Both
66
+ pac solution import --path bin/Release/<Solution>.zip
67
+ ```
68
+
69
+ (If you have the `claude-workspace` checkout, `msft/04-dynamics/scripts/deploy/package-control.sh
70
+ --source . --version 1.0.0 --output ./dist` automates exactly that — it discards this `.pcfproj`
71
+ and generates its own, so the two paths never conflict.)
72
+
73
+ ★ **Bump `ControlManifest.Input.xml`'s `version=` on every change.** Neither command does it for
74
+ you, and without a bump the platform serves the cached previous build — the single most common
75
+ "my fix didn't deploy".
76
+
77
+ ★ **The `<platform-library>` versions must be exact.** `pcf-scripts` matches a *range* and
78
+ silently remaps the bundle, but leaves your number in the manifest — so a wrong version **builds
79
+ green and fails to load in every host**. React must be `16.14.0`; Fluent `@fluentui/react` must be
80
+ `8.29.0` or `8.121.1`.
81
+
82
+ ### Web resource
83
+
84
+ No deploy script ships. Build, then upload `dist/` as a JavaScript web resource — via the maker
85
+ portal, `pac webresource push`, or the workspace's `webresource-manager.sh` if you have that
86
+ checkout — and reference it from a form or the sitemap.
87
+
88
+ ### Custom page
89
+
90
+ ★ **No deploy path ships for this host.** Despite the name it emits a Vite-built **HTML web
91
+ resource**, not a Power Apps custom page — see the registry description. If you want an uploader,
92
+ the `react-custom-page` artifact has one (`tools/deploy/deploy-webresource.cjs`); if you want a
93
+ *real* model-driven custom page, use `--host pcf` and drop the control onto the page in Studio.
@@ -1,14 +1,18 @@
1
1
  import * as React from "react";
2
2
  import { ServiceFactory } from "./core/services/ServiceFactory";
3
3
  import { Component } from "./Component";
4
+ import { useHostWidth } from "./useHostWidth";
4
5
 
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.
6
+ // Resolve the Xrm context. This host is an HTML WEB RESOURCE (not a Power Apps
7
+ // custom page): it runs in an iframe inside a model-driven app, so Xrm comes from
8
+ // window.parent. ServiceFactory then picks the IApiService per environment: Mock on
9
+ // localhost, the token-proxy in dev, or the production XrmApiService when deployed.
9
10
  const Xrm = (window.parent as any)?.Xrm ?? (window as any).Xrm;
10
11
 
11
12
  export const App: React.FC = () => {
12
13
  const api = React.useMemo(() => ServiceFactory.createApiService(Xrm), []);
13
- return <Component api={api} />;
14
+ // Embedded in a form iframe this is often narrow on a phone, so measure rather
15
+ // than assume desktop.
16
+ const width = useHostWidth();
17
+ return <Component api={api} width={width} />;
14
18
  };
@@ -6,8 +6,32 @@
6
6
  <property name="value" display-name-key="value_Display_Key" description-key="value_Desc_Key" of-type="SingleLine.Text" usage="bound" required="false" />
7
7
  <resources>
8
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" />
9
+ <!--
10
+ ★ These versions MUST come from Microsoft's supported platform-library list; arbitrary
11
+ versions do not fail the build, they fail the RUNTIME. `pcf-scripts` silently maps an
12
+ unsupported version to the nearest supported one when bundling (8.115.6 was emitted as
13
+ `external "FluentUIReactv81211"`), but the MANIFEST keeps the number the platform then
14
+ cannot resolve — so the control builds green and renders "Error loading control" in every
15
+ host. Live-diagnosed 2026-07-26: this template previously declared React 18.2.0 + Fluent
16
+ 8.115.6, neither of which is supported, and could not load in a form OR a custom page.
17
+ Supported (learn.microsoft.com/power-apps/developer/component-framework/react-controls-platform-libraries):
18
+ React → 16.14.0 only (the platform loads 17.0.2 in model-driven, 16.14.0 in canvas)
19
+ Fluent → @fluentui/react 8.29.0 or 8.121.1; @fluentui/react-components >=9.4.0 <=9.46.2
20
+ v8 and v9 cannot both be declared in one manifest.
21
+
22
+ ★ The rule is EQUALITY, not range membership. pcf-scripts matches a supported
23
+ {minVersion, maxVersion} range and remaps the bundle to that row's platformVersion,
24
+ but never rewrites this file — so a version inside the range yet unequal to its
25
+ platformVersion builds green and cannot be resolved at runtime. Asserted in
26
+ src/__tests__/registry.test.ts.
27
+
28
+ ★ package.json intentionally carries react@^18 while this says 16.14.0: the shared
29
+ dataverse-seam pulls @khester/reusable-components, whose peer is react>=17. So the
30
+ types will NOT stop you writing a React 18 API that crashes only in a custom page
31
+ (canvas serves 16.14.0). Don't. See package.json `_comment_versions`.
32
+ -->
33
+ <platform-library name="React" version="16.14.0" />
34
+ <platform-library name="Fluent" version="8.121.1" />
11
35
  <resx path="strings/{{componentName}}.1033.resx" version="1.0.0" />
12
36
  </resources>
13
37
  <feature-usage>
@@ -3,3 +3,10 @@ out/
3
3
  generated/
4
4
  *.log
5
5
  .env
6
+
7
+ # MSBuild output from the .pcfproj (`pac pcf push` / `dotnet build`). Without these
8
+ # the first push litters an untracked obj/PowerAppsToolsTemp_* tree into the repo.
9
+ bin/
10
+ obj/
11
+ *.binlog
12
+ .vs/
@@ -15,12 +15,18 @@ import { MockApiService } from "./src/core/services/MockApiService";
15
15
  * the session cookie — no Xrm or clientUrl needed. On localhost (the pcf-scripts
16
16
  * test harness) it falls back to the in-memory mock so the control renders with
17
17
  * no org.
18
+ *
19
+ * ★ This holds in a model-driven CUSTOM PAGE too — verified live 2026-07-25: a
20
+ * custom page reports the org origin (not apps.powerapps.com) and a same-origin
21
+ * fetch returns 200, in a desktop browser and in the native Power Apps iOS app.
22
+ * That is observed behaviour rather than a documented contract, so if it ever
23
+ * regresses, swap this line for `context.webAPI` (the API Microsoft documents).
18
24
  */
19
25
  export class {{componentName}} implements ComponentFramework.ReactControl<IInputs, IOutputs> {
20
26
  private api!: IApiService;
21
27
 
22
28
  public init(
23
- _context: ComponentFramework.Context<IInputs>,
29
+ context: ComponentFramework.Context<IInputs>,
24
30
  _notifyOutputChanged: () => void,
25
31
  _state: ComponentFramework.Dictionary,
26
32
  ): void {
@@ -28,10 +34,22 @@ export class {{componentName}} implements ComponentFramework.ReactControl<IInput
28
34
  typeof window !== "undefined" &&
29
35
  /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
30
36
  this.api = onLocalhost ? new MockApiService() : new FetchApiService("");
37
+
38
+ // Opt in to container-size reporting. WITHOUT THIS `allocatedWidth` never
39
+ // updates, so the control cannot respond to the device or to a resize — the
40
+ // usual reason a "responsive" PCF silently isn't.
41
+ context.mode.trackContainerResize(true);
31
42
  }
32
43
 
33
- public updateView(_context: ComponentFramework.Context<IInputs>): React.ReactElement {
34
- return React.createElement(Component, { api: this.api });
44
+ public updateView(context: ComponentFramework.Context<IInputs>): React.ReactElement {
45
+ // Pass the measured width down. Do NOT use context.client.getFormFactor():
46
+ // verified live on a physical iPhone in the native Power Apps app, a custom
47
+ // page reports formFactor 1 (Desktop) and client "Web", so form-factor
48
+ // branching hands every phone the desktop layout.
49
+ return React.createElement(Component, {
50
+ api: this.api,
51
+ width: context.mode.allocatedWidth,
52
+ });
35
53
  }
36
54
 
37
55
  public getOutputs(): IOutputs {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "{{projectName}}",
3
3
  "version": "1.0.0",
4
- "description": "{{componentName}} — host-swap component hosted as a PCF control",
4
+ "description": "{{componentName}} \u2014 host-swap component hosted as a PCF control",
5
5
  "scripts": {
6
6
  "build": "pcf-scripts build",
7
7
  "clean": "pcf-scripts clean",
@@ -9,8 +9,24 @@
9
9
  "start": "pcf-scripts start",
10
10
  "refreshTypes": "pcf-scripts refreshTypes"
11
11
  },
12
+ "_comment_versions": [
13
+ "\u2605 @fluentui/react is PINNED EXACTLY to the <platform-library> version in",
14
+ "ControlManifest.Input.xml and must move with it. This is a VIRTUAL control: react, react-dom",
15
+ "and @fluentui/react are externalized by pcf-scripts and supplied by the platform at runtime \u2014",
16
+ "and by pcf-start in the harness \u2014 so none of them reaches the bundle. That makes the version a",
17
+ "TYPES decision: the compiler should see the API the platform will actually serve. A caret",
18
+ "defeats it \u2014 ^8.120.0 resolved to 8.125.7 while the platform served 8.121.1.",
19
+ "",
20
+ "\u2605 react/react-dom stay at ^18 on purpose: pinning them to the manifest's 16.14.0 fails",
21
+ "npm install ERESOLVE, because the shared dataverse-seam imports @khester/reusable-components",
22
+ "(peer react>=17). But the @types ARE pinned to 16, which is what actually matters here \u2014 the",
23
+ "canvas runtime behind a custom page serves React 16.14.0, so useId/useTransition/",
24
+ "useSyncExternalStore must be a COMPILE error rather than a crash that only reproduces on a",
25
+ "phone. Since nothing from these packages is bundled, the runtime versions are inert and the",
26
+ "types carry the contract."
27
+ ],
12
28
  "dependencies": {
13
- "@fluentui/react": "^8.120.0",
29
+ "@fluentui/react": "8.121.1",
14
30
  "@types/powerapps-component-framework": "^1.3.18",
15
31
  "react": "^18.2.0",
16
32
  "react-dom": "^18.2.0"
@@ -18,8 +34,8 @@
18
34
  "devDependencies": {
19
35
  "@microsoft/eslint-config-spfx": "^1.18.2",
20
36
  "@types/node": "^18.16.9",
21
- "@types/react": "^18.2.0",
22
- "@types/react-dom": "^18.2.0",
37
+ "@types/react": "^16.14.0",
38
+ "@types/react-dom": "^16.9.0",
23
39
  "eslint-plugin-react-hooks": "^4.6.0",
24
40
  "pcf-scripts": "^1",
25
41
  "pcf-start": "^1",
@@ -0,0 +1,67 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!--
3
+ MSBuild project for the PCF control. Without this file `pac pcf push` fails
4
+ outright, so a scaffolded control has no deploy path at all — which is exactly
5
+ what this template shipped before.
6
+
7
+ Two ways to deploy (see the README's Deploy section):
8
+ • pac pcf push --publisher-prefix <p> — fastest dev loop; wraps the control
9
+ in a temporary PowerAppsToolsTemp_<p> solution and imports it.
10
+ • package-control.sh --source <dir> — emits versioned managed/unmanaged
11
+ solution zips with no temp solution. That script DISCARDS this file and
12
+ generates its own, so the two never conflict.
13
+
14
+ ProjectGuid is generated per scaffold, so two controls from this template never collide
15
+ inside one solution.
16
+ -->
17
+ <Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
18
+ <PropertyGroup>
19
+ <PowerAppsTargetsPath>$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps</PowerAppsTargetsPath>
20
+ </PropertyGroup>
21
+
22
+ <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" />
23
+ <Import Project="$(PowerAppsTargetsPath)\Microsoft.PowerApps.VisualStudio.Pcf.props" Condition="Exists('$(PowerAppsTargetsPath)\Microsoft.PowerApps.VisualStudio.Pcf.props')" />
24
+
25
+ <PropertyGroup>
26
+ <Name>{{componentName}}</Name>
27
+ <ProjectGuid>{{{projectGuid}}}</ProjectGuid>
28
+ <OutputPath>$(MSBuildThisFileDirectory)out\controls</OutputPath>
29
+ </PropertyGroup>
30
+
31
+ <PropertyGroup>
32
+ <TargetFrameworkVersion>v4.6.2</TargetFrameworkVersion>
33
+ <TargetFramework>net462</TargetFramework>
34
+ <RestoreProjectStyle>PackageReference</RestoreProjectStyle>
35
+ </PropertyGroup>
36
+
37
+ <ItemGroup>
38
+ <PackageReference Include="Microsoft.PowerApps.MSBuild.Pcf" Version="1.*" />
39
+ <!-- Supplies the net462 reference assemblies on macOS/Linux so this builds
40
+ without a Windows image (otherwise MSB3644). -->
41
+ <PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="All" />
42
+ </ItemGroup>
43
+
44
+ <ItemGroup>
45
+ <ExcludeDirectories Include="$(MSBuildThisFileDirectory)\.gitignore" />
46
+ <!-- A scaffolded project always has a nested .git (initGit runs unless --skip-git); the
47
+ probe this was modelled on lives inside a repo and never had one. package-control.sh
48
+ rm -rf's it before building, which is the same signal. -->
49
+ <ExcludeDirectories Include="$(MSBuildThisFileDirectory)\.git\**" />
50
+ <ExcludeDirectories Include="$(MSBuildThisFileDirectory)\.vs\**" />
51
+ <ExcludeDirectories Include="$(MSBuildThisFileDirectory)\bin\**" />
52
+ <ExcludeDirectories Include="$(MSBuildThisFileDirectory)\obj\**" />
53
+ <ExcludeDirectories Include="$(OutputPath)\**" />
54
+ <ExcludeDirectories Include="$(MSBuildThisFileDirectory)\*.pcfproj" />
55
+ <ExcludeDirectories Include="$(MSBuildThisFileDirectory)\*.pcfproj.user" />
56
+ <ExcludeDirectories Include="$(MSBuildThisFileDirectory)\*.sln" />
57
+ <ExcludeDirectories Include="$(MSBuildThisFileDirectory)\node_modules\**" />
58
+ </ItemGroup>
59
+
60
+ <ItemGroup>
61
+ <None Include="$(MSBuildThisFileDirectory)\**" Exclude="@(ExcludeDirectories)" />
62
+ </ItemGroup>
63
+
64
+ <Import Project="$(MSBuildToolsPath)\Microsoft.Common.targets" />
65
+ <Import Project="$(PowerAppsTargetsPath)\Microsoft.PowerApps.VisualStudio.Pcf.targets" Condition="Exists('$(PowerAppsTargetsPath)\Microsoft.PowerApps.VisualStudio.Pcf.targets')" />
66
+
67
+ </Project>
@@ -3,6 +3,7 @@ import type { IApiService } from "./core/services/IApiService";
3
3
  import { FetchApiService } from "./core/services/FetchApiService";
4
4
  import { MockApiService } from "./core/services/MockApiService";
5
5
  import { Component } from "./Component";
6
+ import { useHostWidth } from "./useHostWidth";
6
7
 
7
8
  // A web resource is served from the org origin, so a same-origin relative
8
9
  // /api/data/v9.2 fetch is authenticated by the session cookie — no Xrm or
@@ -14,5 +15,8 @@ export const App: React.FC = () => {
14
15
  const onLocalhost = /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
15
16
  return onLocalhost ? new MockApiService() : new FetchApiService("");
16
17
  }, []);
17
- return <Component api={api} />;
18
+ // A web resource embedded on a form is often narrow on a phone, so measure the
19
+ // viewport rather than assuming desktop.
20
+ const width = useHostWidth();
21
+ return <Component api={api} width={width} />;
18
22
  };
@@ -17,14 +17,33 @@ initializeIcons();
17
17
 
18
18
  /**
19
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.
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.
23
24
  */
24
25
  export interface ComponentProps {
25
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;
26
39
  }
27
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
+
28
47
  const ACCOUNTS_FETCHXML = [
29
48
  '<fetch top="25">',
30
49
  ' <entity name="account">',
@@ -47,10 +66,14 @@ const COLUMNS: IColumn[] = [
47
66
  { key: "telephone1", name: "Phone", fieldName: "telephone1", minWidth: 140, isResizable: true },
48
67
  ];
49
68
 
50
- export const Component: React.FC<ComponentProps> = ({ api }) => {
69
+ export const Component: React.FC<ComponentProps> = ({ api, width }) => {
51
70
  const [rows, setRows] = React.useState<AccountRow[] | null>(null);
52
71
  const [error, setError] = React.useState<string | null>(null);
53
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
+
54
77
  React.useEffect(() => {
55
78
  let cancelled = false;
56
79
  api
@@ -70,12 +93,13 @@ export const Component: React.FC<ComponentProps> = ({ api }) => {
70
93
  <Stack tokens={{ childrenGap: 12 }} styles={{ root: { padding: 16 } }}>
71
94
  <Text variant="xLarge">Accounts</Text>
72
95
  <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.
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)`}
75
99
  </Text>
76
100
  {error && <MessageBar messageBarType={MessageBarType.error}>{error}</MessageBar>}
77
101
  {!rows && !error && <Spinner label="Loading accounts…" />}
78
- {rows && (
102
+ {rows && !isNarrow && (
79
103
  <DetailsList
80
104
  items={rows}
81
105
  columns={COLUMNS}
@@ -84,6 +108,29 @@ export const Component: React.FC<ComponentProps> = ({ api }) => {
84
108
  selectionMode={SelectionMode.none}
85
109
  />
86
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
+ )}
87
134
  </Stack>
88
135
  );
89
136
  };
@@ -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,82 @@
1
+ import { apiFetch, toHttpError } from '@dataverse-kit/pcf-dev-harness/shell';
2
+ import type { ColumnDef } from '@dataverse-kit/grid-kit';
3
+ import type { Row } from '../../{{componentName}}/{{componentName}}App';
4
+
5
+ /**
6
+ * TIER 2 of the dev harness — the {{componentName}}-specific live-org read.
7
+ *
8
+ * The reusable parts (shell, proxy, token refresh, `apiFetch`'s one-shot 401/403 retry) come
9
+ * from `@dataverse-kit/pcf-dev-harness`. This file is what only THIS control can supply:
10
+ * turning an OData recordset into the exact `items` + `columns` its App expects.
11
+ *
12
+ * All calls go through the same-origin `/api/data` proxy in `vite.config.ts`, which injects
13
+ * the bearer token server-side — no token ever reaches the browser. READ-ONLY: this is a
14
+ * read-only grid, so there is no save counterpart.
15
+ */
16
+
17
+ export type { HttpError } from '@dataverse-kit/pcf-dev-harness/shell';
18
+
19
+ /**
20
+ * Shape a raw OData recordset into the App's props.
21
+ *
22
+ * Prefers the `@OData.Community.Display.V1.FormattedValue` annotation for each field, which
23
+ * is what the deployed control sees via `record.getFormattedValue()` — currency symbols,
24
+ * option-set labels and lookup display names rather than raw ids. `fields` fixes the column
25
+ * set and order; the first field is treated as the primary column.
26
+ */
27
+ function shapeResult(
28
+ rows: Array<Record<string, unknown>>,
29
+ fields: string[],
30
+ ): { items: Row[]; columns: ColumnDef<Row>[] } {
31
+ const items: Row[] = rows.map((r, i) => {
32
+ // The harness never navigates, so a per-render index is a stable enough record id.
33
+ const row: Row = { key: String(i) };
34
+ for (const f of fields) {
35
+ const formatted = r[`${f}@OData.Community.Display.V1.FormattedValue`];
36
+ row[f] = formatted !== undefined && formatted !== null ? String(formatted) : String(r[f] ?? '');
37
+ }
38
+ return row;
39
+ });
40
+
41
+ const columns: ColumnDef<Row>[] = fields.map((f, i) => ({
42
+ key: f,
43
+ fieldName: f,
44
+ name: f,
45
+ rendererType: 'text',
46
+ minWidth: i === 0 ? 200 : 120,
47
+ maxWidth: i === 0 ? 320 : 240,
48
+ isResizable: true,
49
+ isSortable: true,
50
+ isFilterable: true,
51
+ }));
52
+
53
+ return { items, columns };
54
+ }
55
+
56
+ /**
57
+ * Fetch `top` rows of `select` columns from an entity set.
58
+ *
59
+ * @param entitySetName plural set name, e.g. `accounts`
60
+ * @param select comma-separated logical names, e.g. `name,revenue`
61
+ * @param top max rows
62
+ */
63
+ export async function fetchRecords(
64
+ entitySetName: string,
65
+ select: string,
66
+ top: string,
67
+ ): Promise<{ items: Row[]; columns: ColumnDef<Row>[] }> {
68
+ const fields = select
69
+ .split(',')
70
+ .map((s) => s.trim())
71
+ .filter(Boolean);
72
+
73
+ const params = new URLSearchParams({ $select: fields.join(','), $top: top || '50' });
74
+ const res = await apiFetch(`/api/data/v9.2/${entitySetName}?${params}`, {
75
+ // Ask for the formatted-value annotations the deployed control renders.
76
+ headers: { Prefer: 'odata.include-annotations="OData.Community.Display.V1.FormattedValue"' },
77
+ });
78
+ if (!res.ok) throw await toHttpError(res);
79
+
80
+ const body = (await res.json()) as { value?: Array<Record<string, unknown>> };
81
+ return shapeResult(body.value ?? [], fields);
82
+ }
@@ -0,0 +1,20 @@
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>{{componentName}} – Dev Harness</title>
7
+ <style>
8
+ html, body, #root {
9
+ margin: 0;
10
+ padding: 0;
11
+ height: 100%;
12
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
13
+ }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <div id="root"></div>
18
+ <script type="module" src="./main.tsx"></script>
19
+ </body>
20
+ </html>