@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.
Files changed (93) hide show
  1. package/README.md +2 -2
  2. package/dist/artifacts/registry.d.ts.map +1 -1
  3. package/dist/artifacts/registry.js +76 -20
  4. package/dist/artifacts/registry.js.map +1 -1
  5. package/dist/index.js +8 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/templatePlan.d.ts.map +1 -1
  8. package/dist/templatePlan.js +20 -3
  9. package/dist/templatePlan.js.map +1 -1
  10. package/dist/utils/copyTemplate.d.ts.map +1 -1
  11. package/dist/utils/copyTemplate.js +4 -0
  12. package/dist/utils/copyTemplate.js.map +1 -1
  13. package/package.json +1 -1
  14. package/templates/_shared/dataverse-seam/XrmApiService.ts +26 -5
  15. package/templates/grid-customizer/README.md +105 -15
  16. package/templates/grid-customizer/dev/harness/MockGrid.tsx +165 -0
  17. package/templates/grid-customizer/dev/harness/dataverse.ts +168 -0
  18. package/templates/grid-customizer/dev/index.html +20 -0
  19. package/templates/grid-customizer/dev/main.tsx +260 -0
  20. package/templates/grid-customizer/dev/vite-env.d.ts +1 -0
  21. package/templates/grid-customizer/env.local.example +24 -0
  22. package/templates/grid-customizer/eslintrc.json +11 -0
  23. package/templates/grid-customizer/featureconfig.json +3 -0
  24. package/templates/grid-customizer/gitignore +3 -0
  25. package/templates/grid-customizer/package.json +30 -16
  26. package/templates/grid-customizer/pcfconfig.json +3 -0
  27. package/templates/grid-customizer/tsconfig.dev.json +16 -0
  28. package/templates/grid-customizer/tsconfig.json +17 -10
  29. package/templates/grid-customizer/vite.config.ts +64 -0
  30. package/templates/grid-customizer/{{componentName}}/ControlManifest.Input.xml +63 -0
  31. package/templates/grid-customizer/{{componentName}}/customizers/CellEditorOverrides.tsx +38 -0
  32. package/templates/grid-customizer/{{componentName}}/customizers/CellRendererOverrides.tsx +109 -0
  33. package/templates/grid-customizer/{{componentName}}/customizers/cells.tsx +137 -0
  34. package/templates/grid-customizer/{{componentName}}/customizers/config.ts +84 -0
  35. package/templates/grid-customizer/{{componentName}}/index.ts +81 -0
  36. package/templates/grid-customizer/{{componentName}}/strings/{{componentName}}.1033.resx +56 -0
  37. package/templates/grid-customizer/{{componentName}}/types.ts +168 -0
  38. package/templates/host-swap/README.md +57 -2
  39. package/templates/host-swap/_variants/host.custom-page/src/App.tsx +9 -5
  40. package/templates/host-swap/_variants/host.pcf/ControlManifest.Input.xml +26 -2
  41. package/templates/host-swap/_variants/host.pcf/gitignore +7 -0
  42. package/templates/host-swap/_variants/host.pcf/index.ts +21 -3
  43. package/templates/host-swap/_variants/host.pcf/package.json +20 -4
  44. package/templates/host-swap/_variants/host.pcf/{{componentName}}.pcfproj +67 -0
  45. package/templates/host-swap/_variants/host.web-resource/src/App.tsx +5 -1
  46. package/templates/host-swap/src/Component.tsx +54 -7
  47. package/templates/host-swap/src/useHostWidth.ts +53 -0
  48. package/templates/pcf-dataset/dev/harness/dataverse.ts +82 -0
  49. package/templates/pcf-dataset/dev/index.html +20 -0
  50. package/templates/pcf-dataset/dev/main.tsx +194 -0
  51. package/templates/pcf-dataset/dev/vite-env.d.ts +1 -0
  52. package/templates/pcf-dataset/env.local.example +24 -0
  53. package/templates/pcf-dataset/eslintrc.json +11 -0
  54. package/templates/pcf-dataset/gitignore +4 -0
  55. package/templates/pcf-dataset/package.json +18 -8
  56. package/templates/pcf-dataset/pcfconfig.json +3 -0
  57. package/templates/pcf-dataset/tsconfig.dev.json +15 -0
  58. package/templates/pcf-dataset/tsconfig.json +11 -2
  59. package/templates/pcf-dataset/vite.config.ts +63 -0
  60. package/templates/pcf-dataset/{{componentName}}/ControlManifest.Input.xml +52 -0
  61. package/templates/pcf-dataset/{{componentName}}/{{componentName}}App.tsx +64 -0
  62. package/templates/pcf-dataset/{{{componentName}}Component.tsx → {{componentName}}/{{componentName}}Component.tsx} +9 -46
  63. package/templates/pcf-field/dev/harness/dataverse.ts +71 -0
  64. package/templates/pcf-field/dev/index.html +20 -0
  65. package/templates/pcf-field/dev/main.tsx +212 -0
  66. package/templates/pcf-field/dev/vite-env.d.ts +1 -0
  67. package/templates/pcf-field/env.local.example +24 -0
  68. package/templates/pcf-field/eslintrc.json +11 -0
  69. package/templates/pcf-field/gitignore +4 -0
  70. package/templates/pcf-field/package.json +11 -2
  71. package/templates/pcf-field/pcfconfig.json +3 -0
  72. package/templates/pcf-field/tsconfig.dev.json +15 -0
  73. package/templates/pcf-field/tsconfig.json +20 -4
  74. package/templates/pcf-field/vite.config.ts +60 -0
  75. package/templates/pcf-field/{{componentName}}/ControlManifest.Input.xml +42 -0
  76. package/templates/pcf-field/{{{componentName}}Component.tsx → {{componentName}}/{{componentName}}Component.tsx} +6 -1
  77. package/templates/power-pages-island/README.md +12 -0
  78. package/templates/power-pages-island/src/App.tsx +13 -1
  79. package/templates/power-pages-island/src/Component.tsx +54 -7
  80. package/templates/power-pages-island/src/useHostWidth.ts +53 -0
  81. package/templates/react-custom-page/tools/deploy/deploy-webresource.cjs +8 -2
  82. package/templates/grid-customizer/src/GridCustomizer.ts +0 -28
  83. package/templates/grid-customizer/src/cell-renderers.tsx +0 -35
  84. package/templates/grid-customizer/src/index.ts +0 -4
  85. package/templates/grid-customizer/src/types/grid-types.ts +0 -30
  86. package/templates/grid-customizer/src/utils/color-utils.ts +0 -24
  87. package/templates/grid-customizer/webpack.config.js +0 -17
  88. package/templates/pcf-dataset/ControlManifest.Input.xml +0 -16
  89. package/templates/pcf-field/ControlManifest.Input.xml +0 -17
  90. /package/templates/pcf-dataset/{index.ts → {{componentName}}/index.ts} +0 -0
  91. /package/templates/pcf-dataset/{strings → {{componentName}}/strings}/{{componentName}}.1033.resx +0 -0
  92. /package/templates/pcf-field/{index.ts → {{componentName}}/index.ts} +0 -0
  93. /package/templates/pcf-field/{strings → {{componentName}}/strings}/{{componentName}}.1033.resx +0 -0
@@ -1,28 +1,118 @@
1
- # {{componentName}} (grid customizer)
1
+ # {{componentName}} — Power Apps grid customizer
2
2
 
3
- A Dynamics 365 **editable-grid customizer** — maps column data types / logical names to custom Fluent
4
- UI v9 cell renderers (status badge, currency, date, progress).
3
+ A **PCF virtual control** that overrides how Microsoft's editable grid (PowerAppsOneGrid)
4
+ paints its cells: option sets become coloured badges, currency is right-aligned and coloured
5
+ by sign, yes/no becomes a tick or cross, and nominated numeric columns become progress bars.
5
6
 
6
- ## Develop
7
+ It is **not a grid**. It renders nothing of its own — it hands the platform's grid a map of
8
+ cell renderers and the grid does the drawing.
9
+
10
+ ```
11
+ OneGrid ──(bound EventName)──▶ {{componentName}}.init
12
+ └─ factory.fireEvent(eventName, { cellRendererOverrides, cellEditorOverrides })
13
+ OneGrid ◀─────────────────────┘ then calls those overrides while painting each cell
14
+ ```
15
+
16
+ ## Quick start
7
17
 
8
18
  ```bash
9
19
  npm install
10
- npm run build # webpack → dist/{{projectName}}.js (UMD)
11
- npm run typecheck # tsc --noEmit
20
+
21
+ npm run dev # ★ the working local loop — http://localhost:8182
22
+ npm run build # pcf-scripts build → out/controls/{{componentName}}/
12
23
  ```
13
24
 
14
- ## Customize
25
+ `npm start` (the stock `pcf-scripts` harness) will load the control and show a **blank
26
+ canvas**. That is correct, not a bug: the harness has no OneGrid, so nothing ever calls the
27
+ overrides. `npm run dev` runs a stand-in grid that calls them exactly as the platform does.
28
+
29
+ ## Local development
30
+
31
+ `npm run dev` serves the harness in `dev/`:
15
32
 
16
- Edit `src/GridCustomizer.ts` to map columns to renderers, and add renderers in
17
- `src/cell-renderers.tsx`:
33
+ | Data source | What it gives you |
34
+ |---|---|
35
+ | **Mock** (default) | One column per overridden data type, including an unmapped status label so you can see the derived-colour fallback. No org needed. |
36
+ | **Live** | Real rows **and real column data types** from your org, read from entity metadata. |
18
37
 
19
- ```ts
20
- cellRendererOverrides = {
21
- OptionSet: StatusBadgeRenderer, // by data type
22
- prioritycode: StatusBadgeRenderer // by column logical name
23
- };
38
+ Live mode needs one line of config plus an Azure login:
39
+
40
+ ```bash
41
+ cp env.local.example .env.local # set VITE_DYNAMICS_URL=https://<org>.crm.dynamics.com
42
+ az login
43
+ npm run dev
24
44
  ```
25
45
 
46
+ The dev server acquires and refreshes the bearer token **in-process, server-side** — it is
47
+ never exposed to the browser bundle. See `env.local.example` for the full contract.
48
+
49
+ > **Why Live mode reads metadata.** A customizer is dispatched purely by `ColumnDataType`.
50
+ > Currency and Decimal are both `number` on the wire; Picklist and Integer are too. Guessing
51
+ > the type from a sample value would exercise the wrong handler, so the harness reads the
52
+ > real `AttributeType` — the same classification the platform uses.
53
+
54
+ ## Customizing it
55
+
56
+ | File | What it is for |
57
+ |---|---|
58
+ | `{{componentName}}/customizers/config.ts` | **Start here.** Status colours, which columns get progress bars, currency colours. |
59
+ | `{{componentName}}/customizers/cells.tsx` | What each custom cell looks like. |
60
+ | `{{componentName}}/customizers/CellRendererOverrides.tsx` | Which data type maps to which cell, and how to scope one to a single column. |
61
+ | `{{componentName}}/customizers/CellEditorOverrides.tsx` | Edit-mode overrides. Empty by default — the built-in editors are good. |
62
+ | `{{componentName}}/types.ts` | The platform contract. Vendored: Microsoft ships no package for it. |
63
+
64
+ Three rules the compiler cannot enforce for you:
65
+
66
+ 1. **Override keys are column DATA TYPES, never logical names.** `{ statuscode: … }` never
67
+ fires. Register under `OptionSet` and narrow with
68
+ `params.colDefs[params.columnIndex].name`.
69
+ 2. **Return `undefined` to fall through** to the grid's own renderer. Returning an empty
70
+ element blanks the cell instead.
71
+ 3. **Render `props.formattedValue`, not `props.value`.** `formattedValue` is already
72
+ localized, currency-symbolled and resolved to option labels; `value` is raw.
73
+
74
+ ### There is no maker-facing configuration
75
+
76
+ A grid customizer **cannot receive `usage="input"` properties**. The grid's *Customizer
77
+ control* field references this control by name only, with nowhere to put a value, so any
78
+ input property is `undefined` at runtime — and reading `.raw` off it throws inside `init()`,
79
+ which the host silently swallows. Configure in `config.ts` and redeploy.
80
+
81
+ If you need rules that change without a redeploy, read them from a Dataverse **environment
82
+ variable** in `init()`. That requires `<uses-feature name="WebAPI" required="true" />` in the
83
+ manifest — without it `context.webAPI` exists but throws. (If you have the `claude-workspace`
84
+ checkout, `shared/pcf-gallery/controls/color-grid` is a complete worked example, including
85
+ throttled re-reads so an already-open grid picks up a change.)
86
+
26
87
  ## Deploy
27
88
 
28
- Bundle and register `dist/{{projectName}}.js` on the grid control's customizer property.
89
+ A customizer is a **PCF control**, so it ships in a solution — not as a web resource.
90
+
91
+ ```bash
92
+ # 1. package (from the workspace root)
93
+ msft/04-dynamics/scripts/deploy/package-control.sh \
94
+ --source ./{{projectName}} --version 1.0.0 --output ./dist
95
+
96
+ # 2. import
97
+ pac solution import --path ./dist/{{componentName}}_1.0.0.zip --publish-changes
98
+ ```
99
+
100
+ Then bind it to a table's grid by setting `GridCustomizerControlFullName` on that table's
101
+ PowerAppsOneGrid configuration (a `customcontroldefaultconfigs` record) to
102
+ `<prefix>_{{namespace}}.{{componentName}}` — `org-bootstrap.sh`'s `controls` + `bindings`
103
+ steps do this, or set **Customizer control** in the table's grid properties in the maker
104
+ portal.
105
+
106
+ ★ **Bump `version=` in `ControlManifest.Input.xml` on every change.** The platform caches by
107
+ `(namespace.constructor, version)`; without a bump it keeps serving the previous build and
108
+ you will debug a fix that was never deployed.
109
+
110
+ ## Platform versions
111
+
112
+ `React 16.14.0` + `Fluent 8.121.1`, both declared as `<platform-library>` and pinned exactly
113
+ in `package.json`. A virtual control returns a ReactElement that the **platform** renders, so
114
+ compiled-against must equal served. Measured live 2026-08-02: the model-driven host serves
115
+ React 17.0.2 under the `Reactv16` alias, defines no `Reactv18`, and serves Fluent 8.121.1
116
+ **and** 9.68.0. v8 is used here because the cells render inside OneGrid's own v8 React tree —
117
+ a v9 cell would need its own `FluentProvider` and would be themed independently of the grid
118
+ around it. The manifest carries the full measurement.
@@ -0,0 +1,165 @@
1
+ import * as React from 'react';
2
+ import type {
3
+ CellRendererOverrides,
4
+ CellRendererProps,
5
+ ColumnDataType,
6
+ ColumnDefinition,
7
+ GetRendererParams,
8
+ } from '../../{{componentName}}/types';
9
+
10
+ /**
11
+ * A stand-in for Microsoft's PowerAppsOneGrid, present only so the customizer has something
12
+ * to customize during local development.
13
+ *
14
+ * ★ Its whole value is that it calls the overrides EXACTLY the way the real grid does:
15
+ * - looks the handler up by the column's `ColumnDataType`, never by its logical name;
16
+ * - passes `(CellRendererProps, GetRendererParams)`, with `colDefs` + `columnIndex` so a
17
+ * handler can narrow to one column;
18
+ * - treats a `null`/`undefined` return as "use the default renderer" rather than as an
19
+ * empty cell.
20
+ *
21
+ * If a renderer works here and not in the org, the difference is the platform (versions,
22
+ * bindings, publish state) — not the contract. It deliberately does NOT reimplement sorting,
23
+ * filtering, selection, paging or inline editing: the real grid owns all of those, and a
24
+ * fake one that drifted from them would be worse than none.
25
+ */
26
+
27
+ /** One column of the fake grid: the platform's `ColumnDefinition` plus a display width. */
28
+ export interface HarnessColumn {
29
+ name: string;
30
+ displayName: string;
31
+ dataType: ColumnDataType;
32
+ width?: number;
33
+ }
34
+
35
+ /** One cell: the raw value the platform would pass, and the text it would have painted. */
36
+ export interface HarnessCell {
37
+ raw: unknown;
38
+ formatted: string;
39
+ }
40
+
41
+ /** One row, keyed by column logical name. */
42
+ export type HarnessRow = Record<string, HarnessCell>;
43
+
44
+ export interface MockGridProps {
45
+ columns: HarnessColumn[];
46
+ rows: HarnessRow[];
47
+ overrides: CellRendererOverrides;
48
+ /** Row height in px — the real grid gives cells a fixed height, so mirror that. */
49
+ rowHeight?: number;
50
+ }
51
+
52
+ const ROW_HEIGHT = 42;
53
+ const BORDER = '#edebe9';
54
+
55
+ const headerCell: React.CSSProperties = {
56
+ textAlign: 'left',
57
+ fontSize: 12,
58
+ fontWeight: 600,
59
+ color: '#323130',
60
+ padding: '0 8px',
61
+ height: 36,
62
+ borderBottom: `1px solid ${BORDER}`,
63
+ whiteSpace: 'nowrap',
64
+ };
65
+
66
+ export const MockGrid: React.FC<MockGridProps> = ({
67
+ columns,
68
+ rows,
69
+ overrides,
70
+ rowHeight = ROW_HEIGHT,
71
+ }) => {
72
+ // Built once per render and shared by every cell, exactly as the grid does — a handler
73
+ // that inspects sibling columns sees the same array the platform would give it.
74
+ const colDefs: ColumnDefinition[] = columns.map((c, i) => ({
75
+ name: c.name,
76
+ displayName: c.displayName,
77
+ dataType: c.dataType,
78
+ isPrimary: i === 0,
79
+ }));
80
+
81
+ if (columns.length === 0) {
82
+ return (
83
+ <div style={{ color: '#605e5c', fontSize: 13, padding: 8 }}>
84
+ No columns — pick a data source and load.
85
+ </div>
86
+ );
87
+ }
88
+
89
+ return (
90
+ <div style={{ border: `1px solid ${BORDER}`, background: '#fff', overflowX: 'auto' }}>
91
+ <table style={{ borderCollapse: 'collapse', width: '100%', tableLayout: 'fixed' }}>
92
+ <colgroup>
93
+ {columns.map((c) => (
94
+ <col key={c.name} style={{ width: c.width ? `${c.width}px` : 'auto' }} />
95
+ ))}
96
+ </colgroup>
97
+ <thead>
98
+ <tr>
99
+ {columns.map((c) => (
100
+ <th key={c.name} style={headerCell} title={`${c.name} — ${c.dataType}`}>
101
+ {c.displayName}
102
+ <span style={{ color: '#a19f9d', fontWeight: 400 }}> · {c.dataType}</span>
103
+ </th>
104
+ ))}
105
+ </tr>
106
+ </thead>
107
+ <tbody>
108
+ {rows.map((row, rowIndex) => (
109
+ <tr key={rowIndex}>
110
+ {columns.map((col, columnIndex) => {
111
+ const cell = row[col.name] ?? { raw: undefined, formatted: '' };
112
+
113
+ const props: CellRendererProps = {
114
+ value: cell.raw,
115
+ formattedValue: cell.formatted,
116
+ columnDataType: col.dataType,
117
+ rowHeight,
118
+ columnEditable: false,
119
+ isRightAligned:
120
+ col.dataType === 'Currency' ||
121
+ col.dataType === 'Decimal' ||
122
+ col.dataType === 'Integer' ||
123
+ col.dataType === 'FloatingPoint',
124
+ // Part of the contract, unused by a read-only harness. A renderer that
125
+ // calls it in the org would still compile against the same signature.
126
+ onValueChange: () => undefined,
127
+ };
128
+
129
+ const params: GetRendererParams = {
130
+ colDefs,
131
+ columnIndex,
132
+ rowData: { __rec_id: String(rowIndex) },
133
+ };
134
+
135
+ const handler = overrides[col.dataType];
136
+ const custom = handler ? handler(props, params) : undefined;
137
+
138
+ return (
139
+ <td
140
+ key={col.name}
141
+ style={{
142
+ height: rowHeight,
143
+ padding: '0 8px',
144
+ borderBottom: `1px solid ${BORDER}`,
145
+ fontSize: 13,
146
+ overflow: 'hidden',
147
+ textOverflow: 'ellipsis',
148
+ whiteSpace: 'nowrap',
149
+ // The real grid right-aligns numerics itself; a custom renderer that
150
+ // returns undefined must therefore still land in the default layout.
151
+ textAlign: props.isRightAligned && !custom ? 'right' : 'left',
152
+ }}
153
+ >
154
+ {/* ★ null/undefined => the grid's own rendering, not an empty cell. */}
155
+ {custom ?? cell.formatted}
156
+ </td>
157
+ );
158
+ })}
159
+ </tr>
160
+ ))}
161
+ </tbody>
162
+ </table>
163
+ </div>
164
+ );
165
+ };
@@ -0,0 +1,168 @@
1
+ import { apiFetch, toHttpError } from '@dataverse-kit/pcf-dev-harness/shell';
2
+ import type { ColumnDataType } from '../../{{componentName}}/types';
3
+ import type { HarnessColumn, HarnessRow } from './MockGrid';
4
+
5
+ /**
6
+ * TIER 2 of the dev harness — the {{componentName}}-specific live-org read.
7
+ *
8
+ * The reusable parts (shell, proxy, token refresh, `apiFetch`'s one-shot 401/403 retry) come
9
+ * from `@dataverse-kit/pcf-dev-harness`. This file supplies what only THIS control needs,
10
+ * and it is not the rows: it is the **column data types**.
11
+ *
12
+ * ★ A customizer is dispatched entirely by `ColumnDataType`. Guessing the type from a sample
13
+ * value cannot work — a Currency and a Decimal are both `number` over the wire, a Picklist
14
+ * and an Integer are both `number`, a DateOnly and a DateAndTime are both strings — so the
15
+ * harness would exercise the wrong handler (or none) and tell you nothing. It therefore
16
+ * reads the real `AttributeType` from entity metadata, the same classification the platform
17
+ * uses when it decides which override to call.
18
+ *
19
+ * All calls go through the same-origin `/api/data` proxy in `vite.config.ts`, which injects
20
+ * the bearer token server-side — no token ever reaches the browser. READ-ONLY: the harness
21
+ * never writes, so there is no save counterpart.
22
+ */
23
+
24
+ export type { HttpError } from '@dataverse-kit/pcf-dev-harness/shell';
25
+
26
+ /** What the grid contract calls a column, keyed by Dataverse's `AttributeTypeCode`. */
27
+ const ATTRIBUTE_TYPE_TO_COLUMN_TYPE: Readonly<Record<string, ColumnDataType>> = {
28
+ // The three that actually drive this template's overrides.
29
+ Picklist: 'OptionSet',
30
+ State: 'OptionSet',
31
+ Status: 'OptionSet',
32
+ Boolean: 'TwoOptions',
33
+ Money: 'Currency',
34
+ Decimal: 'Decimal',
35
+ Double: 'FloatingPoint',
36
+ // Everything below is mapped for completeness, so a column you add an override for later
37
+ // is dispatched correctly without touching this file.
38
+ Integer: 'Integer',
39
+ BigInt: 'Integer',
40
+ String: 'Text',
41
+ Memo: 'TextArea',
42
+ DateTime: 'DateAndTime',
43
+ Lookup: 'Lookup',
44
+ Customer: 'Customer',
45
+ Owner: 'Owner',
46
+ Virtual: 'Text',
47
+ Uniqueidentifier: 'Text',
48
+ EntityName: 'Text',
49
+ };
50
+
51
+ /**
52
+ * ★ Lookup-typed columns are mapped above but cannot be LOADED by this harness. Dataverse
53
+ * requires the `_<name>_value` form in `$select` for a lookup, while the metadata call wants
54
+ * the plain logical name — so a binding that lists one gets a clean metadata read and then a
55
+ * 400 on the row fetch. Drop lookups from the Columns box; the mapping is here so that a
56
+ * lookup override you add later dispatches correctly in the org, where the platform supplies
57
+ * the columns rather than you typing them.
58
+ */
59
+
60
+ interface AttributeMetadata {
61
+ LogicalName: string;
62
+ AttributeType: string;
63
+ DisplayName?: { UserLocalizedLabel?: { Label?: string } | null } | null;
64
+ }
65
+
66
+ /** OData annotation carrying the text the platform itself would paint in the cell. */
67
+ const FORMATTED = '@OData.Community.Display.V1.FormattedValue';
68
+
69
+ /** Resolve an entity SET name (`accounts`) to its logical name (`account`) for metadata. */
70
+ async function resolveLogicalName(entitySetName: string): Promise<string> {
71
+ const params = new URLSearchParams({
72
+ $select: 'LogicalName',
73
+ $filter: `EntitySetName eq '${entitySetName.replace(/'/g, "''")}'`,
74
+ });
75
+ const res = await apiFetch(`/api/data/v9.2/EntityDefinitions?${params}`);
76
+ if (!res.ok) throw await toHttpError(res);
77
+ const body = (await res.json()) as { value?: { LogicalName: string }[] };
78
+ const logicalName = body.value?.[0]?.LogicalName;
79
+ if (!logicalName) {
80
+ throw new Error(`No table has entity set name "${entitySetName}".`);
81
+ }
82
+ return logicalName;
83
+ }
84
+
85
+ /**
86
+ * Read `AttributeType` + display label for every requested column, in the order given.
87
+ *
88
+ * Fetches the table's WHOLE attribute set (~200 rows for `account`) rather than filtering to
89
+ * the requested columns. Deliberate: an `or`-chain built from user-typed field names is the
90
+ * one place in this file where input would reach an OData filter expression, and the saving
91
+ * is a few hundred KB on a dev-only call made once per Load.
92
+ */
93
+ async function fetchColumnTypes(
94
+ logicalName: string,
95
+ fields: string[],
96
+ ): Promise<HarnessColumn[]> {
97
+ const params = new URLSearchParams({ $select: 'LogicalName,AttributeType,DisplayName' });
98
+ const res = await apiFetch(
99
+ `/api/data/v9.2/EntityDefinitions(LogicalName='${logicalName}')/Attributes?${params}`,
100
+ );
101
+ if (!res.ok) throw await toHttpError(res);
102
+ const body = (await res.json()) as { value?: AttributeMetadata[] };
103
+
104
+ const byName = new Map((body.value ?? []).map((a) => [a.LogicalName, a]));
105
+ return fields.map((f, i) => {
106
+ const meta = byName.get(f);
107
+ return {
108
+ name: f,
109
+ displayName: meta?.DisplayName?.UserLocalizedLabel?.Label ?? f,
110
+ // An unknown or missing attribute falls back to Text — it then renders through the
111
+ // default path, which is the honest outcome for a column we cannot classify.
112
+ dataType: (meta && ATTRIBUTE_TYPE_TO_COLUMN_TYPE[meta.AttributeType]) ?? 'Text',
113
+ width: i === 0 ? 220 : 160,
114
+ };
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Fetch `top` rows of `select` columns, with each column's real data type attached.
120
+ *
121
+ * @param entitySetName plural set name, e.g. `accounts`
122
+ * @param select comma-separated logical names, e.g. `name,revenue,statuscode`
123
+ * @param top max rows
124
+ */
125
+ export async function fetchGridData(
126
+ entitySetName: string,
127
+ select: string,
128
+ top: string,
129
+ ): Promise<{ columns: HarnessColumn[]; rows: HarnessRow[] }> {
130
+ const fields = select
131
+ .split(',')
132
+ .map((s) => s.trim())
133
+ .filter(Boolean);
134
+ if (fields.length === 0) throw new Error('Enter at least one column.');
135
+
136
+ const logicalName = await resolveLogicalName(entitySetName);
137
+ const columns = await fetchColumnTypes(logicalName, fields);
138
+
139
+ const params = new URLSearchParams({ $select: fields.join(','), $top: top || '25' });
140
+ const res = await apiFetch(`/api/data/v9.2/${entitySetName}?${params}`, {
141
+ // Ask for the formatted-value annotations the deployed control renders.
142
+ headers: { Prefer: 'odata.include-annotations="OData.Community.Display.V1.FormattedValue"' },
143
+ });
144
+ if (!res.ok) throw await toHttpError(res);
145
+ const body = (await res.json()) as { value?: Record<string, unknown>[] };
146
+
147
+ const rows: HarnessRow[] = (body.value ?? []).map((record) => {
148
+ const row: HarnessRow = {};
149
+ for (const f of fields) {
150
+ const raw = record[f];
151
+ const formatted = record[`${f}${FORMATTED}`];
152
+ row[f] = {
153
+ raw: raw ?? undefined,
154
+ // No annotation means the value needs no formatting (plain text, a number the
155
+ // platform shows as-is) — stringify the raw value, as the grid would.
156
+ formatted:
157
+ formatted !== undefined && formatted !== null
158
+ ? String(formatted)
159
+ : raw === undefined || raw === null
160
+ ? ''
161
+ : String(raw),
162
+ };
163
+ }
164
+ return row;
165
+ });
166
+
167
+ return { columns, rows };
168
+ }
@@ -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>