@khester/create-dynamics-app 3.2.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/artifacts/registry.d.ts.map +1 -1
- package/dist/artifacts/registry.js +4 -3
- package/dist/artifacts/registry.js.map +1 -1
- package/dist/index.js +23 -1
- package/dist/index.js.map +1 -1
- package/dist/injectDevTools.d.ts +2 -0
- package/dist/injectDevTools.d.ts.map +1 -1
- package/dist/injectDevTools.js +27 -1
- package/dist/injectDevTools.js.map +1 -1
- package/dist/utils/installDependencies.d.ts +15 -0
- package/dist/utils/installDependencies.d.ts.map +1 -1
- package/dist/utils/installDependencies.js +32 -2
- package/dist/utils/installDependencies.js.map +1 -1
- package/package.json +2 -2
- package/templates/_shared/dev-tools/auth/get-token.cjs +1 -1
- package/templates/_shared/dev-tools/metadata-sync/index.js +23 -11
- package/templates/_shared/dev-tools/smoke/test-retrieve.js +45 -16
- package/templates/grid-customizer/README.md +21 -2
- package/templates/grid-customizer/dev/harness/MockGrid.tsx +133 -6
- package/templates/grid-customizer/dev/main.tsx +54 -8
- package/templates/grid-customizer/{{componentName}}/customizers/CellEditorOverrides.tsx +97 -28
- package/templates/pcf-field/dev/harness/dataverse.ts +106 -0
- package/templates/pcf-field/dev/main.tsx +62 -17
- package/templates/static-web-app/api/local.settings.json +7 -0
- package/templates/static-web-app/frontend/vite.config.ts +9 -1
- package/templates/static-web-app/package.json +3 -1
- package/templates/static-web-app/staticwebapp.config.json +4 -1
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import type {
|
|
3
|
+
CellEditorOverrides,
|
|
4
|
+
CellEditorProps,
|
|
3
5
|
CellRendererOverrides,
|
|
4
6
|
CellRendererProps,
|
|
5
7
|
ColumnDataType,
|
|
6
8
|
ColumnDefinition,
|
|
9
|
+
GetEditorParams,
|
|
7
10
|
GetRendererParams,
|
|
8
11
|
} from '../../{{componentName}}/types';
|
|
9
12
|
|
|
@@ -20,8 +23,23 @@ import type {
|
|
|
20
23
|
*
|
|
21
24
|
* If a renderer works here and not in the org, the difference is the platform (versions,
|
|
22
25
|
* bindings, publish state) — not the contract. It deliberately does NOT reimplement sorting,
|
|
23
|
-
* filtering, selection
|
|
24
|
-
*
|
|
26
|
+
* filtering, selection or paging: the real grid owns all of those, and a fake one that drifted
|
|
27
|
+
* from them would be worse than none.
|
|
28
|
+
*
|
|
29
|
+
* ★ It DOES reimplement just enough inline editing to invoke `cellEditorOverrides`, because
|
|
30
|
+
* without it that half of the contract had never been executed anywhere — the map shipped
|
|
31
|
+
* empty, the only example lived in a docblock, and "it typechecks" was the whole of its
|
|
32
|
+
* coverage. Edit mode dispatches editors by the same rules as renderers:
|
|
33
|
+
* - keyed by `ColumnDataType`, never by column name;
|
|
34
|
+
* - `(CellEditorProps, GetEditorParams)`, with `colDefs` + `columnIndex` for narrowing;
|
|
35
|
+
* - `undefined` means "use the platform's own editor", NOT "render nothing".
|
|
36
|
+
* ⚠️ The double-click GESTURE is the harness's own invention — the platform also has a
|
|
37
|
+
* type-to-edit path that seeds `charPress`, which this never populates.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ What it CANNOT tell you: whether the real grid commits the value the way this does. The
|
|
40
|
+
* platform owns commit (`onChange` vs `onCellValueChanged` vs `stopEditing`) and none of that
|
|
41
|
+
* is observable here. Edit mode proves the map is reached, keyed and narrowed correctly; it
|
|
42
|
+
* does not prove a write lands in Dataverse.
|
|
25
43
|
*/
|
|
26
44
|
|
|
27
45
|
/** One column of the fake grid: the platform's `ColumnDefinition` plus a display width. */
|
|
@@ -30,6 +48,8 @@ export interface HarnessColumn {
|
|
|
30
48
|
displayName: string;
|
|
31
49
|
dataType: ColumnDataType;
|
|
32
50
|
width?: number;
|
|
51
|
+
/** Per-column editability. The platform sets this from metadata/security, not per grid. */
|
|
52
|
+
editable?: boolean;
|
|
33
53
|
}
|
|
34
54
|
|
|
35
55
|
/** One cell: the raw value the platform would pass, and the text it would have painted. */
|
|
@@ -47,6 +67,18 @@ export interface MockGridProps {
|
|
|
47
67
|
overrides: CellRendererOverrides;
|
|
48
68
|
/** Row height in px — the real grid gives cells a fixed height, so mirror that. */
|
|
49
69
|
rowHeight?: number;
|
|
70
|
+
/** Editor map to dispatch when a cell is being edited. Omit for a read-only grid. */
|
|
71
|
+
editorOverrides?: CellEditorOverrides;
|
|
72
|
+
/** Turn inline editing on. Without it the grid behaves exactly as it always has. */
|
|
73
|
+
editable?: boolean;
|
|
74
|
+
/** Commit handler — the harness owns the row data, as the platform owns the record. */
|
|
75
|
+
onCellCommit?: (rowIndex: number, columnName: string, next: unknown) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Which cell is open for editing. */
|
|
79
|
+
interface EditTarget {
|
|
80
|
+
rowIndex: number;
|
|
81
|
+
columnIndex: number;
|
|
50
82
|
}
|
|
51
83
|
|
|
52
84
|
const ROW_HEIGHT = 42;
|
|
@@ -68,7 +100,26 @@ export const MockGrid: React.FC<MockGridProps> = ({
|
|
|
68
100
|
rows,
|
|
69
101
|
overrides,
|
|
70
102
|
rowHeight = ROW_HEIGHT,
|
|
103
|
+
editorOverrides,
|
|
104
|
+
editable = false,
|
|
105
|
+
onCellCommit,
|
|
71
106
|
}) => {
|
|
107
|
+
const [editing, setEditing] = React.useState<EditTarget | null>(null);
|
|
108
|
+
// Leaving edit mode must also close any open editor, or the cell stays stuck in an editor
|
|
109
|
+
// the grid no longer considers editable.
|
|
110
|
+
React.useEffect(() => {
|
|
111
|
+
if (!editable) setEditing(null);
|
|
112
|
+
}, [editable]);
|
|
113
|
+
// ★ `editing` is a pair of INDICES. If the recordset is swapped underneath it (Mock↔Live, a
|
|
114
|
+
// reload, a different $select) the same indices address a different record and a commit would
|
|
115
|
+
// write into it. Close the editor whenever that happens.
|
|
116
|
+
//
|
|
117
|
+
// Keyed on `columns`, NOT `rows`: the harness replaces the columns array on every dataset
|
|
118
|
+
// change, while a COMMIT replaces the rows array too — so depending on `rows` would close the
|
|
119
|
+
// editor after every single edit, which is a worse bug than the one being fixed.
|
|
120
|
+
React.useEffect(() => {
|
|
121
|
+
setEditing(null);
|
|
122
|
+
}, [columns]);
|
|
72
123
|
// Built once per render and shared by every cell, exactly as the grid does — a handler
|
|
73
124
|
// that inspects sibling columns sees the same array the platform would give it.
|
|
74
125
|
const colDefs: ColumnDefinition[] = columns.map((c, i) => ({
|
|
@@ -110,12 +161,15 @@ export const MockGrid: React.FC<MockGridProps> = ({
|
|
|
110
161
|
{columns.map((col, columnIndex) => {
|
|
111
162
|
const cell = row[col.name] ?? { raw: undefined, formatted: '' };
|
|
112
163
|
|
|
164
|
+
const isEditing =
|
|
165
|
+
editing?.rowIndex === rowIndex && editing?.columnIndex === columnIndex;
|
|
166
|
+
|
|
113
167
|
const props: CellRendererProps = {
|
|
114
168
|
value: cell.raw,
|
|
115
169
|
formattedValue: cell.formatted,
|
|
116
170
|
columnDataType: col.dataType,
|
|
117
171
|
rowHeight,
|
|
118
|
-
columnEditable:
|
|
172
|
+
columnEditable: editable && (col.editable ?? true),
|
|
119
173
|
isRightAligned:
|
|
120
174
|
col.dataType === 'Currency' ||
|
|
121
175
|
col.dataType === 'Decimal' ||
|
|
@@ -135,9 +189,82 @@ export const MockGrid: React.FC<MockGridProps> = ({
|
|
|
135
189
|
const handler = overrides[col.dataType];
|
|
136
190
|
const custom = handler ? handler(props, params) : undefined;
|
|
137
191
|
|
|
192
|
+
let content: React.ReactNode;
|
|
193
|
+
if (isEditing) {
|
|
194
|
+
const stopEditing = (): void => setEditing(null);
|
|
195
|
+
const commit = (next: unknown): void => {
|
|
196
|
+
onCellCommit?.(rowIndex, col.name, next);
|
|
197
|
+
};
|
|
198
|
+
const editorProps: CellEditorProps = {
|
|
199
|
+
value: cell.raw,
|
|
200
|
+
rowHeight,
|
|
201
|
+
columnDataType: col.dataType,
|
|
202
|
+
isRequired: false,
|
|
203
|
+
charPress: null,
|
|
204
|
+
// The documented commit path. The harness treats it as authoritative and
|
|
205
|
+
// writes straight through to the row data.
|
|
206
|
+
onChange: commit,
|
|
207
|
+
};
|
|
208
|
+
const editorParams: GetEditorParams = {
|
|
209
|
+
colDefs,
|
|
210
|
+
columnIndex,
|
|
211
|
+
// Part of the contract. Wired so a handler that calls it is not a crash,
|
|
212
|
+
// but the harness commits on onChange — see the file header.
|
|
213
|
+
onCellValueChanged: commit,
|
|
214
|
+
rowData: { __rec_id: String(rowIndex) },
|
|
215
|
+
stopEditing,
|
|
216
|
+
};
|
|
217
|
+
const editorHandler = editorOverrides?.[col.dataType];
|
|
218
|
+
const customEditor = editorHandler
|
|
219
|
+
? editorHandler(editorProps, editorParams)
|
|
220
|
+
: undefined;
|
|
221
|
+
|
|
222
|
+
content = (
|
|
223
|
+
// ★ Guarded blur. Un-guarded, React's bubbling onBlur tears the editor down
|
|
224
|
+
// on ANY internal focus move — and Fluent's SpinButton blurs its input when
|
|
225
|
+
// you mousedown an arrow, so a single click would apply one step and close.
|
|
226
|
+
<div
|
|
227
|
+
onBlur={(e) => {
|
|
228
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) stopEditing();
|
|
229
|
+
}}
|
|
230
|
+
ref={(el) => {
|
|
231
|
+
// Focus the custom editor on open; only the fallback input self-focuses.
|
|
232
|
+
if (el && !el.contains(document.activeElement)) {
|
|
233
|
+
el.querySelector<HTMLElement>('input, [tabindex]')?.focus();
|
|
234
|
+
}
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
{/* ★ undefined => the PLATFORM's editor, not an empty cell. The stand-in
|
|
238
|
+
for that is a plain input, which is what the grid falls back to. */}
|
|
239
|
+
{customEditor ?? (
|
|
240
|
+
<input
|
|
241
|
+
autoFocus
|
|
242
|
+
defaultValue={cell.formatted}
|
|
243
|
+
// ★ Commits on BLUR as well as Enter. The real grid commits its
|
|
244
|
+
// built-in editors on blur, and this input stands in for those — if it
|
|
245
|
+
// discarded on blur it would model the platform backwards on the one
|
|
246
|
+
// gesture a user makes constantly.
|
|
247
|
+
onBlur={(e) => commit(e.target.value)}
|
|
248
|
+
onKeyDown={(e) => {
|
|
249
|
+
if (e.key === 'Enter') commit((e.target as HTMLInputElement).value);
|
|
250
|
+
if (e.key === 'Escape') stopEditing();
|
|
251
|
+
if (e.key === 'Enter') stopEditing();
|
|
252
|
+
}}
|
|
253
|
+
style={{ width: '100%', font: 'inherit', boxSizing: 'border-box' }}
|
|
254
|
+
/>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
} else {
|
|
259
|
+
// ★ null/undefined => the grid's own rendering, not an empty cell.
|
|
260
|
+
content = custom ?? cell.formatted;
|
|
261
|
+
}
|
|
262
|
+
|
|
138
263
|
return (
|
|
139
264
|
<td
|
|
140
265
|
key={col.name}
|
|
266
|
+
onDoubleClick={editable ? () => setEditing({ rowIndex, columnIndex }) : undefined}
|
|
267
|
+
title={editable && !isEditing ? 'Double-click to edit' : undefined}
|
|
141
268
|
style={{
|
|
142
269
|
height: rowHeight,
|
|
143
270
|
padding: '0 8px',
|
|
@@ -146,13 +273,13 @@ export const MockGrid: React.FC<MockGridProps> = ({
|
|
|
146
273
|
overflow: 'hidden',
|
|
147
274
|
textOverflow: 'ellipsis',
|
|
148
275
|
whiteSpace: 'nowrap',
|
|
276
|
+
cursor: editable && !isEditing ? 'cell' : undefined,
|
|
149
277
|
// The real grid right-aligns numerics itself; a custom renderer that
|
|
150
278
|
// returns undefined must therefore still land in the default layout.
|
|
151
|
-
textAlign: props.isRightAligned && !custom ? 'right' : 'left',
|
|
279
|
+
textAlign: props.isRightAligned && !custom && !isEditing ? 'right' : 'left',
|
|
152
280
|
}}
|
|
153
281
|
>
|
|
154
|
-
{
|
|
155
|
-
{custom ?? cell.formatted}
|
|
282
|
+
{content}
|
|
156
283
|
</td>
|
|
157
284
|
);
|
|
158
285
|
})}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type HarnessField,
|
|
11
11
|
} from '@dataverse-kit/pcf-dev-harness/shell';
|
|
12
12
|
import { createCellRendererOverrides } from '../{{componentName}}/customizers/CellRendererOverrides';
|
|
13
|
+
import { createCellEditorOverrides } from '../{{componentName}}/customizers/CellEditorOverrides';
|
|
13
14
|
import { MockGrid, type HarnessColumn, type HarnessRow } from './harness/MockGrid';
|
|
14
15
|
import { fetchGridData, type HttpError } from './harness/dataverse';
|
|
15
16
|
|
|
@@ -132,23 +133,52 @@ const Harness: React.FC = () => {
|
|
|
132
133
|
'percentcomplete',
|
|
133
134
|
);
|
|
134
135
|
|
|
136
|
+
// Edit mode is persisted like the other knobs so a reload keeps you in the editor path.
|
|
137
|
+
const [editable, setEditable] = usePersistedState('editable', false);
|
|
138
|
+
|
|
135
139
|
const [columns, setColumns] = React.useState<HarnessColumn[]>(MOCK_COLUMNS);
|
|
136
140
|
const [rows, setRows] = React.useState<HarnessRow[]>(MOCK_ROWS);
|
|
137
141
|
const [liveStatus, setLiveStatus] = React.useState<React.ReactNode>(null);
|
|
138
142
|
|
|
139
143
|
// The real override map, rebuilt only when the progress-column list changes. This is the
|
|
140
144
|
// same factory index.ts calls — the harness never forks the renderers.
|
|
141
|
-
|
|
145
|
+
// ★ Parsed ONCE and shared. Two copies of this split/trim/filter could drift, which would
|
|
146
|
+
// silently retarget the renderers and the editors at different columns.
|
|
147
|
+
const progressColumnList = React.useMemo(
|
|
142
148
|
() =>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
.filter(Boolean),
|
|
148
|
-
}),
|
|
149
|
+
progressColumns
|
|
150
|
+
.split(',')
|
|
151
|
+
.map((s) => s.trim())
|
|
152
|
+
.filter(Boolean),
|
|
149
153
|
[progressColumns],
|
|
150
154
|
);
|
|
151
155
|
|
|
156
|
+
const overrides = React.useMemo(
|
|
157
|
+
() => createCellRendererOverrides({ progressColumns: progressColumnList }),
|
|
158
|
+
[progressColumnList],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// ★ The customizer's REAL editor map, from the same factory index.ts calls and retargeted at
|
|
162
|
+
// the same columns as the renderers — so the harness never forks the editors either.
|
|
163
|
+
const editorOverrides = React.useMemo(
|
|
164
|
+
() => createCellEditorOverrides({ progressColumns: progressColumnList }),
|
|
165
|
+
[progressColumnList],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
/** Commit an edited cell back into the harness's row data — the platform owns the record. */
|
|
169
|
+
const onCellCommit = React.useCallback(
|
|
170
|
+
(rowIndex: number, columnName: string, next: unknown): void => {
|
|
171
|
+
setRows((prev) =>
|
|
172
|
+
prev.map((row, i) =>
|
|
173
|
+
i === rowIndex
|
|
174
|
+
? { ...row, [columnName]: { raw: next, formatted: next === null || next === undefined ? '' : String(next) } }
|
|
175
|
+
: row,
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
},
|
|
179
|
+
[],
|
|
180
|
+
);
|
|
181
|
+
|
|
152
182
|
// On reload with a restored Live selection: clear and prompt for Load — or fall back to
|
|
153
183
|
// Mock when this session has no org configured. Runs once on mount.
|
|
154
184
|
React.useEffect(() => {
|
|
@@ -250,9 +280,25 @@ const Harness: React.FC = () => {
|
|
|
250
280
|
value: progressColumns,
|
|
251
281
|
onChange: setProgressColumns,
|
|
252
282
|
},
|
|
283
|
+
{
|
|
284
|
+
// ★ The only way to reach `cellEditorOverrides` at all. With it off the grid is
|
|
285
|
+
// read-only and that half of the customizer contract never executes — which is
|
|
286
|
+
// exactly the state this template shipped in until edit mode existed.
|
|
287
|
+
kind: 'boolean',
|
|
288
|
+
label: 'Edit mode (double-click a cell) — exercises cellEditorOverrides',
|
|
289
|
+
value: editable,
|
|
290
|
+
onChange: setEditable,
|
|
291
|
+
},
|
|
253
292
|
]}
|
|
254
293
|
>
|
|
255
|
-
<MockGrid
|
|
294
|
+
<MockGrid
|
|
295
|
+
columns={columns}
|
|
296
|
+
rows={rows}
|
|
297
|
+
overrides={overrides}
|
|
298
|
+
editorOverrides={editorOverrides}
|
|
299
|
+
editable={editable}
|
|
300
|
+
onCellCommit={onCellCommit}
|
|
301
|
+
/>
|
|
256
302
|
</HarnessShell>
|
|
257
303
|
);
|
|
258
304
|
};
|
|
@@ -1,38 +1,107 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { SpinButton } from '@fluentui/react';
|
|
3
|
+
import type { CellEditorOverrides, CellEditorProps, GetEditorParams } from '../types';
|
|
4
|
+
import { PROGRESS_COLUMNS, PROGRESS_MAX, toNumber } from './config';
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Cell EDITOR overrides — what a cell becomes when the user starts editing it.
|
|
5
8
|
*
|
|
6
|
-
* Empty by default: OneGrid's built-in editors already handle every column type correctly,
|
|
7
|
-
* including the lookup search and option-set pickers that are genuinely hard to reimplement.
|
|
8
|
-
* Override only where the default is wrong for your data.
|
|
9
|
-
*
|
|
10
9
|
* The keys and the fall-through rule are identical to `CellRendererOverrides`: key by
|
|
11
10
|
* `ColumnDataType`, narrow to a column with `editorParams.colDefs[editorParams.columnIndex]`,
|
|
12
11
|
* and return `undefined` to keep the platform editor.
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* import { SpinButton } from '@fluentui/react';
|
|
13
|
+
* ★ Override editors SPARINGLY. OneGrid's built-ins already handle every column type, including
|
|
14
|
+
* the lookup search and option-set pickers that are genuinely hard to reimplement — and unlike a
|
|
15
|
+
* renderer, a wrong editor WRITES BAD DATA rather than merely looking wrong. Only `Decimal` is
|
|
16
|
+
* registered here, and only for the columns you opt in.
|
|
19
17
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* defaultValue={String(defaultProps.value ?? 0)}
|
|
30
|
-
* // Commit through onChange; the grid writes the value back to the record.
|
|
31
|
-
* onChange={(_e, v) => defaultProps.onChange(Number(v ?? 0))}
|
|
32
|
-
* />
|
|
33
|
-
* );
|
|
34
|
-
* },
|
|
35
|
-
* };
|
|
36
|
-
* ```
|
|
18
|
+
* ⚠️ **Runtime coverage is LOCAL only.** This map is exercised by the dev harness (`npm run dev`
|
|
19
|
+
* → Edit mode) and by a unit test that invokes it. It has NOT been driven by a real editable
|
|
20
|
+
* grid in an org: doing that needs a live editable subgrid, and the customizer contract's commit
|
|
21
|
+
* semantics (`onChange` vs `editorParams.onCellValueChanged` vs `stopEditing`) are therefore
|
|
22
|
+
* UNVERIFIED here. This file calls `defaultProps.onChange` because it is the only value-carrying
|
|
23
|
+
* callback on `CellEditorProps` — **not** because Microsoft documents it; their editable-grid
|
|
24
|
+
* page has no editor sample, and their own reference control returns `null`. If you hit a commit
|
|
25
|
+
* that does not stick in an org, that is the first thing to re-check — and please record the
|
|
26
|
+
* answer here.
|
|
37
27
|
*/
|
|
38
|
-
|
|
28
|
+
|
|
29
|
+
/** Per-instance knobs, mirroring `RendererOptions` so the harness can retarget the same map. */
|
|
30
|
+
export interface EditorOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Columns that get the clamped numeric editor. Defaults to `config.PROGRESS_COLUMNS` — the
|
|
33
|
+
* SAME list that drives the progress-bar renderer, so a column shown as a 0–100 bar is also
|
|
34
|
+
* edited as one, instead of as a free-text decimal that can be set to 900.
|
|
35
|
+
*/
|
|
36
|
+
progressColumns?: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The column logical name for the cell currently being edited. */
|
|
40
|
+
function columnName(params: GetEditorParams): string | undefined {
|
|
41
|
+
return params.colDefs[params.columnIndex]?.name;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Build the editor map. `cellEditorOverrides` below is the configured singleton. */
|
|
45
|
+
export function createCellEditorOverrides(options: EditorOptions = {}): CellEditorOverrides {
|
|
46
|
+
const progressColumns = options.progressColumns ?? PROGRESS_COLUMNS;
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
// ★ Same per-column narrowing as the Decimal RENDERER. `PROGRESS_COLUMNS` is empty by
|
|
50
|
+
// default, so a scaffold that has not been configured registers this handler and then
|
|
51
|
+
// falls through on every cell — the editor path is inert until you opt a column in, which
|
|
52
|
+
// is the conservative default for something that writes.
|
|
53
|
+
Decimal: (defaultProps: CellEditorProps, editorParams: GetEditorParams) => {
|
|
54
|
+
const name = columnName(editorParams);
|
|
55
|
+
if (!name || !progressColumns.includes(name)) return undefined;
|
|
56
|
+
|
|
57
|
+
const current = toNumber(defaultProps.value) ?? 0;
|
|
58
|
+
const clamp = (n: number): number => Math.min(PROGRESS_MAX, Math.max(0, n));
|
|
59
|
+
|
|
60
|
+
// ★ Commit through `onChange` ONLY.
|
|
61
|
+
//
|
|
62
|
+
// Do not add `onValidate`: Fluent v8's SpinButton calls `onValidate(entered)` and then
|
|
63
|
+
// feeds the result through `useControllableValue`, which calls `onChange` as well — so a
|
|
64
|
+
// single committed edit fires this twice (measured: typing 900 then blurring commits
|
|
65
|
+
// [100, 100]; junk input commits the unchanged value twice). Fluent's built-in validation
|
|
66
|
+
// already clamps to `min`/`max`, so the clamp below is defence-in-depth, not the mechanism.
|
|
67
|
+
//
|
|
68
|
+
// Nor `editorParams.onCellValueChanged` — that is a separate member of the contract and
|
|
69
|
+
// firing both risks the same double write.
|
|
70
|
+
//
|
|
71
|
+
// ⚠️ Why `onChange` and not one of the others: it is the ONLY value-carrying callback on
|
|
72
|
+
// `CellEditorProps` (see ../types.ts). Microsoft's editable-grid page documents no editor
|
|
73
|
+
// sample at all — their own PowerAppsGridCustomizerControl ships `return null` — so this
|
|
74
|
+
// is inference from the type, not a documented guarantee. See the header caveat.
|
|
75
|
+
// ⚠️ `packages/export-engine`'s PA-grid emitter uses a DIFFERENT convention for the same
|
|
76
|
+
// undocumented contract (`onChange` followed by `editorParams.stopEditing()`, and empty →
|
|
77
|
+
// `onChange(null)`). They disagree; whoever verifies against a live editable grid should
|
|
78
|
+
// settle both at once.
|
|
79
|
+
const commit = (raw?: string): void => {
|
|
80
|
+
// Empty means "clear the cell", not "re-commit what was there" — matching the
|
|
81
|
+
// export-engine emitter. Without this a user can never blank an opted-in column.
|
|
82
|
+
if (raw !== undefined && raw.trim() === '') {
|
|
83
|
+
defaultProps.onChange(null);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const parsed = toNumber(raw);
|
|
87
|
+
defaultProps.onChange(clamp(parsed === undefined ? current : parsed));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<SpinButton
|
|
92
|
+
min={0}
|
|
93
|
+
max={PROGRESS_MAX}
|
|
94
|
+
step={5}
|
|
95
|
+
defaultValue={String(current)}
|
|
96
|
+
// Fires once, on a COMMITTED edit (blur / Enter / arrow) — v8 holds an uncommitted
|
|
97
|
+
// `intermediateValue` while typing, so this is not per-keystroke.
|
|
98
|
+
onChange={(_e, v) => commit(v)}
|
|
99
|
+
styles={{ root: { minWidth: 0, width: '100%' } }}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What the control registers with the grid — see `index.ts`. */
|
|
107
|
+
export const cellEditorOverrides: CellEditorOverrides = createCellEditorOverrides();
|
|
@@ -47,6 +47,112 @@ export async function readField(
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** One choice, in the UPPERCASE shape the live PCF `attributes.Options` API uses. */
|
|
51
|
+
export interface ChoiceOption {
|
|
52
|
+
Value: number;
|
|
53
|
+
Label: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read the REAL choice metadata for a column, so a live option-set value renders its actual
|
|
58
|
+
* label instead of falling back to the Dropdown's placeholder.
|
|
59
|
+
*
|
|
60
|
+
* ★ Why this exists: the harness used to hand the control a hard-coded `Options: [1, 2, 3]`.
|
|
61
|
+
* Reading a real value like `100000002` then matched nothing, so the *read* was correct (the
|
|
62
|
+
* status line proved it) while the *rendered label* was blank — which looks exactly like a
|
|
63
|
+
* broken control. Verified against `dvk_devitem.dvk_status`, whose real options are
|
|
64
|
+
* 100000000/Draft, 100000001/Active, 100000002/Done.
|
|
65
|
+
*
|
|
66
|
+
* Three round trips, on each Load (not at mount):
|
|
67
|
+
* 1. entity SET name → entity LOGICAL name (`EntityDefinitions?$filter=EntitySetName eq …`)
|
|
68
|
+
* — the binding gives us the plural set name, but the metadata tree is keyed by logical name.
|
|
69
|
+
* 2. the attribute's `AttributeTypeName` — which decides the cast in step 3. Guessing instead
|
|
70
|
+
* costs the same number of calls in the worst case and fails non-deterministically.
|
|
71
|
+
* 3. the typed cast, with `OptionSet` expanded.
|
|
72
|
+
*
|
|
73
|
+
* ★ The cast is NOT always `PicklistAttributeMetadata`. A `statuscode` column is
|
|
74
|
+
* `StatusAttributeMetadata` and `statecode` is `StateAttributeMetadata`; asking for the Picklist
|
|
75
|
+
* cast on `account.statuscode` returns **404**, not an empty option list — so a single hard-coded
|
|
76
|
+
* cast silently yields no labels on exactly the columns most likely to be bound.
|
|
77
|
+
*
|
|
78
|
+
* Returns `null` for any column that has no choices (a String column, say) or when metadata is
|
|
79
|
+
* unreadable — the caller then keeps its mock options, which is the right behaviour for a
|
|
80
|
+
* harness: never fail the whole panel because one metadata lookup didn't apply.
|
|
81
|
+
*/
|
|
82
|
+
export async function readChoiceOptions(
|
|
83
|
+
entitySetName: string,
|
|
84
|
+
column: string,
|
|
85
|
+
): Promise<ChoiceOption[] | null> {
|
|
86
|
+
// OData string literals escape a quote by doubling it; the result is then URL-encoded
|
|
87
|
+
// because both of these are free-text harness inputs and a stray `#` would truncate the
|
|
88
|
+
// request at the fragment, yielding a 400 the caller can only read as "no choices".
|
|
89
|
+
// (`%24` for `$` is cosmetic consistency with the rest of this file — `readField` above
|
|
90
|
+
// uses a raw `$select` and works fine; there is no shell in the browser→proxy→org path.)
|
|
91
|
+
const setName = encodeURIComponent(entitySetName.replace(/'/g, "''"));
|
|
92
|
+
const defs = await apiFetch(
|
|
93
|
+
`/api/data/v9.2/EntityDefinitions?%24select=LogicalName&%24filter=EntitySetName%20eq%20'${setName}'`,
|
|
94
|
+
);
|
|
95
|
+
if (!defs.ok) return null;
|
|
96
|
+
const logicalName = ((await defs.json()) as { value?: { LogicalName: string }[] }).value?.[0]
|
|
97
|
+
?.LogicalName;
|
|
98
|
+
if (!logicalName) return null;
|
|
99
|
+
|
|
100
|
+
const safeColumn = encodeURIComponent(column.replace(/'/g, "''"));
|
|
101
|
+
const attrPath = `/api/data/v9.2/EntityDefinitions(LogicalName='${logicalName}')/Attributes(LogicalName='${safeColumn}')`;
|
|
102
|
+
const typeRes = await apiFetch(`${attrPath}?%24select=AttributeType,AttributeTypeName`);
|
|
103
|
+
if (!typeRes.ok) return null;
|
|
104
|
+
const attr = (await typeRes.json()) as {
|
|
105
|
+
AttributeType?: string;
|
|
106
|
+
AttributeTypeName?: { Value?: string };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// ★ Dispatch on `AttributeTypeName.Value`, NOT `AttributeType` (corrected in review of #306).
|
|
110
|
+
// `AttributeType` is the legacy `AttributeTypeCode` enum, whose 21 members do NOT include
|
|
111
|
+
// MultiSelectPicklist — a multi-select choice reports `AttributeType: "Virtual"`, so keying on
|
|
112
|
+
// it made that entry unreachable. `AttributeTypeName` carries the real discriminator.
|
|
113
|
+
// Measured against orge2e285aa:
|
|
114
|
+
// account.statuscode → Status/StatusType account.statecode → State/StateType
|
|
115
|
+
// account.name → String/StringType account.accountcategorycode → Picklist/PicklistType
|
|
116
|
+
const cast = {
|
|
117
|
+
PicklistType: 'PicklistAttributeMetadata',
|
|
118
|
+
StatusType: 'StatusAttributeMetadata',
|
|
119
|
+
StateType: 'StateAttributeMetadata',
|
|
120
|
+
MultiSelectPicklistType: 'MultiSelectPicklistAttributeMetadata',
|
|
121
|
+
}[attr.AttributeTypeName?.Value ?? ''];
|
|
122
|
+
if (!cast) return null; // not a choice column — the caller shows the value raw
|
|
123
|
+
|
|
124
|
+
// `GlobalOptionSet` is expanded as belt-and-braces only. Measured across all 20 picklist
|
|
125
|
+
// columns on `account` in orge2e285aa, `OptionSet` was populated EVERY time, including for
|
|
126
|
+
// global-backed choices — so this is not a confirmed gap, just the same defensive read the
|
|
127
|
+
// gallery's own optionset function does. Costs nothing; do not read it as a known failure.
|
|
128
|
+
const meta = await apiFetch(
|
|
129
|
+
`${attrPath}/Microsoft.Dynamics.CRM.${cast}?%24expand=OptionSet(%24select=Options),GlobalOptionSet(%24select=Options)`,
|
|
130
|
+
);
|
|
131
|
+
if (!meta.ok) return null;
|
|
132
|
+
|
|
133
|
+
type OptionRow = {
|
|
134
|
+
Value: number;
|
|
135
|
+
Label?: { UserLocalizedLabel?: { Label?: string }; LocalizedLabels?: { Label?: string }[] };
|
|
136
|
+
};
|
|
137
|
+
const body = (await meta.json()) as {
|
|
138
|
+
OptionSet?: { Options?: OptionRow[] };
|
|
139
|
+
GlobalOptionSet?: { Options?: OptionRow[] };
|
|
140
|
+
};
|
|
141
|
+
const options = body.OptionSet?.Options ?? body.GlobalOptionSet?.Options;
|
|
142
|
+
if (!options?.length) return null;
|
|
143
|
+
|
|
144
|
+
return options.map((o) => ({
|
|
145
|
+
Value: o.Value,
|
|
146
|
+
// `UserLocalizedLabel` is null when the caller's UI language has no provisioned label —
|
|
147
|
+
// a live scenario on CDX orgs — so fall through to the first localised label, then to the
|
|
148
|
+
// raw value. A visible number beats a blank option.
|
|
149
|
+
Label:
|
|
150
|
+
o.Label?.UserLocalizedLabel?.Label ??
|
|
151
|
+
o.Label?.LocalizedLabels?.[0]?.Label ??
|
|
152
|
+
String(o.Value),
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
50
156
|
/**
|
|
51
157
|
* Write one column back to one record — the save half of the round-trip.
|
|
52
158
|
*
|