@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
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|
|
@@ -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>
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
// ReactDOM.render, NOT createRoot — this control is pinned to React 16.14.0 to match the
|
|
3
|
+
// `Reactv16` platform global it externalises, and `react-dom/client` does not exist there.
|
|
4
|
+
import * as ReactDOM from 'react-dom';
|
|
5
|
+
import {
|
|
6
|
+
HarnessShell,
|
|
7
|
+
usePersistedState,
|
|
8
|
+
type ThemeChoice,
|
|
9
|
+
type DataSource,
|
|
10
|
+
type HarnessField,
|
|
11
|
+
} from '@dataverse-kit/pcf-dev-harness/shell';
|
|
12
|
+
import type { ColumnDef } from '@dataverse-kit/grid-kit';
|
|
13
|
+
import { {{componentName}}App, type Row } from '../{{componentName}}/{{componentName}}App';
|
|
14
|
+
import { fetchRecords, type HttpError } from './harness/dataverse';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Dev harness — renders the control's host-agnostic <{{componentName}}App> inside the shared
|
|
18
|
+
* shell, with two data sources:
|
|
19
|
+
* - Mock — the hard-coded recordset below. Default; works with no org.
|
|
20
|
+
* - Live — real rows from a Dataverse org via the /api/data proxy. Enabled only when
|
|
21
|
+
* VITE_DYNAMICS_URL is set in `.env.local`.
|
|
22
|
+
*
|
|
23
|
+
* This is the working local loop for this artifact — see the note in vite.config.ts about
|
|
24
|
+
* `pcf-scripts start` being unable to host a React-18 virtual control.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Live-org binding for a DATASET control: entity set + columns + page size. */
|
|
28
|
+
interface RecordsetBinding {
|
|
29
|
+
entitySetName: string;
|
|
30
|
+
select: string;
|
|
31
|
+
top: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MOCK_COLUMNS: ColumnDef<Row>[] = [
|
|
35
|
+
{ key: 'name', fieldName: 'name', name: 'Name', rendererType: 'text', minWidth: 200, maxWidth: 320, isResizable: true, isSortable: true, isFilterable: true },
|
|
36
|
+
{ key: 'city', fieldName: 'city', name: 'City', rendererType: 'text', minWidth: 120, maxWidth: 240, isResizable: true, isSortable: true, isFilterable: true },
|
|
37
|
+
{ key: 'revenue', fieldName: 'revenue', name: 'Revenue', rendererType: 'text', minWidth: 120, maxWidth: 240, isResizable: true, isSortable: true, isFilterable: true },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const MOCK_ITEMS: Row[] = [
|
|
41
|
+
{ key: '0', name: 'Contoso Ltd', city: 'Seattle', revenue: '$5,200,000.00' },
|
|
42
|
+
{ key: '1', name: 'Fabrikam Inc', city: 'Redmond', revenue: '$1,800,000.00' },
|
|
43
|
+
{ key: '2', name: 'Adventure Works', city: 'Portland', revenue: '$920,000.00' },
|
|
44
|
+
{ key: '3', name: 'Northwind Traders', city: 'Vancouver', revenue: '$3,400,000.00' },
|
|
45
|
+
{ key: '4', name: 'Tailspin Toys', city: 'Boise', revenue: '$640,000.00' },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// VITE_-prefixed vars reach client code; this one only GATES Live mode. The token is
|
|
49
|
+
// deliberately not read here — it would be inlined into the bundle. The proxy injects it.
|
|
50
|
+
const LIVE_ENABLED = !!import.meta.env.VITE_DYNAMICS_URL;
|
|
51
|
+
|
|
52
|
+
/** Turn a failed live fetch into a red status line, calling out auth failures specifically. */
|
|
53
|
+
function describeLoadError(e: unknown): React.ReactNode {
|
|
54
|
+
const err = e as HttpError;
|
|
55
|
+
// The dev server refreshes the token in the background, so a 401/403 that survives the
|
|
56
|
+
// client retry means the `az login` session itself lapsed — not just the short-lived token.
|
|
57
|
+
const msg =
|
|
58
|
+
err.status === 401 || err.status === 403
|
|
59
|
+
? 'Not authorized — your `az login` session has expired. Run `az login`, then Load again.'
|
|
60
|
+
: `Failed to load: ${err.message}`;
|
|
61
|
+
return <span style={{ color: '#a4262c' }}>{msg}</span>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const Harness: React.FC = () => {
|
|
65
|
+
// Panel CONFIG persists across reloads (localStorage); fetched DATA does not.
|
|
66
|
+
const [width, setWidth] = usePersistedState('width', 0); // 0 => fill the canvas
|
|
67
|
+
const [height, setHeight] = usePersistedState('height', 0); // 0 => auto
|
|
68
|
+
const [theme, setTheme] = usePersistedState<ThemeChoice>('theme', 'light');
|
|
69
|
+
const [dataSource, setDataSource] = usePersistedState<DataSource>('dataSource', 'mock');
|
|
70
|
+
const [binding, setBinding] = usePersistedState<RecordsetBinding>('binding', {
|
|
71
|
+
entitySetName: 'accounts',
|
|
72
|
+
select: 'name,address1_city,revenue',
|
|
73
|
+
top: '25',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const [items, setItems] = React.useState<Row[]>(MOCK_ITEMS);
|
|
77
|
+
const [columns, setColumns] = React.useState<ColumnDef<Row>[]>(MOCK_COLUMNS);
|
|
78
|
+
const [liveStatus, setLiveStatus] = React.useState<React.ReactNode>(null);
|
|
79
|
+
|
|
80
|
+
// On reload with a restored Live selection: clear and prompt for Load — or fall back to
|
|
81
|
+
// Mock when this session has no org configured. Runs once on mount.
|
|
82
|
+
React.useEffect(() => {
|
|
83
|
+
if (dataSource !== 'live') return;
|
|
84
|
+
if (!LIVE_ENABLED) {
|
|
85
|
+
setDataSource('mock');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
setItems([]);
|
|
89
|
+
setColumns([]);
|
|
90
|
+
setLiveStatus(<span style={{ color: '#605e5c' }}>Binding restored — click Load.</span>);
|
|
91
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const onDataSourceChange = (next: DataSource): void => {
|
|
95
|
+
setDataSource(next);
|
|
96
|
+
if (next === 'mock') {
|
|
97
|
+
setItems(MOCK_ITEMS);
|
|
98
|
+
setColumns(MOCK_COLUMNS);
|
|
99
|
+
setLiveStatus(null);
|
|
100
|
+
} else {
|
|
101
|
+
setItems([]);
|
|
102
|
+
setColumns([]);
|
|
103
|
+
setLiveStatus(<span style={{ color: '#605e5c' }}>Enter a binding and click Load.</span>);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const onLoadLive = (): void => {
|
|
108
|
+
void (async () => {
|
|
109
|
+
if (!binding.entitySetName || !binding.select) {
|
|
110
|
+
setLiveStatus(<span style={{ color: '#a4262c' }}>Enter an entity set + columns.</span>);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
setLiveStatus(<span style={{ color: '#605e5c' }}>Loading…</span>);
|
|
114
|
+
try {
|
|
115
|
+
const result = await fetchRecords(binding.entitySetName, binding.select, binding.top);
|
|
116
|
+
setItems(result.items);
|
|
117
|
+
setColumns(result.columns);
|
|
118
|
+
setLiveStatus(
|
|
119
|
+
<span style={{ color: '#107c10' }}>
|
|
120
|
+
Loaded {result.items.length} row{result.items.length === 1 ? '' : 's'}.
|
|
121
|
+
</span>,
|
|
122
|
+
);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
setLiveStatus(describeLoadError(e));
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const bindingFields: HarnessField[] = [
|
|
130
|
+
{
|
|
131
|
+
kind: 'text',
|
|
132
|
+
label: 'Entity set (plural)',
|
|
133
|
+
placeholder: 'accounts',
|
|
134
|
+
value: binding.entitySetName,
|
|
135
|
+
onChange: (v) => setBinding((b) => ({ ...b, entitySetName: v })),
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
kind: 'text',
|
|
139
|
+
label: 'Columns ($select, comma-separated)',
|
|
140
|
+
placeholder: 'name,address1_city,revenue',
|
|
141
|
+
value: binding.select,
|
|
142
|
+
onChange: (v) => setBinding((b) => ({ ...b, select: v })),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
kind: 'text',
|
|
146
|
+
label: 'Top (max rows)',
|
|
147
|
+
placeholder: '25',
|
|
148
|
+
value: binding.top,
|
|
149
|
+
onChange: (v) => setBinding((b) => ({ ...b, top: v })),
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<HarnessShell
|
|
155
|
+
title="{{componentName}} — dev harness"
|
|
156
|
+
subtitle="Dataset grid (grid-kit ReadOnlyGrid)"
|
|
157
|
+
width={width}
|
|
158
|
+
onWidthChange={setWidth}
|
|
159
|
+
height={height}
|
|
160
|
+
onHeightChange={setHeight}
|
|
161
|
+
theme={theme}
|
|
162
|
+
onThemeChange={setTheme}
|
|
163
|
+
dataSource={dataSource}
|
|
164
|
+
onDataSourceChange={onDataSourceChange}
|
|
165
|
+
liveEnabled={LIVE_ENABLED}
|
|
166
|
+
bindingFields={bindingFields}
|
|
167
|
+
onLoadLive={onLoadLive}
|
|
168
|
+
liveStatus={liveStatus}
|
|
169
|
+
// Control-specific inputs panel. This grid takes no configuration properties beyond its
|
|
170
|
+
// bound dataset, so it is empty — add entries here as you add manifest properties.
|
|
171
|
+
fields={[]}
|
|
172
|
+
>
|
|
173
|
+
{/* Guard the empty-columns state (Live selected, not yet loaded): a PCF dataset always
|
|
174
|
+
has columns, so the control isn't built to render with none. Fluent v8's
|
|
175
|
+
ScrollablePane is absolutely positioned, so it needs a sized, position:relative
|
|
176
|
+
ancestor — the PCF host provides one; mirror it here. */}
|
|
177
|
+
{columns.length > 0 ? (
|
|
178
|
+
<div style={{ position: 'relative', height: height || 420 }}>
|
|
179
|
+
<{{componentName}}App items={items} columns={columns} />
|
|
180
|
+
</div>
|
|
181
|
+
) : (
|
|
182
|
+
<div style={{ color: '#605e5c', fontSize: 13, padding: 8 }}>
|
|
183
|
+
No records — enter a binding and click Load.
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
</HarnessShell>
|
|
187
|
+
);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
ReactDOM.render(<Harness />, document.getElementById('root'));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Live-org dev harness config. Copy to `.env.local` (gitignored) and fill in your org.
|
|
2
|
+
#
|
|
3
|
+
# cp env.local.example .env.local
|
|
4
|
+
# az login
|
|
5
|
+
# npm run dev # http://localhost:8182
|
|
6
|
+
#
|
|
7
|
+
# `az login` alone is enough: the Vite dev server acquires the bearer token in-process and
|
|
8
|
+
# refreshes it before expiry. DYNAMICS_TOKEN below is only an optional static fallback.
|
|
9
|
+
|
|
10
|
+
# The ONLY var exposed to the browser bundle. Setting it enables the harness "Live" toggle;
|
|
11
|
+
# leave it blank to run against mock data only.
|
|
12
|
+
VITE_DYNAMICS_URL=
|
|
13
|
+
|
|
14
|
+
# Optional. Set for a CDX / cross-tenant org — passed to `az --tenant`.
|
|
15
|
+
DYNAMICS_TENANT=
|
|
16
|
+
|
|
17
|
+
# Optional static token fallback, used only if the in-process `az` acquisition fails.
|
|
18
|
+
# ★ NO `VITE_` PREFIX, deliberately: a VITE_-prefixed var is inlined into the client bundle,
|
|
19
|
+
# which would ship your bearer token to the browser. Server-side only.
|
|
20
|
+
#
|
|
21
|
+
# ★ Do NOT populate this with the workspace's `msft/04-dynamics/scripts/get-token.sh` — its
|
|
22
|
+
# detect_env_prefix writes REACT_APP_/VITE_-prefixed names, and this harness looks up a bare
|
|
23
|
+
# `DYNAMICS_TOKEN`. Use `npm run auth:token` (the pcf-dev-token bin) instead.
|
|
24
|
+
DYNAMICS_TOKEN=
|
|
@@ -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
|
+
}
|
|
@@ -2,31 +2,41 @@
|
|
|
2
2
|
"name": "{{projectName}}",
|
|
3
3
|
"version": "1.0.0",
|
|
4
4
|
"description": "{{componentName}} PCF control built with Dynamics UI Kit",
|
|
5
|
+
"_comment_versions": "react/react-dom are pinned EXACTLY to 16.14.0 and @fluentui/react to 8.121.1 because this is a control-type=\"virtual\" control: both are externalised to the platform globals Reactv16 / FluentUIReactv81211, so the compiled-against version MUST equal the served version. Measured live 2026-08-01: the model-driven host serves React 17.0.2 under the Reactv16 alias and defines NO Reactv18 \u2014 declaring React 18.3.1 built green and failed to instantiate in the org. Pinning the 16.x types also makes any React-17+-only API a compile error instead of a runtime crash. Requires @dataverse-kit/grid-kit >= 0.16.0, whose react peer was widened to >=16.14.0 <19 for exactly this reason.",
|
|
5
6
|
"scripts": {
|
|
6
7
|
"build": "pcf-scripts build",
|
|
7
8
|
"clean": "pcf-scripts clean",
|
|
8
9
|
"rebuild": "pcf-scripts rebuild",
|
|
9
10
|
"start": "pcf-scripts start",
|
|
10
|
-
"refreshTypes": "pcf-scripts refreshTypes"
|
|
11
|
+
"refreshTypes": "pcf-scripts refreshTypes",
|
|
12
|
+
"dev": "vite --config vite.config.ts",
|
|
13
|
+
"dev:token": "npm run auth:token && npm run dev",
|
|
14
|
+
"auth:token": "pcf-dev-token",
|
|
15
|
+
"typecheck:dev": "tsc -p tsconfig.dev.json --noEmit",
|
|
16
|
+
"harness:check": "npm run typecheck:dev && vite build --config vite.config.ts"
|
|
11
17
|
},
|
|
12
18
|
"dependencies": {
|
|
13
|
-
"@dataverse-kit/grid-kit": "^0.
|
|
14
|
-
"@fluentui/react": "
|
|
19
|
+
"@dataverse-kit/grid-kit": "^0.16.0",
|
|
20
|
+
"@fluentui/react": "8.121.1",
|
|
15
21
|
"@khester/dynamics-cell-renderers": "^1.1.0",
|
|
16
22
|
"@khester/dynamics-utils": "^1.2.0",
|
|
17
23
|
"@types/powerapps-component-framework": "^1.3.18",
|
|
18
|
-
"react": "
|
|
19
|
-
"react-dom": "
|
|
24
|
+
"react": "16.14.0",
|
|
25
|
+
"react-dom": "16.14.0"
|
|
20
26
|
},
|
|
21
27
|
"devDependencies": {
|
|
28
|
+
"@dataverse-kit/pcf-dev-harness": "^0.2.0",
|
|
22
29
|
"@microsoft/eslint-config-spfx": "^1.18.2",
|
|
30
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
23
31
|
"@types/node": "^18.16.9",
|
|
24
|
-
"@types/react": "^
|
|
25
|
-
"@types/react-dom": "^
|
|
32
|
+
"@types/react": "^16.14.34",
|
|
33
|
+
"@types/react-dom": "^16.9.17",
|
|
34
|
+
"@typescript-eslint/parser": "^6.21.0",
|
|
26
35
|
"eslint-plugin-react-hooks": "^4.6.0",
|
|
27
36
|
"pcf-scripts": "^1",
|
|
28
37
|
"pcf-start": "^1",
|
|
29
|
-
"typescript": "^5.3.0"
|
|
38
|
+
"typescript": "^5.3.0",
|
|
39
|
+
"vite": "^5.4.0"
|
|
30
40
|
},
|
|
31
41
|
"browserslist": [
|
|
32
42
|
"last 2 versions"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": true,
|
|
5
|
+
"skipLibCheck": true
|
|
6
|
+
},
|
|
7
|
+
"//": [
|
|
8
|
+
"★ `exclude` is INHERITED through `extends`, so it MUST be overridden here — otherwise",
|
|
9
|
+
"dev/ stays excluded from its own project, `include` resolves to nothing, and",
|
|
10
|
+
"`npm run typecheck:dev` passes while checking zero files: a green gate gating nothing.",
|
|
11
|
+
"dev/main.tsx imports the control's App, which pulls the shared component in too."
|
|
12
|
+
],
|
|
13
|
+
"exclude": ["node_modules", "out"],
|
|
14
|
+
"include": ["dev", "vite.config.ts"]
|
|
15
|
+
}
|