@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,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,212 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
// ReactDOM.render, NOT createRoot — this control is pinned to React 16.14.0 (the floor the
|
|
3
|
+
// canvas runtime serves), 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 { {{componentName}}Component } from '../{{componentName}}/{{componentName}}Component';
|
|
13
|
+
import { readField, saveField, type HttpError } from './harness/dataverse';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Dev harness — renders the control against either a local value or a REAL Dataverse column.
|
|
17
|
+
*
|
|
18
|
+
* - Mock — the local value below. Default; works with no org.
|
|
19
|
+
* - Live — read a column off a record via the /api/data proxy, edit it in the control, and
|
|
20
|
+
* Save it back. Enabled only when VITE_DYNAMICS_URL is set in `.env.local`.
|
|
21
|
+
*
|
|
22
|
+
* The read/edit/save round-trip is the point: `npm start` (pcf-scripts, :8181) can only feed
|
|
23
|
+
* this control hand-typed values, so it never proves the control round-trips real org data.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
interface FieldBinding {
|
|
27
|
+
entitySetName: string;
|
|
28
|
+
recordId: string;
|
|
29
|
+
column: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// VITE_-prefixed vars reach client code; this one only GATES Live mode. The token is
|
|
33
|
+
// deliberately not read here — it would be inlined into the bundle. The proxy injects it.
|
|
34
|
+
const LIVE_ENABLED = !!import.meta.env.VITE_DYNAMICS_URL;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Minimal stand-in for the PCF context. Only the OptionSet variant reads it, for the bound
|
|
38
|
+
* column's choice metadata — note the UPPERCASE `Options`/`Value`/`Label`, which is the live
|
|
39
|
+
* PCF API shape, not the form-builder's design-time config. Harmless for the other variants,
|
|
40
|
+
* which ignore `context` entirely.
|
|
41
|
+
*/
|
|
42
|
+
const MOCK_CONTEXT = {
|
|
43
|
+
parameters: {
|
|
44
|
+
value: {
|
|
45
|
+
attributes: {
|
|
46
|
+
Options: [
|
|
47
|
+
{ Value: 1, Label: 'Option A' },
|
|
48
|
+
{ Value: 2, Label: 'Option B' },
|
|
49
|
+
{ Value: 3, Label: 'Option C' },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
} as unknown as ComponentFramework.Context<any>;
|
|
55
|
+
|
|
56
|
+
/** Turn a failed live call into a red status line, calling out auth failures specifically. */
|
|
57
|
+
function describeError(e: unknown, verb: string): React.ReactNode {
|
|
58
|
+
const err = e as HttpError;
|
|
59
|
+
// The dev server refreshes the token in the background, so a 401/403 that survives the
|
|
60
|
+
// client retry means the `az login` session itself lapsed — not just the short-lived token.
|
|
61
|
+
const msg =
|
|
62
|
+
err.status === 401 || err.status === 403
|
|
63
|
+
? 'Not authorized — your `az login` session has expired. Run `az login`, then try again.'
|
|
64
|
+
: `Failed to ${verb}: ${err.message}`;
|
|
65
|
+
return <span style={{ color: '#a4262c' }}>{msg}</span>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const Harness: React.FC = () => {
|
|
69
|
+
// Panel CONFIG persists across reloads (localStorage); the fetched VALUE does not.
|
|
70
|
+
const [width, setWidth] = usePersistedState('width', 320);
|
|
71
|
+
const [height, setHeight] = usePersistedState('height', 0);
|
|
72
|
+
const [theme, setTheme] = usePersistedState<ThemeChoice>('theme', 'light');
|
|
73
|
+
const [dataSource, setDataSource] = usePersistedState<DataSource>('dataSource', 'mock');
|
|
74
|
+
const [placeholder, setPlaceholder] = usePersistedState('placeholder', 'Enter a value');
|
|
75
|
+
const [disabled, setDisabled] = usePersistedState('disabled', false);
|
|
76
|
+
const [binding, setBinding] = usePersistedState<FieldBinding>('binding', {
|
|
77
|
+
entitySetName: 'accounts',
|
|
78
|
+
recordId: '',
|
|
79
|
+
column: 'accountcategorycode',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const [value, setValue] = React.useState<{{tsType}}>({{valueDefault}});
|
|
83
|
+
const [liveStatus, setLiveStatus] = React.useState<React.ReactNode>(null);
|
|
84
|
+
|
|
85
|
+
// On reload with a restored Live selection: clear and prompt for Load — or fall back to Mock
|
|
86
|
+
// when this session has no org configured. Runs once on mount.
|
|
87
|
+
React.useEffect(() => {
|
|
88
|
+
if (dataSource !== 'live') return;
|
|
89
|
+
if (!LIVE_ENABLED) {
|
|
90
|
+
setDataSource('mock');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setLiveStatus(<span style={{ color: '#605e5c' }}>Binding restored — click Load.</span>);
|
|
94
|
+
// Mount-only by design: `[]` deliberately omits dataSource/LIVE_ENABLED, because this
|
|
95
|
+
// reconciles the RESTORED selection once and must not re-run when it later changes.
|
|
96
|
+
// (No eslint-disable here: this template's .eslintrc loads no plugins — a devDependency
|
|
97
|
+
// is not enough — so a `react-hooks/exhaustive-deps` disable names an unresolvable rule,
|
|
98
|
+
// which `pcf-scripts build` treats as an ESLint ERROR. Gated by gridCustomizer.test.ts.)
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const onDataSourceChange = (next: DataSource): void => {
|
|
102
|
+
setDataSource(next);
|
|
103
|
+
setValue({{valueDefault}});
|
|
104
|
+
setLiveStatus(
|
|
105
|
+
next === 'mock' ? null : <span style={{ color: '#605e5c' }}>Enter a binding and click Load.</span>,
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const onLoadLive = (): void => {
|
|
110
|
+
void (async () => {
|
|
111
|
+
if (!binding.entitySetName || !binding.recordId || !binding.column) {
|
|
112
|
+
setLiveStatus(<span style={{ color: '#a4262c' }}>Enter entity set, record id and column.</span>);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
setLiveStatus(<span style={{ color: '#605e5c' }}>Loading…</span>);
|
|
116
|
+
try {
|
|
117
|
+
const { raw, formatted } = await readField(binding.entitySetName, binding.recordId, binding.column);
|
|
118
|
+
// The control owns its own typing; the harness hands the raw platform value straight
|
|
119
|
+
// through, exactly as the PCF host does via `context.parameters.value.raw`.
|
|
120
|
+
setValue(raw as {{tsType}});
|
|
121
|
+
setLiveStatus(
|
|
122
|
+
<span style={{ color: '#107c10' }}>
|
|
123
|
+
Loaded{formatted ? ` — "${formatted}"` : ''}.
|
|
124
|
+
</span>,
|
|
125
|
+
);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
setLiveStatus(describeError(e, 'load'));
|
|
128
|
+
}
|
|
129
|
+
})();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const onSave = (): void => {
|
|
133
|
+
void (async () => {
|
|
134
|
+
setLiveStatus(<span style={{ color: '#605e5c' }}>Saving…</span>);
|
|
135
|
+
try {
|
|
136
|
+
await saveField(binding.entitySetName, binding.recordId, binding.column, value);
|
|
137
|
+
setLiveStatus(<span style={{ color: '#107c10' }}>Saved to the org.</span>);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
setLiveStatus(describeError(e, 'save'));
|
|
140
|
+
}
|
|
141
|
+
})();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const bindingFields: HarnessField[] = [
|
|
145
|
+
{
|
|
146
|
+
kind: 'text',
|
|
147
|
+
label: 'Entity set (plural)',
|
|
148
|
+
placeholder: 'accounts',
|
|
149
|
+
value: binding.entitySetName,
|
|
150
|
+
onChange: (v) => setBinding((b) => ({ ...b, entitySetName: v })),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
kind: 'text',
|
|
154
|
+
label: 'Record id (GUID)',
|
|
155
|
+
placeholder: '00000000-0000-0000-0000-000000000000',
|
|
156
|
+
value: binding.recordId,
|
|
157
|
+
onChange: (v) => setBinding((b) => ({ ...b, recordId: v })),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
kind: 'text',
|
|
161
|
+
label: 'Column (logical name)',
|
|
162
|
+
placeholder: 'accountcategorycode',
|
|
163
|
+
value: binding.column,
|
|
164
|
+
onChange: (v) => setBinding((b) => ({ ...b, column: v })),
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<HarnessShell
|
|
170
|
+
title="{{componentName}} — dev harness"
|
|
171
|
+
subtitle="Field control ({{propertyType}})"
|
|
172
|
+
width={width}
|
|
173
|
+
onWidthChange={setWidth}
|
|
174
|
+
height={height}
|
|
175
|
+
onHeightChange={setHeight}
|
|
176
|
+
theme={theme}
|
|
177
|
+
onThemeChange={setTheme}
|
|
178
|
+
dataSource={dataSource}
|
|
179
|
+
onDataSourceChange={onDataSourceChange}
|
|
180
|
+
liveEnabled={LIVE_ENABLED}
|
|
181
|
+
bindingFields={bindingFields}
|
|
182
|
+
onLoadLive={onLoadLive}
|
|
183
|
+
liveStatus={liveStatus}
|
|
184
|
+
fields={[
|
|
185
|
+
{ kind: 'text', label: 'placeholder', value: placeholder, onChange: setPlaceholder },
|
|
186
|
+
{ kind: 'boolean', label: 'disabled', value: disabled, onChange: setDisabled },
|
|
187
|
+
]}
|
|
188
|
+
>
|
|
189
|
+
<div style={{ width: width || 320 }}>
|
|
190
|
+
<{{componentName}}Component
|
|
191
|
+
value={value}
|
|
192
|
+
placeholder={placeholder}
|
|
193
|
+
disabled={disabled}
|
|
194
|
+
onChange={setValue}
|
|
195
|
+
onBlur={() => undefined}
|
|
196
|
+
context={MOCK_CONTEXT}
|
|
197
|
+
/>
|
|
198
|
+
{dataSource === 'live' && (
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
onClick={onSave}
|
|
202
|
+
style={{ marginTop: 12, padding: '6px 14px', cursor: 'pointer' }}
|
|
203
|
+
>
|
|
204
|
+
Save to org
|
|
205
|
+
</button>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
</HarnessShell>
|
|
209
|
+
);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
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
|
+
}
|
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
"clean": "pcf-scripts clean",
|
|
8
8
|
"rebuild": "pcf-scripts rebuild",
|
|
9
9
|
"start": "pcf-scripts start",
|
|
10
|
-
"refreshTypes": "pcf-scripts refreshTypes"
|
|
10
|
+
"refreshTypes": "pcf-scripts refreshTypes",
|
|
11
|
+
"dev": "vite --config vite.config.ts",
|
|
12
|
+
"dev:token": "npm run auth:token && npm run dev",
|
|
13
|
+
"auth:token": "pcf-dev-token",
|
|
14
|
+
"typecheck:dev": "tsc -p tsconfig.dev.json --noEmit",
|
|
15
|
+
"harness:check": "npm run typecheck:dev && vite build --config vite.config.ts"
|
|
11
16
|
},
|
|
12
17
|
"dependencies": {
|
|
13
18
|
"@fluentui/react": "^8.120.0",
|
|
@@ -16,14 +21,18 @@
|
|
|
16
21
|
"react-dom": "^16.14.0"
|
|
17
22
|
},
|
|
18
23
|
"devDependencies": {
|
|
24
|
+
"@dataverse-kit/pcf-dev-harness": "^0.2.0",
|
|
19
25
|
"@microsoft/eslint-config-spfx": "^1.18.2",
|
|
20
26
|
"@types/node": "^18.16.9",
|
|
21
27
|
"@types/react": "^16.14.34",
|
|
22
28
|
"@types/react-dom": "^16.9.17",
|
|
29
|
+
"@typescript-eslint/parser": "^6.21.0",
|
|
30
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
23
31
|
"eslint-plugin-react-hooks": "^4.6.0",
|
|
24
32
|
"pcf-scripts": "^1",
|
|
25
33
|
"pcf-start": "^1",
|
|
26
|
-
"typescript": "^5.3.0"
|
|
34
|
+
"typescript": "^5.3.0",
|
|
35
|
+
"vite": "^5.4.0"
|
|
27
36
|
},
|
|
28
37
|
"browserslist": [
|
|
29
38
|
"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
|
+
}
|
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"extends": "./node_modules/pcf-scripts/tsconfig_base.json",
|
|
3
3
|
"compilerOptions": {
|
|
4
|
-
"typeRoots": [
|
|
4
|
+
"typeRoots": [
|
|
5
|
+
"node_modules/@types"
|
|
6
|
+
],
|
|
5
7
|
"jsx": "react",
|
|
6
|
-
"experimentalDecorators": true
|
|
7
|
-
|
|
8
|
-
}
|
|
8
|
+
"experimentalDecorators": true,
|
|
9
|
+
"skipLibCheck": true
|
|
10
|
+
},
|
|
11
|
+
"//": [
|
|
12
|
+
"\u2605 `exclude` REPLACES the base's [\"./node_modules\"] \u2014 node_modules MUST be re-listed here.",
|
|
13
|
+
"pcf-scripts runs ts-loader in full (non-transpileOnly) mode, and ts-loader seeds its file",
|
|
14
|
+
"list from EVERY file the tsconfig resolves \u2014 not just what webpack reaches from the entry.",
|
|
15
|
+
"So without this exclude the Vite harness under dev/ would be typechecked as part of the",
|
|
16
|
+
"control build and could fail it. dev/ has its own project: tsconfig.dev.json."
|
|
17
|
+
],
|
|
18
|
+
"exclude": [
|
|
19
|
+
"node_modules",
|
|
20
|
+
"out",
|
|
21
|
+
"dev",
|
|
22
|
+
"vite.config.ts"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
* Complements `npm start` (the pcf-scripts harness on :8181) rather than replacing it: this
|
|
10
|
+
* control is control-type="standard" on React 16 and bundles its own React, so it loads fine
|
|
11
|
+
* in the stock harness. What the stock harness cannot do is show you REAL org data — it only
|
|
12
|
+
* takes hand-typed values. This one reads and writes an actual Dataverse column.
|
|
13
|
+
*
|
|
14
|
+
* Two data sources (see `dev/main.tsx`):
|
|
15
|
+
* - Mock — a local value. Default; needs no org.
|
|
16
|
+
* - Live — read a column off a real record through the same-origin `/api/data` proxy, edit
|
|
17
|
+
* it, and save it back. Enabled only when VITE_DYNAMICS_URL is set in `.env.local`.
|
|
18
|
+
*
|
|
19
|
+
* The proxy and its in-process Azure-CLI token refresh come from
|
|
20
|
+
* `@dataverse-kit/pcf-dev-harness/node`: the bearer token is injected SERVER-SIDE and never
|
|
21
|
+
* reaches the browser. `az login` + `npm run dev` is enough — `npm run auth:token` only writes
|
|
22
|
+
* an optional static fallback.
|
|
23
|
+
*/
|
|
24
|
+
export default defineConfig(({ mode }) => {
|
|
25
|
+
// Read .env* from the PROJECT root (__dirname), not `root: 'dev'`. The empty prefix loads
|
|
26
|
+
// every var server-side; `envPrefix` below still limits what reaches the client bundle.
|
|
27
|
+
const env = loadEnv(mode, __dirname, '');
|
|
28
|
+
const live = dataverseProxy({
|
|
29
|
+
dynamicsUrl: env.VITE_DYNAMICS_URL || '',
|
|
30
|
+
tenant: env.DYNAMICS_TENANT,
|
|
31
|
+
// Array: `.env.local` is the documented file, `.env` is tolerated as a fallback so a token
|
|
32
|
+
// written by another tool still works. First file defining DYNAMICS_TOKEN wins.
|
|
33
|
+
envFile: [path.resolve(__dirname, '.env.local'), path.resolve(__dirname, '.env')],
|
|
34
|
+
pluginName: '{{componentName}}-live-token',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
root: 'dev',
|
|
39
|
+
// .env* live at the project root, not the `dev` root, so client code still sees
|
|
40
|
+
// import.meta.env.VITE_DYNAMICS_URL (which gates Live mode).
|
|
41
|
+
envDir: __dirname,
|
|
42
|
+
// SECURITY: the bearer token is acquired server-side by dataverseProxy and is never put in
|
|
43
|
+
// a VITE_-prefixed var, so Vite cannot inline it into the client bundle. envPrefix narrows
|
|
44
|
+
// client exposure to VITE_DYNAMICS_URL alone; the static fallback DYNAMICS_TOKEN is
|
|
45
|
+
// deliberately unprefixed for the same reason.
|
|
46
|
+
envPrefix: 'VITE_DYNAMICS_URL',
|
|
47
|
+
// jsxRuntime: 'classic' — the automatic runtime emits `react/jsx-runtime`, which does not
|
|
48
|
+
// exist in React 16. This control pins 16.14.0 (the floor the canvas runtime serves), and
|
|
49
|
+
// tsconfig sets `"jsx": "react"` (classic) for the same reason.
|
|
50
|
+
plugins: [react({ jsxRuntime: 'classic' }), live.plugin],
|
|
51
|
+
server: {
|
|
52
|
+
// 8182, because `pcf-scripts start` owns 8181 — both harnesses can run side by side.
|
|
53
|
+
// strictPort so a clash fails loudly instead of silently moving to another port.
|
|
54
|
+
port: 8182,
|
|
55
|
+
strictPort: true,
|
|
56
|
+
open: true,
|
|
57
|
+
proxy: live.proxy,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
});
|
|
@@ -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>
|
|
@@ -11,7 +11,12 @@ export interface I{{componentName}}ComponentProps {
|
|
|
11
11
|
disabled?: boolean;
|
|
12
12
|
onChange: (value: {{tsType}}) => void;
|
|
13
13
|
onBlur: () => void;
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The bound PCF context. OPTIONAL so the dev harness can render this component
|
|
16
|
+
* without a platform context (only the optionset variant reads it, for the
|
|
17
|
+
* bound column's choice metadata). The PCF host always supplies it.
|
|
18
|
+
*/
|
|
19
|
+
context?: ComponentFramework.Context<any>;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
export const {{componentName}}Component: React.FC<I{{componentName}}ComponentProps> = ({
|
|
@@ -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
|
};
|