@khester/create-dynamics-app 3.1.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 +23 -5
- package/dist/artifacts/registry.js.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- 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/pcf-dataset/dev/main.tsx +5 -1
- package/templates/pcf-dataset/{{componentName}}/ControlManifest.Input.xml +8 -1
- 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/gitignore +4 -0
- package/templates/pcf-field/package.json +10 -2
- 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}}/{{componentName}}Component.tsx +6 -1
- 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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Power Apps OneGrid customizer contract.
|
|
5
|
+
*
|
|
6
|
+
* Microsoft publishes no npm package for these types, so every grid customizer vendors
|
|
7
|
+
* them. This copy matches Microsoft's documented shape. (If you have the `claude-workspace`
|
|
8
|
+
* checkout, `shared/pcf-gallery/controls/color-grid` vendors the same contract.)
|
|
9
|
+
*
|
|
10
|
+
* Reference:
|
|
11
|
+
* https://learn.microsoft.com/power-apps/developer/component-framework/customize-editable-grid-control
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** What `context.factory.fireEvent(eventName, …)` is handed. */
|
|
15
|
+
export interface PAOneGridCustomizer {
|
|
16
|
+
/** Full-surface customization. Prefer the two override maps below for cell-level work. */
|
|
17
|
+
gridCustomizer?: GridCustomizer;
|
|
18
|
+
/** Overrides for the grid's built-in cell RENDERERS (read mode). */
|
|
19
|
+
cellRendererOverrides?: CellRendererOverrides;
|
|
20
|
+
/** Overrides for the grid's built-in cell EDITORS (edit mode). */
|
|
21
|
+
cellEditorOverrides?: CellEditorOverrides;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* ★ Keyed by COLUMN DATA TYPE, never by column logical name.
|
|
26
|
+
*
|
|
27
|
+
* A `{ statuscode: renderer }` entry silently never fires — `statuscode` is not a
|
|
28
|
+
* `ColumnDataType`. To scope a renderer to one column, register under its data type and
|
|
29
|
+
* narrow inside the handler with `params.colDefs[params.columnIndex].name`.
|
|
30
|
+
*
|
|
31
|
+
* Returning `undefined` (or `null`) falls through to the grid's own renderer for that cell,
|
|
32
|
+
* which is how a handler opts out per row/column.
|
|
33
|
+
*/
|
|
34
|
+
export type CellRendererOverrides = {
|
|
35
|
+
[dataType in ColumnDataType]?: (
|
|
36
|
+
props: CellRendererProps,
|
|
37
|
+
rendererParams: GetRendererParams,
|
|
38
|
+
) => React.ReactElement | null | undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Same keying + fall-through rules as CellRendererOverrides. */
|
|
42
|
+
export type CellEditorOverrides = {
|
|
43
|
+
[dataType in ColumnDataType]?: (
|
|
44
|
+
defaultProps: CellEditorProps,
|
|
45
|
+
editorParams: GetEditorParams,
|
|
46
|
+
) => React.ReactElement | null | undefined;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export interface GridCustomizer {
|
|
50
|
+
GetHeaderRenderer?(params: GetHeaderParams): React.ReactElement;
|
|
51
|
+
GetCellRenderer?(params: GetRendererParams): React.ReactElement;
|
|
52
|
+
GetLoadingRowRenderer?(): React.ReactElement;
|
|
53
|
+
GetCellEditor?(params: GetEditorParams): React.ReactElement;
|
|
54
|
+
GetNoRowsOverlayConfiguration?(): NoRowsOverlayConfig;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CellRendererProps {
|
|
58
|
+
onValueChange(checked: boolean): void;
|
|
59
|
+
/** Raw cell value — a number for Currency/Decimal, an option value for OptionSet, … */
|
|
60
|
+
readonly value: unknown;
|
|
61
|
+
readonly isRTLMode?: boolean;
|
|
62
|
+
readonly onCellClicked?: (
|
|
63
|
+
event?: React.MouseEvent<HTMLElement, MouseEvent> | MouseEvent,
|
|
64
|
+
) => void;
|
|
65
|
+
readonly validationError?: Error | null;
|
|
66
|
+
readonly isRightAligned?: boolean;
|
|
67
|
+
readonly startEditing?: (editorInitValue?: unknown) => void;
|
|
68
|
+
readonly isLastRow?: boolean;
|
|
69
|
+
readonly rowHeight?: number;
|
|
70
|
+
readonly columnDataType?: ColumnDataType;
|
|
71
|
+
/** Platform-formatted display text — currency symbols, option labels, localized dates. */
|
|
72
|
+
readonly formattedValue?: string;
|
|
73
|
+
readonly cellContainerElement?: HTMLElement;
|
|
74
|
+
readonly cellErrorLabelId?: string;
|
|
75
|
+
readonly columnEditable?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface GetRendererParams {
|
|
79
|
+
/** Definitions for every visible column, in visual order. */
|
|
80
|
+
readonly colDefs: ColumnDefinition[];
|
|
81
|
+
/** Index into `colDefs` for the cell being rendered. */
|
|
82
|
+
readonly columnIndex: number;
|
|
83
|
+
readonly rowData?: RowData;
|
|
84
|
+
readonly allowTabKeyNavigation?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface CellEditorProps {
|
|
88
|
+
readonly secured?: boolean;
|
|
89
|
+
readonly value: unknown;
|
|
90
|
+
readonly isRTLMode?: boolean;
|
|
91
|
+
readonly rowHeight?: number;
|
|
92
|
+
readonly isRequired?: boolean;
|
|
93
|
+
readonly charPress?: string | null;
|
|
94
|
+
readonly columnDataType?: ColumnDataType;
|
|
95
|
+
readonly onChange: (newValue: unknown) => void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface GetEditorParams {
|
|
99
|
+
readonly colDefs: ColumnDefinition[];
|
|
100
|
+
readonly columnIndex: number;
|
|
101
|
+
readonly onCellValueChanged: (newValue: unknown) => void;
|
|
102
|
+
readonly rowData?: RowData;
|
|
103
|
+
readonly stopEditing: (cancel?: boolean) => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface GetHeaderParams {
|
|
107
|
+
readonly colDefs: ColumnDefinition[];
|
|
108
|
+
readonly columnIndex: number;
|
|
109
|
+
readonly isFirstVisualColumn?: boolean;
|
|
110
|
+
readonly isLastVisualColumn?: boolean;
|
|
111
|
+
readonly rowData?: RowData;
|
|
112
|
+
readonly isRTLMode?: boolean;
|
|
113
|
+
readonly allowTabKeyNavigation?: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface NoRowsOverlayConfig {
|
|
117
|
+
component: React.ComponentClass | undefined;
|
|
118
|
+
props: unknown | undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const RECID = '__rec_id';
|
|
122
|
+
|
|
123
|
+
export interface RowData {
|
|
124
|
+
readonly [RECID]: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ColumnDefinition {
|
|
128
|
+
/** Column LOGICAL name — what you match on to scope a renderer to one column. */
|
|
129
|
+
readonly name: string;
|
|
130
|
+
readonly displayName?: string;
|
|
131
|
+
readonly colId?: string;
|
|
132
|
+
readonly dataType: string;
|
|
133
|
+
readonly CustomRendererType?: string;
|
|
134
|
+
readonly width?: number;
|
|
135
|
+
readonly minWidth?: number;
|
|
136
|
+
readonly maxWidth?: number;
|
|
137
|
+
readonly hide?: boolean;
|
|
138
|
+
readonly isPrimary: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The complete set of override keys the grid recognises. */
|
|
142
|
+
export type ColumnDataType =
|
|
143
|
+
| 'Text'
|
|
144
|
+
| 'Email'
|
|
145
|
+
| 'Phone'
|
|
146
|
+
| 'Ticker'
|
|
147
|
+
| 'URL'
|
|
148
|
+
| 'TextArea'
|
|
149
|
+
| 'Lookup'
|
|
150
|
+
| 'Customer'
|
|
151
|
+
| 'Owner'
|
|
152
|
+
| 'MultiSelectPicklist'
|
|
153
|
+
| 'OptionSet'
|
|
154
|
+
| 'TwoOptions'
|
|
155
|
+
| 'Duration'
|
|
156
|
+
| 'Language'
|
|
157
|
+
| 'Multiple'
|
|
158
|
+
| 'TimeZone'
|
|
159
|
+
| 'Integer'
|
|
160
|
+
| 'Currency'
|
|
161
|
+
| 'Decimal'
|
|
162
|
+
| 'FloatingPoint'
|
|
163
|
+
| 'AutoNumber'
|
|
164
|
+
| 'DateOnly'
|
|
165
|
+
| 'DateAndTime'
|
|
166
|
+
| 'Image'
|
|
167
|
+
| 'File'
|
|
168
|
+
| 'Persona';
|
|
@@ -88,7 +88,11 @@ const Harness: React.FC = () => {
|
|
|
88
88
|
setItems([]);
|
|
89
89
|
setColumns([]);
|
|
90
90
|
setLiveStatus(<span style={{ color: '#605e5c' }}>Binding restored — click Load.</span>);
|
|
91
|
-
//
|
|
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.)
|
|
92
96
|
}, []);
|
|
93
97
|
|
|
94
98
|
const onDataSourceChange = (next: DataSource): void => {
|
|
@@ -20,7 +20,14 @@
|
|
|
20
20
|
ReactDOMv16 = present
|
|
21
21
|
ReactDOMv18 = undefined
|
|
22
22
|
FluentUIReactv81211 = present ← Fluent 8.121.1
|
|
23
|
-
|
|
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.
|
|
24
31
|
|
|
25
32
|
This control previously declared React 18.3.1 (because pcf-scripts allows it). It built
|
|
26
33
|
green and then failed with "An error has occurred." in the org, because it externalised
|
|
@@ -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
|
+
}
|
|
@@ -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=
|
|
@@ -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,15 +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",
|
|
23
29
|
"@typescript-eslint/parser": "^6.21.0",
|
|
30
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
24
31
|
"eslint-plugin-react-hooks": "^4.6.0",
|
|
25
32
|
"pcf-scripts": "^1",
|
|
26
33
|
"pcf-start": "^1",
|
|
27
|
-
"typescript": "^5.3.0"
|
|
34
|
+
"typescript": "^5.3.0",
|
|
35
|
+
"vite": "^5.4.0"
|
|
28
36
|
},
|
|
29
37
|
"browserslist": [
|
|
30
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
|
+
}
|