@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.
- package/README.md +2 -2
- package/dist/artifacts/registry.d.ts.map +1 -1
- package/dist/artifacts/registry.js +76 -20
- package/dist/artifacts/registry.js.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.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/grid-customizer/README.md +105 -15
- package/templates/grid-customizer/dev/harness/MockGrid.tsx +165 -0
- package/templates/grid-customizer/dev/harness/dataverse.ts +168 -0
- package/templates/grid-customizer/dev/index.html +20 -0
- package/templates/grid-customizer/dev/main.tsx +260 -0
- package/templates/grid-customizer/dev/vite-env.d.ts +1 -0
- package/templates/grid-customizer/env.local.example +24 -0
- package/templates/grid-customizer/eslintrc.json +11 -0
- package/templates/grid-customizer/featureconfig.json +3 -0
- package/templates/grid-customizer/gitignore +3 -0
- package/templates/grid-customizer/package.json +30 -16
- package/templates/grid-customizer/pcfconfig.json +3 -0
- package/templates/grid-customizer/tsconfig.dev.json +16 -0
- package/templates/grid-customizer/tsconfig.json +17 -10
- package/templates/grid-customizer/vite.config.ts +64 -0
- package/templates/grid-customizer/{{componentName}}/ControlManifest.Input.xml +63 -0
- package/templates/grid-customizer/{{componentName}}/customizers/CellEditorOverrides.tsx +38 -0
- package/templates/grid-customizer/{{componentName}}/customizers/CellRendererOverrides.tsx +109 -0
- package/templates/grid-customizer/{{componentName}}/customizers/cells.tsx +137 -0
- package/templates/grid-customizer/{{componentName}}/customizers/config.ts +84 -0
- package/templates/grid-customizer/{{componentName}}/index.ts +81 -0
- package/templates/grid-customizer/{{componentName}}/strings/{{componentName}}.1033.resx +56 -0
- package/templates/grid-customizer/{{componentName}}/types.ts +168 -0
- 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 +194 -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 +52 -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/dev/harness/dataverse.ts +71 -0
- package/templates/pcf-field/dev/index.html +20 -0
- package/templates/pcf-field/dev/main.tsx +212 -0
- package/templates/pcf-field/dev/vite-env.d.ts +1 -0
- package/templates/pcf-field/env.local.example +24 -0
- package/templates/pcf-field/eslintrc.json +11 -0
- package/templates/pcf-field/gitignore +4 -0
- package/templates/pcf-field/package.json +11 -2
- package/templates/pcf-field/pcfconfig.json +3 -0
- package/templates/pcf-field/tsconfig.dev.json +15 -0
- package/templates/pcf-field/tsconfig.json +20 -4
- package/templates/pcf-field/vite.config.ts +60 -0
- package/templates/pcf-field/{{componentName}}/ControlManifest.Input.xml +42 -0
- package/templates/pcf-field/{{{componentName}}Component.tsx → {{componentName}}/{{componentName}}Component.tsx} +6 -1
- 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/grid-customizer/src/GridCustomizer.ts +0 -28
- package/templates/grid-customizer/src/cell-renderers.tsx +0 -35
- package/templates/grid-customizer/src/index.ts +0 -4
- package/templates/grid-customizer/src/types/grid-types.ts +0 -30
- package/templates/grid-customizer/src/utils/color-utils.ts +0 -24
- package/templates/grid-customizer/webpack.config.js +0 -17
- 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
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
// Mount-only by design: `[]` deliberately omits dataSource/LIVE_ENABLED, because this
|
|
92
|
+
// reconciles the RESTORED selection once and must not re-run when it later changes.
|
|
93
|
+
// (No eslint-disable here: this template's .eslintrc loads no plugins — a devDependency
|
|
94
|
+
// is not enough — so a `react-hooks/exhaustive-deps` disable names an unresolvable rule,
|
|
95
|
+
// which `pcf-scripts build` treats as an ESLint ERROR. Gated by gridCustomizer.test.ts.)
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const onDataSourceChange = (next: DataSource): void => {
|
|
99
|
+
setDataSource(next);
|
|
100
|
+
if (next === 'mock') {
|
|
101
|
+
setItems(MOCK_ITEMS);
|
|
102
|
+
setColumns(MOCK_COLUMNS);
|
|
103
|
+
setLiveStatus(null);
|
|
104
|
+
} else {
|
|
105
|
+
setItems([]);
|
|
106
|
+
setColumns([]);
|
|
107
|
+
setLiveStatus(<span style={{ color: '#605e5c' }}>Enter a binding and click Load.</span>);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const onLoadLive = (): void => {
|
|
112
|
+
void (async () => {
|
|
113
|
+
if (!binding.entitySetName || !binding.select) {
|
|
114
|
+
setLiveStatus(<span style={{ color: '#a4262c' }}>Enter an entity set + columns.</span>);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
setLiveStatus(<span style={{ color: '#605e5c' }}>Loading…</span>);
|
|
118
|
+
try {
|
|
119
|
+
const result = await fetchRecords(binding.entitySetName, binding.select, binding.top);
|
|
120
|
+
setItems(result.items);
|
|
121
|
+
setColumns(result.columns);
|
|
122
|
+
setLiveStatus(
|
|
123
|
+
<span style={{ color: '#107c10' }}>
|
|
124
|
+
Loaded {result.items.length} row{result.items.length === 1 ? '' : 's'}.
|
|
125
|
+
</span>,
|
|
126
|
+
);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
setLiveStatus(describeLoadError(e));
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const bindingFields: HarnessField[] = [
|
|
134
|
+
{
|
|
135
|
+
kind: 'text',
|
|
136
|
+
label: 'Entity set (plural)',
|
|
137
|
+
placeholder: 'accounts',
|
|
138
|
+
value: binding.entitySetName,
|
|
139
|
+
onChange: (v) => setBinding((b) => ({ ...b, entitySetName: v })),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
kind: 'text',
|
|
143
|
+
label: 'Columns ($select, comma-separated)',
|
|
144
|
+
placeholder: 'name,address1_city,revenue',
|
|
145
|
+
value: binding.select,
|
|
146
|
+
onChange: (v) => setBinding((b) => ({ ...b, select: v })),
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
kind: 'text',
|
|
150
|
+
label: 'Top (max rows)',
|
|
151
|
+
placeholder: '25',
|
|
152
|
+
value: binding.top,
|
|
153
|
+
onChange: (v) => setBinding((b) => ({ ...b, top: v })),
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<HarnessShell
|
|
159
|
+
title="{{componentName}} — dev harness"
|
|
160
|
+
subtitle="Dataset grid (grid-kit ReadOnlyGrid)"
|
|
161
|
+
width={width}
|
|
162
|
+
onWidthChange={setWidth}
|
|
163
|
+
height={height}
|
|
164
|
+
onHeightChange={setHeight}
|
|
165
|
+
theme={theme}
|
|
166
|
+
onThemeChange={setTheme}
|
|
167
|
+
dataSource={dataSource}
|
|
168
|
+
onDataSourceChange={onDataSourceChange}
|
|
169
|
+
liveEnabled={LIVE_ENABLED}
|
|
170
|
+
bindingFields={bindingFields}
|
|
171
|
+
onLoadLive={onLoadLive}
|
|
172
|
+
liveStatus={liveStatus}
|
|
173
|
+
// Control-specific inputs panel. This grid takes no configuration properties beyond its
|
|
174
|
+
// bound dataset, so it is empty — add entries here as you add manifest properties.
|
|
175
|
+
fields={[]}
|
|
176
|
+
>
|
|
177
|
+
{/* Guard the empty-columns state (Live selected, not yet loaded): a PCF dataset always
|
|
178
|
+
has columns, so the control isn't built to render with none. Fluent v8's
|
|
179
|
+
ScrollablePane is absolutely positioned, so it needs a sized, position:relative
|
|
180
|
+
ancestor — the PCF host provides one; mirror it here. */}
|
|
181
|
+
{columns.length > 0 ? (
|
|
182
|
+
<div style={{ position: 'relative', height: height || 420 }}>
|
|
183
|
+
<{{componentName}}App items={items} columns={columns} />
|
|
184
|
+
</div>
|
|
185
|
+
) : (
|
|
186
|
+
<div style={{ color: '#605e5c', fontSize: 13, padding: 8 }}>
|
|
187
|
+
No records — enter a binding and click Load.
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</HarnessShell>
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
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
|
+
}
|
|
@@ -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,52 @@
|
|
|
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
|
+
FluentUIReactv940 = present ← Fluent 9.68.0 IS served (re-measured 2026-08-02)
|
|
24
|
+
|
|
25
|
+
★ Correction, 2026-08-02: an earlier revision of this comment claimed "8.29.0 is NOT
|
|
26
|
+
served", from probing `FluentUIReactv829`. The real alias is `FluentUIReactv8290`, and
|
|
27
|
+
8.29.0 IS served. Platform libraries are injected ON DEMAND from manifest declarations,
|
|
28
|
+
so a global is absent simply because nothing on that page declared it — "undefined here"
|
|
29
|
+
is not evidence that a version is unavailable. React 18 is the genuine exception:
|
|
30
|
+
`Reactv18` is undefined even on pages actively loading controls.
|
|
31
|
+
|
|
32
|
+
This control previously declared React 18.3.1 (because pcf-scripts allows it). It built
|
|
33
|
+
green and then failed with "An error has occurred." in the org, because it externalised
|
|
34
|
+
a `Reactv18` global that does not exist. Now 16.14.0 — matching Microsoft's published
|
|
35
|
+
table ("allowed 16.14.0; version loaded: 17.0.2 (Model), 16.14.0 (Canvas)") and the
|
|
36
|
+
only React alias the runtime supplies. It is also what the canvas runtime serves, so
|
|
37
|
+
this control now works in a model-driven custom page too.
|
|
38
|
+
|
|
39
|
+
A virtual control CANNOT bundle its own React as a workaround: it returns a ReactElement
|
|
40
|
+
that the platform renders with the platform's React, so two React instances would break.
|
|
41
|
+
That is why grid-kit's peer was widened to >=16.14.0 <19 rather than pinning React 18
|
|
42
|
+
here. See src/__tests__/registry.test.ts.
|
|
43
|
+
-->
|
|
44
|
+
<platform-library name="React" version="16.14.0" />
|
|
45
|
+
<platform-library name="Fluent" version="8.121.1" />
|
|
46
|
+
<resx path="strings/{{componentName}}.1033.resx" version="1.0.0" />
|
|
47
|
+
</resources>
|
|
48
|
+
<feature-usage>
|
|
49
|
+
<uses-feature name="Utility" required="true" />
|
|
50
|
+
</feature-usage>
|
|
51
|
+
</control>
|
|
52
|
+
</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,71 @@
|
|
|
1
|
+
import { apiFetch, toHttpError } from '@dataverse-kit/pcf-dev-harness/shell';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TIER 2 of the dev harness — the {{componentName}}-specific live-org read/write.
|
|
5
|
+
*
|
|
6
|
+
* The reusable parts (shell, proxy, token refresh, `apiFetch`'s one-shot 401/403 retry) come
|
|
7
|
+
* from `@dataverse-kit/pcf-dev-harness`. This file is what only THIS control can supply:
|
|
8
|
+
* reading and writing a SINGLE COLUMN on a single record — which is exactly what a field
|
|
9
|
+
* control does, so the harness exercises the same round-trip the deployed control performs.
|
|
10
|
+
*
|
|
11
|
+
* All calls go through the same-origin `/api/data` proxy in `vite.config.ts`, which injects
|
|
12
|
+
* the bearer token server-side — no token ever reaches the browser.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type { HttpError } from '@dataverse-kit/pcf-dev-harness/shell';
|
|
16
|
+
|
|
17
|
+
/** The raw value plus the platform's formatted rendering, when the column has one. */
|
|
18
|
+
export interface FieldRead {
|
|
19
|
+
raw: unknown;
|
|
20
|
+
formatted?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read one column off one record.
|
|
25
|
+
*
|
|
26
|
+
* @param entitySetName plural set name, e.g. `accounts`
|
|
27
|
+
* @param recordId GUID, with or without braces
|
|
28
|
+
* @param column logical name, e.g. `accountcategorycode`
|
|
29
|
+
*/
|
|
30
|
+
export async function readField(
|
|
31
|
+
entitySetName: string,
|
|
32
|
+
recordId: string,
|
|
33
|
+
column: string,
|
|
34
|
+
): Promise<FieldRead> {
|
|
35
|
+
const id = recordId.replace(/[{}]/g, '').trim();
|
|
36
|
+
const res = await apiFetch(`/api/data/v9.2/${entitySetName}(${id})?$select=${column}`, {
|
|
37
|
+
// Ask for the formatted-value annotation so option-set labels / currency symbols show
|
|
38
|
+
// the same text the platform renders on a form.
|
|
39
|
+
headers: { Prefer: 'odata.include-annotations="OData.Community.Display.V1.FormattedValue"' },
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok) throw await toHttpError(res);
|
|
42
|
+
|
|
43
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
44
|
+
return {
|
|
45
|
+
raw: body[column] ?? null,
|
|
46
|
+
formatted: body[`${column}@OData.Community.Display.V1.FormattedValue`] as string | undefined,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write one column back to one record — the save half of the round-trip.
|
|
52
|
+
*
|
|
53
|
+
* PATCH with a single property, exactly as the deployed control's `getOutputs` → platform save
|
|
54
|
+
* would. Returns nothing; a non-2xx throws an HttpError the caller can surface.
|
|
55
|
+
*/
|
|
56
|
+
export async function saveField(
|
|
57
|
+
entitySetName: string,
|
|
58
|
+
recordId: string,
|
|
59
|
+
column: string,
|
|
60
|
+
value: unknown,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
const id = recordId.replace(/[{}]/g, '').trim();
|
|
63
|
+
const res = await apiFetch(`/api/data/v9.2/${entitySetName}(${id})`, {
|
|
64
|
+
method: 'PATCH',
|
|
65
|
+
headers: { 'Content-Type': 'application/json' },
|
|
66
|
+
// `null` is meaningful here — it clears the column, which is a real thing a field
|
|
67
|
+
// control must be able to do, so it is NOT coerced away.
|
|
68
|
+
body: JSON.stringify({ [column]: value === undefined ? null : value }),
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok) throw await toHttpError(res);
|
|
71
|
+
}
|