@khester/create-dynamics-app 3.0.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.
- package/dist/artifacts/registry.d.ts.map +1 -1
- package/dist/artifacts/registry.js +53 -15
- package/dist/artifacts/registry.js.map +1 -1
- package/dist/templatePlan.d.ts.map +1 -1
- package/dist/templatePlan.js +20 -3
- package/dist/templatePlan.js.map +1 -1
- package/dist/utils/copyTemplate.d.ts.map +1 -1
- package/dist/utils/copyTemplate.js +4 -0
- package/dist/utils/copyTemplate.js.map +1 -1
- package/package.json +1 -1
- package/templates/_shared/dataverse-seam/XrmApiService.ts +26 -5
- package/templates/host-swap/README.md +57 -2
- package/templates/host-swap/_variants/host.custom-page/src/App.tsx +9 -5
- package/templates/host-swap/_variants/host.pcf/ControlManifest.Input.xml +26 -2
- package/templates/host-swap/_variants/host.pcf/gitignore +7 -0
- package/templates/host-swap/_variants/host.pcf/index.ts +21 -3
- package/templates/host-swap/_variants/host.pcf/package.json +20 -4
- package/templates/host-swap/_variants/host.pcf/{{componentName}}.pcfproj +67 -0
- package/templates/host-swap/_variants/host.web-resource/src/App.tsx +5 -1
- package/templates/host-swap/src/Component.tsx +54 -7
- package/templates/host-swap/src/useHostWidth.ts +53 -0
- package/templates/pcf-dataset/dev/harness/dataverse.ts +82 -0
- package/templates/pcf-dataset/dev/index.html +20 -0
- package/templates/pcf-dataset/dev/main.tsx +190 -0
- package/templates/pcf-dataset/dev/vite-env.d.ts +1 -0
- package/templates/pcf-dataset/env.local.example +24 -0
- package/templates/pcf-dataset/eslintrc.json +11 -0
- package/templates/pcf-dataset/gitignore +4 -0
- package/templates/pcf-dataset/package.json +18 -8
- package/templates/pcf-dataset/pcfconfig.json +3 -0
- package/templates/pcf-dataset/tsconfig.dev.json +15 -0
- package/templates/pcf-dataset/tsconfig.json +11 -2
- package/templates/pcf-dataset/vite.config.ts +63 -0
- package/templates/pcf-dataset/{{componentName}}/ControlManifest.Input.xml +45 -0
- package/templates/pcf-dataset/{{componentName}}/{{componentName}}App.tsx +64 -0
- package/templates/pcf-dataset/{{{componentName}}Component.tsx → {{componentName}}/{{componentName}}Component.tsx} +9 -46
- package/templates/pcf-field/eslintrc.json +11 -0
- package/templates/pcf-field/package.json +1 -0
- package/templates/pcf-field/pcfconfig.json +3 -0
- package/templates/pcf-field/{{componentName}}/ControlManifest.Input.xml +42 -0
- package/templates/power-pages-island/README.md +12 -0
- package/templates/power-pages-island/src/App.tsx +13 -1
- package/templates/power-pages-island/src/Component.tsx +54 -7
- package/templates/power-pages-island/src/useHostWidth.ts +53 -0
- package/templates/react-custom-page/tools/deploy/deploy-webresource.cjs +8 -2
- package/templates/pcf-dataset/ControlManifest.Input.xml +0 -16
- package/templates/pcf-field/ControlManifest.Input.xml +0 -17
- /package/templates/pcf-dataset/{index.ts → {{componentName}}/index.ts} +0 -0
- /package/templates/pcf-dataset/{strings → {{componentName}}/strings}/{{componentName}}.1033.resx +0 -0
- /package/templates/pcf-field/{index.ts → {{componentName}}/index.ts} +0 -0
- /package/templates/pcf-field/{strings → {{componentName}}/strings}/{{componentName}}.1033.resx +0 -0
- /package/templates/pcf-field/{{{componentName}}Component.tsx → {{componentName}}/{{componentName}}Component.tsx} +0 -0
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
"typeRoots": ["node_modules/@types"],
|
|
5
5
|
"jsx": "react",
|
|
6
6
|
"esModuleInterop": true,
|
|
7
|
-
"experimentalDecorators": true
|
|
8
|
-
|
|
7
|
+
"experimentalDecorators": true,
|
|
8
|
+
"skipLibCheck": true
|
|
9
|
+
},
|
|
10
|
+
"//": [
|
|
11
|
+
"★ `exclude` REPLACES the base's [\"./node_modules\"] — node_modules MUST be re-listed here.",
|
|
12
|
+
"pcf-scripts runs ts-loader in full (non-transpileOnly) mode, and ts-loader seeds its file",
|
|
13
|
+
"list from EVERY file the tsconfig resolves — not just what webpack reaches from the entry.",
|
|
14
|
+
"So without this exclude the Vite harness under dev/ would be typechecked as part of the",
|
|
15
|
+
"control build and could fail it. dev/ has its own project: tsconfig.dev.json."
|
|
16
|
+
],
|
|
17
|
+
"exclude": ["node_modules", "out", "dev", "vite.config.ts"]
|
|
9
18
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { defineConfig, loadEnv } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { dataverseProxy } from '@dataverse-kit/pcf-dev-harness/node';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Live-org dev harness for {{componentName}} — `npm run dev` → http://localhost:8182
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS EXISTS, given `npm start` already runs the Microsoft PCF harness:
|
|
10
|
+
* `pcf-start` hard-codes React 16 in its harness page and never loads its own
|
|
11
|
+
* `lib/react_18_3_1.js`, so a control-type="virtual" control declaring React 18.3.1 — which
|
|
12
|
+
* this one is — externalises a `Reactv18` global that the harness never defines, and shows
|
|
13
|
+
* "Error loading control". Verified against pcf-start 1.51.1 (latest) on 2026-08-01.
|
|
14
|
+
* So for THIS artifact the Vite harness is the working local loop, not a convenience.
|
|
15
|
+
*
|
|
16
|
+
* Two data sources (see `dev/main.tsx`):
|
|
17
|
+
* - Mock — a hard-coded recordset. Default; needs no org.
|
|
18
|
+
* - Live — real rows from Dataverse through the same-origin `/api/data` proxy. Enabled
|
|
19
|
+
* only when VITE_DYNAMICS_URL is set in `.env.local`.
|
|
20
|
+
*
|
|
21
|
+
* The proxy and its in-process Azure-CLI token refresh come from
|
|
22
|
+
* `@dataverse-kit/pcf-dev-harness/node`: the bearer token is injected SERVER-SIDE and never
|
|
23
|
+
* reaches the browser. `az login` + `npm run dev` is enough — `npm run auth:token` only
|
|
24
|
+
* writes an optional static fallback.
|
|
25
|
+
*/
|
|
26
|
+
export default defineConfig(({ mode }) => {
|
|
27
|
+
// Read .env* from the PROJECT root (__dirname), not `root: 'dev'`. The empty prefix loads
|
|
28
|
+
// every var server-side (URL + tenant + any static token); `envPrefix` below still limits
|
|
29
|
+
// what can reach the client bundle.
|
|
30
|
+
const env = loadEnv(mode, __dirname, '');
|
|
31
|
+
const live = dataverseProxy({
|
|
32
|
+
dynamicsUrl: env.VITE_DYNAMICS_URL || '',
|
|
33
|
+
tenant: env.DYNAMICS_TENANT,
|
|
34
|
+
// Array: `.env.local` is the documented file, `.env` is tolerated as a fallback so a
|
|
35
|
+
// token written by a different tool still works. First file defining DYNAMICS_TOKEN wins.
|
|
36
|
+
envFile: [path.resolve(__dirname, '.env.local'), path.resolve(__dirname, '.env')],
|
|
37
|
+
pluginName: '{{componentName}}-live-token',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
root: 'dev',
|
|
42
|
+
// .env* live at the project root, not the `dev` root, so client code still sees
|
|
43
|
+
// import.meta.env.VITE_DYNAMICS_URL (which gates Live mode).
|
|
44
|
+
envDir: __dirname,
|
|
45
|
+
// SECURITY: the bearer token is acquired server-side by dataverseProxy and is never put
|
|
46
|
+
// in a VITE_-prefixed var, so Vite cannot inline it into the client bundle. envPrefix
|
|
47
|
+
// narrows client exposure to VITE_DYNAMICS_URL alone; the static fallback DYNAMICS_TOKEN
|
|
48
|
+
// is deliberately unprefixed for the same reason.
|
|
49
|
+
envPrefix: 'VITE_DYNAMICS_URL',
|
|
50
|
+
// jsxRuntime: 'classic' — the automatic runtime emits `react/jsx-runtime`, which does not
|
|
51
|
+
// exist in React 16. This control is pinned to 16.14.0 to match the platform's Reactv16
|
|
52
|
+
// global, and tsconfig sets `"jsx": "react"` (classic) for the same reason.
|
|
53
|
+
plugins: [react({ jsxRuntime: 'classic' }), live.plugin],
|
|
54
|
+
server: {
|
|
55
|
+
// 8182, because `pcf-scripts start` owns 8181 — the two harnesses can run side by side.
|
|
56
|
+
// strictPort so a clash fails loudly instead of silently moving to another port.
|
|
57
|
+
port: 8182,
|
|
58
|
+
strictPort: true,
|
|
59
|
+
open: true,
|
|
60
|
+
proxy: live.proxy,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
<data-set name="dataset" display-name-key="Dataset_Display_Key">
|
|
5
|
+
</data-set>
|
|
6
|
+
<resources>
|
|
7
|
+
<code path="index.ts" order="1"/>
|
|
8
|
+
<!--
|
|
9
|
+
★ These versions must EQUAL a version the PLATFORM ACTUALLY SERVES — not merely one
|
|
10
|
+
pcf-scripts accepts. The two are NOT the same list, and that gap is what kills controls:
|
|
11
|
+
pcf-scripts matches a {minVersion, maxVersion} row and remaps the bundle to that row's
|
|
12
|
+
alias, but never rewrites this file and never checks the runtime.
|
|
13
|
+
|
|
14
|
+
Measured on a live model-driven page (orge2e285aa, 2026-08-01) — these are the globals
|
|
15
|
+
the host defines:
|
|
16
|
+
|
|
17
|
+
Reactv16 = React 17.0.2 ← the alias says 16; the served React is 17.0.2
|
|
18
|
+
Reactv17 = undefined
|
|
19
|
+
Reactv18 = undefined ← pcf-scripts HAS an 18.3.1 row; the org has no React 18
|
|
20
|
+
ReactDOMv16 = present
|
|
21
|
+
ReactDOMv18 = undefined
|
|
22
|
+
FluentUIReactv81211 = present ← Fluent 8.121.1
|
|
23
|
+
FluentUIReactv829 = undefined ← 8.29.0 is NOT served either
|
|
24
|
+
|
|
25
|
+
This control previously declared React 18.3.1 (because pcf-scripts allows it). It built
|
|
26
|
+
green and then failed with "An error has occurred." in the org, because it externalised
|
|
27
|
+
a `Reactv18` global that does not exist. Now 16.14.0 — matching Microsoft's published
|
|
28
|
+
table ("allowed 16.14.0; version loaded: 17.0.2 (Model), 16.14.0 (Canvas)") and the
|
|
29
|
+
only React alias the runtime supplies. It is also what the canvas runtime serves, so
|
|
30
|
+
this control now works in a model-driven custom page too.
|
|
31
|
+
|
|
32
|
+
A virtual control CANNOT bundle its own React as a workaround: it returns a ReactElement
|
|
33
|
+
that the platform renders with the platform's React, so two React instances would break.
|
|
34
|
+
That is why grid-kit's peer was widened to >=16.14.0 <19 rather than pinning React 18
|
|
35
|
+
here. See src/__tests__/registry.test.ts.
|
|
36
|
+
-->
|
|
37
|
+
<platform-library name="React" version="16.14.0" />
|
|
38
|
+
<platform-library name="Fluent" version="8.121.1" />
|
|
39
|
+
<resx path="strings/{{componentName}}.1033.resx" version="1.0.0" />
|
|
40
|
+
</resources>
|
|
41
|
+
<feature-usage>
|
|
42
|
+
<uses-feature name="Utility" required="true" />
|
|
43
|
+
</feature-usage>
|
|
44
|
+
</control>
|
|
45
|
+
</manifest>
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
// Lives here (not in the PCF adapter) so BOTH hosts get it: the control and the dev harness.
|
|
8
|
+
initializeIcons();
|
|
9
|
+
|
|
10
|
+
/** A grid row. `key` is the record id and is always present. */
|
|
11
|
+
export interface Row extends Record<string, unknown> {
|
|
12
|
+
key: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface I{{componentName}}AppProps {
|
|
16
|
+
items: Row[];
|
|
17
|
+
columns: ColumnDef<Row>[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* HOST-AGNOSTIC grid app — plain props, no PCF and no Xrm.
|
|
22
|
+
*
|
|
23
|
+
* That is the whole point of the split: `{{componentName}}Component.tsx` adapts the PCF
|
|
24
|
+
* dataset into `items` + `columns`, and `dev/main.tsx` supplies the same shape from mock
|
|
25
|
+
* data or a live Dataverse fetch. One component, two hosts, no duplicated grid logic.
|
|
26
|
+
*/
|
|
27
|
+
export const {{componentName}}App: React.FC<I{{componentName}}AppProps> = ({ items, columns }) => {
|
|
28
|
+
const registry = React.useMemo(() => createCellRegistry(), []);
|
|
29
|
+
const [search, setSearch] = React.useState('');
|
|
30
|
+
const [sort, setSort] = React.useState<GridSortState | undefined>(undefined);
|
|
31
|
+
|
|
32
|
+
// grid-kit's toolbar `onSearch` only reports the term — the host owns filtering.
|
|
33
|
+
// Substring-match across every column's formatted value.
|
|
34
|
+
const q = search.trim().toLowerCase();
|
|
35
|
+
const filtered = q
|
|
36
|
+
? items.filter((row) =>
|
|
37
|
+
columns.some((c) => String(row[c.fieldName] ?? '').toLowerCase().includes(q)),
|
|
38
|
+
)
|
|
39
|
+
: items;
|
|
40
|
+
|
|
41
|
+
// grid-kit sort is controlled — `onSortChange` reports the header click; the host
|
|
42
|
+
// reorders nothing. Sort the (filtered) rows by the formatted string.
|
|
43
|
+
const sorted = sort
|
|
44
|
+
? [...filtered].sort(
|
|
45
|
+
(a, b) =>
|
|
46
|
+
String(a[sort.fieldName] ?? '').localeCompare(String(b[sort.fieldName] ?? '')) *
|
|
47
|
+
(sort.direction === 'desc' ? -1 : 1),
|
|
48
|
+
)
|
|
49
|
+
: filtered;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ReadOnlyGrid<Row>
|
|
53
|
+
items={sorted}
|
|
54
|
+
columns={columns}
|
|
55
|
+
registry={registry}
|
|
56
|
+
getKey={(r) => r.key}
|
|
57
|
+
sort={sort}
|
|
58
|
+
onSortChange={(fieldName, direction) => setSort({ fieldName, direction })}
|
|
59
|
+
columnChooser
|
|
60
|
+
filterBuilder
|
|
61
|
+
toolbar={{ showSearch: true, searchPlaceholder: 'Search', onSearch: setSearch }}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
@@ -1,24 +1,18 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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>
|
|
@@ -79,3 +79,15 @@ This starter is **build-verified** (`tsc` + webpack bundle) and **localhost-mock
|
|
|
79
79
|
(`npm run dev`). Live behavior against a real traditional portal (the web-file upload, the Liquid
|
|
80
80
|
snippet, and `PortalSeamService` against live `/_api/`) is the deploy step above — verify it on your
|
|
81
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).
|
|
@@ -3,6 +3,7 @@ import type { IApiService } from "./core/services/IApiService";
|
|
|
3
3
|
import { MockApiService } from "./core/services/MockApiService";
|
|
4
4
|
import { PortalSeamService } from "@dataverse-kit/portal-api-service";
|
|
5
5
|
import { Component } from "./Component";
|
|
6
|
+
import { useHostWidth } from "./useHostWidth";
|
|
6
7
|
|
|
7
8
|
// A Power Pages island is served from the portal origin, so a same-origin `/_api`
|
|
8
9
|
// fetch is authenticated by the portal session cookie + anti-forgery token —
|
|
@@ -21,5 +22,16 @@ export const App: React.FC<{ island?: string }> = () => {
|
|
|
21
22
|
const onLocalhost = /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
|
|
22
23
|
return onLocalhost ? new MockApiService() : new PortalSeamService();
|
|
23
24
|
}, []);
|
|
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
|
+
);
|
|
25
37
|
};
|
|
@@ -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
|
|
21
|
-
* the SAME source renders unchanged inside a
|
|
22
|
-
*
|
|
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
|
|
74
|
-
|
|
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
|
+
}
|
|
@@ -107,8 +107,14 @@ async function main() {
|
|
|
107
107
|
await req("POST", "PublishXml", { ParameterXml: paramXml });
|
|
108
108
|
console.log("✓ Published.");
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
// Surfacing guidance. NOTE: this app reads window.parent.Xrm / Xrm.Page — a model-driven FORM
|
|
111
|
+
// context. It therefore needs a host that provides one. It CANNOT run in a Power Apps custom page
|
|
112
|
+
// (that is a canvasapp; Xrm.Page does not exist there), and opening the raw /WebResources/ URL
|
|
113
|
+
// standalone gives it no parent Xrm either, so ServiceFactory throws.
|
|
114
|
+
console.log(`\nSurface it via a sitemap SubArea: $webresource:${name}`);
|
|
115
|
+
console.log("Or embed it on a model-driven form, or open it with Xrm.Navigation.navigateTo.");
|
|
116
|
+
console.log(`\nDirect URL (dev only — no parent Xrm, so production mode will fail):`);
|
|
117
|
+
console.log(` ${url}/WebResources/${name}`);
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
main().catch((e) => {
|
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
<data-set name="dataset" display-name-key="Dataset_Display_Key">
|
|
5
|
-
</data-set>
|
|
6
|
-
<resources>
|
|
7
|
-
<code path="index.ts" order="1"/>
|
|
8
|
-
<platform-library name="React" version="18.2.0" />
|
|
9
|
-
<platform-library name="Fluent" version="8.115.6" />
|
|
10
|
-
<resx path="strings/{{componentName}}.1033.resx" version="1.0.0" />
|
|
11
|
-
</resources>
|
|
12
|
-
<feature-usage>
|
|
13
|
-
<uses-feature name="Utility" required="true" />
|
|
14
|
-
</feature-usage>
|
|
15
|
-
</control>
|
|
16
|
-
</manifest>
|
|
@@ -1,17 +0,0 @@
|
|
|
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
|
-
<platform-library name="React" version="16.8.6" />
|
|
10
|
-
<platform-library name="Fluent" version="8.29.0" />
|
|
11
|
-
<resx path="strings/{{componentName}}.1033.resx" version="1.0.0" />
|
|
12
|
-
</resources>
|
|
13
|
-
<feature-usage>
|
|
14
|
-
<uses-feature name="Utility" required="true" />
|
|
15
|
-
</feature-usage>
|
|
16
|
-
</control>
|
|
17
|
-
</manifest>
|
|
File without changes
|
/package/templates/pcf-dataset/{strings → {{componentName}}/strings}/{{componentName}}.1033.resx
RENAMED
|
File without changes
|
|
File without changes
|
/package/templates/pcf-field/{strings → {{componentName}}/strings}/{{componentName}}.1033.resx
RENAMED
|
File without changes
|
|
File without changes
|