@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.
Files changed (49) hide show
  1. package/README.md +2 -2
  2. package/dist/artifacts/registry.d.ts.map +1 -1
  3. package/dist/artifacts/registry.js +23 -5
  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/package.json +1 -1
  8. package/templates/grid-customizer/README.md +105 -15
  9. package/templates/grid-customizer/dev/harness/MockGrid.tsx +165 -0
  10. package/templates/grid-customizer/dev/harness/dataverse.ts +168 -0
  11. package/templates/grid-customizer/dev/index.html +20 -0
  12. package/templates/grid-customizer/dev/main.tsx +260 -0
  13. package/templates/grid-customizer/dev/vite-env.d.ts +1 -0
  14. package/templates/grid-customizer/env.local.example +24 -0
  15. package/templates/grid-customizer/eslintrc.json +11 -0
  16. package/templates/grid-customizer/featureconfig.json +3 -0
  17. package/templates/grid-customizer/gitignore +3 -0
  18. package/templates/grid-customizer/package.json +30 -16
  19. package/templates/grid-customizer/pcfconfig.json +3 -0
  20. package/templates/grid-customizer/tsconfig.dev.json +16 -0
  21. package/templates/grid-customizer/tsconfig.json +17 -10
  22. package/templates/grid-customizer/vite.config.ts +64 -0
  23. package/templates/grid-customizer/{{componentName}}/ControlManifest.Input.xml +63 -0
  24. package/templates/grid-customizer/{{componentName}}/customizers/CellEditorOverrides.tsx +38 -0
  25. package/templates/grid-customizer/{{componentName}}/customizers/CellRendererOverrides.tsx +109 -0
  26. package/templates/grid-customizer/{{componentName}}/customizers/cells.tsx +137 -0
  27. package/templates/grid-customizer/{{componentName}}/customizers/config.ts +84 -0
  28. package/templates/grid-customizer/{{componentName}}/index.ts +81 -0
  29. package/templates/grid-customizer/{{componentName}}/strings/{{componentName}}.1033.resx +56 -0
  30. package/templates/grid-customizer/{{componentName}}/types.ts +168 -0
  31. package/templates/pcf-dataset/dev/main.tsx +5 -1
  32. package/templates/pcf-dataset/{{componentName}}/ControlManifest.Input.xml +8 -1
  33. package/templates/pcf-field/dev/harness/dataverse.ts +71 -0
  34. package/templates/pcf-field/dev/index.html +20 -0
  35. package/templates/pcf-field/dev/main.tsx +212 -0
  36. package/templates/pcf-field/dev/vite-env.d.ts +1 -0
  37. package/templates/pcf-field/env.local.example +24 -0
  38. package/templates/pcf-field/gitignore +4 -0
  39. package/templates/pcf-field/package.json +10 -2
  40. package/templates/pcf-field/tsconfig.dev.json +15 -0
  41. package/templates/pcf-field/tsconfig.json +20 -4
  42. package/templates/pcf-field/vite.config.ts +60 -0
  43. package/templates/pcf-field/{{componentName}}/{{componentName}}Component.tsx +6 -1
  44. package/templates/grid-customizer/src/GridCustomizer.ts +0 -28
  45. package/templates/grid-customizer/src/cell-renderers.tsx +0 -35
  46. package/templates/grid-customizer/src/index.ts +0 -4
  47. package/templates/grid-customizer/src/types/grid-types.ts +0 -30
  48. package/templates/grid-customizer/src/utils/color-utils.ts +0 -24
  49. package/templates/grid-customizer/webpack.config.js +0 -17
@@ -0,0 +1,64 @@
1
+ import { defineConfig, loadEnv } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import * as path from 'path';
4
+ import { dataverseProxy } from '@dataverse-kit/pcf-dev-harness/node';
5
+
6
+ /**
7
+ * Live-org dev harness for {{componentName}} — `npm run dev` → http://localhost:8182
8
+ *
9
+ * WHY THIS EXISTS, given `npm start` already runs the Microsoft PCF harness:
10
+ * a grid customizer renders NOTHING of its own. `pcf-scripts start` will load this control
11
+ * happily and show an empty canvas, because the thing being customized — Microsoft's
12
+ * PowerAppsOneGrid — is not present in that harness and never calls the overrides. So the
13
+ * stock harness can tell you the control instantiates, and nothing else.
14
+ *
15
+ * `dev/harness/MockGrid.tsx` fills that gap: a minimal grid that calls the SAME
16
+ * `cellRendererOverrides` map, with the same `(props, params)` arguments and the same
17
+ * fall-through-on-undefined rule as OneGrid. Two data sources (see `dev/main.tsx`):
18
+ * - Mock — a hard-coded recordset covering each overridden data type. Needs no org.
19
+ * - Live — real rows AND real column data types from Dataverse metadata, through the
20
+ * same-origin `/api/data` proxy. Enabled when VITE_DYNAMICS_URL is set.
21
+ *
22
+ * The proxy and its in-process Azure-CLI token refresh come from
23
+ * `@dataverse-kit/pcf-dev-harness/node`: the bearer token is injected SERVER-SIDE and never
24
+ * reaches the browser. `az login` + `npm run dev` is enough — `npm run auth:token` only
25
+ * writes an optional static fallback.
26
+ */
27
+ export default defineConfig(({ mode }) => {
28
+ // Read .env* from the PROJECT root (__dirname), not `root: 'dev'`. The empty prefix loads
29
+ // every var server-side (URL + tenant + any static token); `envPrefix` below still limits
30
+ // what can reach the client bundle.
31
+ const env = loadEnv(mode, __dirname, '');
32
+ const live = dataverseProxy({
33
+ dynamicsUrl: env.VITE_DYNAMICS_URL || '',
34
+ tenant: env.DYNAMICS_TENANT,
35
+ // Array: `.env.local` is the documented file, `.env` is tolerated as a fallback so a
36
+ // token written by a different tool still works. First file defining DYNAMICS_TOKEN wins.
37
+ envFile: [path.resolve(__dirname, '.env.local'), path.resolve(__dirname, '.env')],
38
+ pluginName: '{{componentName}}-live-token',
39
+ });
40
+
41
+ return {
42
+ root: 'dev',
43
+ // .env* live at the project root, not the `dev` root, so client code still sees
44
+ // import.meta.env.VITE_DYNAMICS_URL (which gates Live mode).
45
+ envDir: __dirname,
46
+ // SECURITY: the bearer token is acquired server-side by dataverseProxy and is never put
47
+ // in a VITE_-prefixed var, so Vite cannot inline it into the client bundle. envPrefix
48
+ // narrows client exposure to VITE_DYNAMICS_URL alone; the static fallback DYNAMICS_TOKEN
49
+ // is deliberately unprefixed for the same reason.
50
+ envPrefix: 'VITE_DYNAMICS_URL',
51
+ // jsxRuntime: 'classic' — the automatic runtime emits `react/jsx-runtime`, which does not
52
+ // exist in React 16. This control is pinned to 16.14.0 to match the platform's Reactv16
53
+ // global, and tsconfig sets `"jsx": "react"` (classic) for the same reason.
54
+ plugins: [react({ jsxRuntime: 'classic' }), live.plugin],
55
+ server: {
56
+ // 8182, because `pcf-scripts start` owns 8181 — the two harnesses can run side by side.
57
+ // strictPort so a clash fails loudly instead of silently moving to another port.
58
+ port: 8182,
59
+ strictPort: true,
60
+ open: true,
61
+ proxy: live.proxy,
62
+ },
63
+ };
64
+ });
@@ -0,0 +1,63 @@
1
+ <?xml version="1.0" encoding="utf-8" ?>
2
+ <manifest>
3
+ <control namespace="{{namespace}}" constructor="{{componentName}}" version="1.0.0" display-name-key="{{namespace}}_{{componentName}}" description-key="{{namespace}}_{{componentName}}_Desc" control-type="virtual">
4
+ <!--
5
+ ★ A GRID CUSTOMIZER IS NOT A GRID. It renders nothing of its own.
6
+
7
+ It is bound to Microsoft's PowerAppsOneGrid via that grid's
8
+ `GridCustomizerControlFullName` parameter. On init the platform hands it an event
9
+ name through the BOUND `EventName` property below; the control answers by calling
10
+ `context.factory.fireEvent(eventName, customizer)` with an object of cell renderer /
11
+ editor overrides. OneGrid then calls those overrides while painting its own cells.
12
+
13
+ Consequences that shape this whole template:
14
+ • `EventName` is the ONLY property. It must be `usage="bound"` and `required="true"`.
15
+ • `updateView` returns an empty Fragment — see index.ts.
16
+ • ★ A customizer CANNOT receive maker-set `usage="input"` properties. The grid's
17
+ "Customizer control" field references this control by NAME only, with nowhere to
18
+ pass a value, so any input property is `undefined` at runtime — and reading `.raw`
19
+ off it throws inside init, which the host SWALLOWS (you get a silently missing
20
+ customizer, not an error). Configure via customizers/config.ts, or read a Dataverse
21
+ environment variable at runtime (see README).
22
+ -->
23
+ <property name="EventName" display-name-key="EventName" description-key="EventName_Desc" of-type="SingleLine.Text" usage="bound" required="true" />
24
+ <resources>
25
+ <code path="index.ts" order="1"/>
26
+ <!--
27
+ ★ These versions must EQUAL a version the PLATFORM ACTUALLY SERVES — not merely one
28
+ pcf-scripts accepts. pcf-scripts matches a {minVersion, maxVersion} row in its
29
+ PlatformLibraryVersions.json and remaps the bundle to that row's global alias, but it
30
+ never checks the runtime. Trusting the tool produces a green build and a dead control.
31
+
32
+ Measured on live model-driven pages (orge2e285aa, 2026-08-02):
33
+
34
+ Reactv16 = React 17.0.2 ← the alias says 16; the served React is 17.0.2
35
+ Reactv17 = undefined
36
+ Reactv18 = undefined ← pcf-scripts HAS an 18.3.1 row; no org serves it
37
+ ReactDOMv16 = present
38
+ ReactDOMv18 = undefined
39
+ FluentUIReactv81211 = present ← Fluent 8.121.1, loaded on every page
40
+ FluentUIReactv940 = present ← Fluent 9.68.0 IS served (see below)
41
+ FluentUIReactv8290 = loaded ON DEMAND — appears only on a page hosting a control
42
+ that declares Fluent 8.29.0, which is how the injection works
43
+
44
+ 16.14.0, not 17.0.2, is deliberate: the model-driven host serves 17.0.2 but the canvas
45
+ host serves 16.14.0, so 16.14.0 is the floor both satisfy.
46
+
47
+ ★ WHY v8 AND NOT v9, given the platform serves both. A customizer's elements are
48
+ rendered by OneGrid INSIDE OneGrid's own React tree, and OneGrid is Fluent v8. A v8
49
+ cell inherits the host's theme for free. A v9 cell would need its own <FluentProvider>
50
+ wrapper per cell to get Griffel styles + tokens, and would then be themed
51
+ independently of the grid it sits in. v8 is the contract here.
52
+
53
+ A virtual control CANNOT bundle its own React: it returns a ReactElement that the
54
+ PLATFORM renders with the PLATFORM's React, so a second React instance breaks it.
55
+ That is why react/react-dom and @fluentui/react are pinned EXACTLY in package.json to
56
+ the versions declared here — compiled-against must equal served.
57
+ -->
58
+ <platform-library name="React" version="16.14.0" />
59
+ <platform-library name="Fluent" version="8.121.1" />
60
+ <resx path="strings/{{componentName}}.1033.resx" version="1.0.0" />
61
+ </resources>
62
+ </control>
63
+ </manifest>
@@ -0,0 +1,38 @@
1
+ import type { CellEditorOverrides } from '../types';
2
+
3
+ /**
4
+ * Cell EDITOR overrides — what a cell becomes when the user starts editing it.
5
+ *
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
+ * The keys and the fall-through rule are identical to `CellRendererOverrides`: key by
11
+ * `ColumnDataType`, narrow to a column with `editorParams.colDefs[editorParams.columnIndex]`,
12
+ * and return `undefined` to keep the platform editor.
13
+ *
14
+ * A worked example — clamp a percentage column to 0–100:
15
+ *
16
+ * ```tsx
17
+ * import * as React from 'react';
18
+ * import { SpinButton } from '@fluentui/react';
19
+ *
20
+ * export const cellEditorOverrides: CellEditorOverrides = {
21
+ * Decimal: (defaultProps, editorParams) => {
22
+ * const name = editorParams.colDefs[editorParams.columnIndex]?.name;
23
+ * if (name !== 'dvk_percentcomplete') return undefined;
24
+ * return (
25
+ * <SpinButton
26
+ * min={0}
27
+ * max={100}
28
+ * step={5}
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
+ * ```
37
+ */
38
+ export const cellEditorOverrides: CellEditorOverrides = {};
@@ -0,0 +1,109 @@
1
+ import * as React from 'react';
2
+ import type { CellRendererOverrides, CellRendererProps, GetRendererParams } from '../types';
3
+ import { BooleanCell, CurrencyCell, ProgressCell, StatusBadgeCell } from './cells';
4
+ import { PROGRESS_COLUMNS, toNumber } from './config';
5
+
6
+ /**
7
+ * The renderer map handed to OneGrid.
8
+ *
9
+ * ★ Three contract rules, each of which silently produces "my customizer does nothing" when
10
+ * broken:
11
+ *
12
+ * 1. **Keys are COLUMN DATA TYPES, never column logical names.** A `{ statuscode: … }`
13
+ * entry type-errors against `CellRendererOverrides` — and would simply never fire if it
14
+ * were cast away. Scope to a single column INSIDE the handler, as `Decimal` does below.
15
+ * 2. **Return `undefined` to fall through** to the grid's own renderer. That is the opt-out
16
+ * for a row or column this customizer has no opinion about — returning an empty element
17
+ * instead blanks the cell.
18
+ * 3. **Render `props.formattedValue`, not `props.value`.** `value` is raw: an option-set
19
+ * integer, an unrounded decimal, a UTC timestamp. `formattedValue` is what the platform
20
+ * itself would have painted — already localized, already currency-symbolled, already
21
+ * resolved to an option label.
22
+ */
23
+
24
+ /** Per-instance knobs. Everything else is compile-time config in `./config`. */
25
+ export interface RendererOptions {
26
+ /**
27
+ * Columns rendered as a progress bar. Defaults to `config.PROGRESS_COLUMNS`.
28
+ *
29
+ * Overridable so the dev harness can retarget the SAME override map at whatever columns
30
+ * you loaded, instead of shipping a second near-copy of the renderers that could drift
31
+ * from the one the org actually runs.
32
+ */
33
+ progressColumns?: readonly string[];
34
+ }
35
+
36
+ /** The column logical name for the cell currently being rendered. */
37
+ function columnName(params: GetRendererParams): string | undefined {
38
+ return params.colDefs[params.columnIndex]?.name;
39
+ }
40
+
41
+ /** Text the grid would have shown, falling back to the raw value when there is no format. */
42
+ function displayText(props: CellRendererProps): string {
43
+ if (props.formattedValue !== undefined && props.formattedValue !== null) {
44
+ return props.formattedValue;
45
+ }
46
+ return props.value === undefined || props.value === null ? '' : String(props.value);
47
+ }
48
+
49
+ /**
50
+ * True when the cell holds no value.
51
+ *
52
+ * ★ Every handler must check this FIRST and fall through. Dataverse columns are nullable —
53
+ * a Boolean added to a table with existing rows is `null` on all of them — and a renderer
54
+ * that coerces null (`value === true` → false) paints a definite answer for missing data.
55
+ * "No" and "not set" are different facts; the grid's own renderer draws the second correctly.
56
+ */
57
+ function isEmpty(props: CellRendererProps): boolean {
58
+ return props.value === undefined || props.value === null;
59
+ }
60
+
61
+ /** Build the override map. `cellRendererOverrides` below is the configured singleton. */
62
+ export function createCellRendererOverrides(
63
+ options: RendererOptions = {},
64
+ ): CellRendererOverrides {
65
+ const progressColumns = options.progressColumns ?? PROGRESS_COLUMNS;
66
+
67
+ return {
68
+ // Option sets and status/state columns → a coloured pill keyed off the option LABEL.
69
+ // An unset option set falls through: a badge with no text is just a coloured smudge.
70
+ OptionSet: (props) => {
71
+ if (isEmpty(props)) return undefined;
72
+ const label = displayText(props);
73
+ return label ? <StatusBadgeCell label={label} /> : undefined;
74
+ },
75
+
76
+ // Yes/no → tick or cross plus the platform's own label ("Yes"/"No", or the custom pair).
77
+ // ★ Falls through on null — otherwise a nullable Boolean renders the same grey cross as a
78
+ // genuine "No", which silently misrepresents the data.
79
+ TwoOptions: (props) => {
80
+ if (isEmpty(props)) return undefined;
81
+ return (
82
+ <BooleanCell on={props.value === true || props.value === 1} label={displayText(props)} />
83
+ );
84
+ },
85
+
86
+ // Money → right-aligned, red when negative. Falls through when the cell is empty so a
87
+ // blank amount stays blank rather than rendering a coloured "0".
88
+ Currency: (props) => {
89
+ const amount = toNumber(props.value);
90
+ if (amount === undefined) return undefined;
91
+ return <CurrencyCell amount={amount} formatted={displayText(props)} />;
92
+ },
93
+
94
+ // ★ The per-column pattern. Decimal is registered for the whole grid, but only the
95
+ // columns in `progressColumns` get a progress bar — everything else falls through to the
96
+ // default renderer. Without this narrowing, every decimal column in every grid this
97
+ // customizer is bound to would become a progress bar.
98
+ Decimal: (props, params) => {
99
+ const name = columnName(params);
100
+ if (!name || !progressColumns.includes(name)) return undefined;
101
+ const value = toNumber(props.value);
102
+ if (value === undefined) return undefined;
103
+ return <ProgressCell value={value} />;
104
+ },
105
+ };
106
+ }
107
+
108
+ /** What the control registers with the grid — see `index.ts`. */
109
+ export const cellRendererOverrides: CellRendererOverrides = createCellRendererOverrides();
@@ -0,0 +1,137 @@
1
+ import * as React from 'react';
2
+ // ★ Root import, not a deep one (`@fluentui/react/lib/Label`). pcf-scripts externalises the
3
+ // module specifier `@fluentui/react` to the platform global; a deep path is a DIFFERENT
4
+ // specifier and gets bundled instead — which reintroduces exactly the compiled-version ≠
5
+ // served-version skew the exact pin in package.json exists to prevent.
6
+ import { ProgressIndicator } from '@fluentui/react';
7
+ import {
8
+ CURRENCY_NEGATIVE,
9
+ CURRENCY_POSITIVE,
10
+ PROGRESS_MAX,
11
+ getStatusColor,
12
+ } from './config';
13
+
14
+ /**
15
+ * The visual half of the customizer: presentational cells with no knowledge of the grid
16
+ * contract. `CellRendererOverrides.tsx` decides WHICH cell renders WHERE; this file decides
17
+ * what each one looks like. Split that way so a renderer can be unit-tested and reused from
18
+ * the dev harness without a PCF context.
19
+ *
20
+ * All of these are Fluent v8 / inline styles, matching the host grid — see the manifest
21
+ * comment on why a customizer is v8 even though the platform also serves Fluent v9.
22
+ */
23
+
24
+ /** Cells sit inside a grid row of fixed height; fill it rather than adding vertical padding. */
25
+ const fill: React.CSSProperties = {
26
+ display: 'flex',
27
+ alignItems: 'center',
28
+ height: '100%',
29
+ width: '100%',
30
+ overflow: 'hidden',
31
+ };
32
+
33
+ /**
34
+ * An option-set / status value as a coloured pill.
35
+ *
36
+ * Assumes a non-empty label — the caller falls through to the grid's own renderer for an
37
+ * unset value rather than asking this component to draw "nothing", which would blank the
38
+ * cell instead of leaving it to the platform.
39
+ */
40
+ export const StatusBadgeCell: React.FC<{ label: string }> = ({ label }) => {
41
+ return (
42
+ <div style={fill}>
43
+ <span
44
+ title={label}
45
+ style={{
46
+ backgroundColor: getStatusColor(label),
47
+ color: '#ffffff',
48
+ borderRadius: 10,
49
+ padding: '2px 10px',
50
+ fontSize: 12,
51
+ fontWeight: 600,
52
+ lineHeight: '16px',
53
+ maxWidth: '100%',
54
+ overflow: 'hidden',
55
+ textOverflow: 'ellipsis',
56
+ whiteSpace: 'nowrap',
57
+ }}
58
+ >
59
+ {label}
60
+ </span>
61
+ </div>
62
+ );
63
+ };
64
+
65
+ /**
66
+ * A currency amount, right-aligned and coloured by sign.
67
+ *
68
+ * Renders the platform's `formattedValue` — it already carries the record's currency symbol
69
+ * and the user's locale separators, which a client-side `Intl.NumberFormat` would guess at
70
+ * (and get wrong for any transaction currency other than the user's own).
71
+ */
72
+ export const CurrencyCell: React.FC<{ amount: number; formatted: string }> = ({
73
+ amount,
74
+ formatted,
75
+ }) => (
76
+ <div style={{ ...fill, justifyContent: 'flex-end' }}>
77
+ <span
78
+ style={{
79
+ color: amount < 0 ? CURRENCY_NEGATIVE : CURRENCY_POSITIVE,
80
+ fontWeight: 600,
81
+ fontVariantNumeric: 'tabular-nums',
82
+ }}
83
+ >
84
+ {formatted}
85
+ </span>
86
+ </div>
87
+ );
88
+
89
+ /** A 0–PROGRESS_MAX number as a labelled bar. */
90
+ export const ProgressCell: React.FC<{ value: number }> = ({ value }) => {
91
+ const clamped = Math.max(0, Math.min(PROGRESS_MAX, value));
92
+ return (
93
+ <div style={{ ...fill, gap: 8 }}>
94
+ <div style={{ flex: '1 1 auto', minWidth: 40 }}>
95
+ <ProgressIndicator
96
+ percentComplete={clamped / PROGRESS_MAX}
97
+ barHeight={6}
98
+ // The grid row supplies the label; a second one here would double the row height.
99
+ styles={{ root: { margin: 0 }, itemProgress: { padding: 0 } }}
100
+ />
101
+ </div>
102
+ <span style={{ flex: '0 0 auto', fontSize: 12, fontVariantNumeric: 'tabular-nums' }}>
103
+ {Math.round(clamped)}%
104
+ </span>
105
+ </div>
106
+ );
107
+ };
108
+
109
+ /**
110
+ * A yes/no value as a tick or cross, so the column scans at a glance.
111
+ *
112
+ * ★ Inline SVG, NOT Fluent's `<Icon iconName="CheckMark">`. Fluent v8 icons are FONT icons
113
+ * that must be registered by `initializeIcons()` before use; an unregistered name renders as
114
+ * nothing and only warns ("The icon 'checkmark' was used but not registered"). A customizer
115
+ * does not own the page, so whether registration has happened against the platform's shared
116
+ * Fluent instance by the time a cell paints is not ours to guarantee — and the failure is a
117
+ * silently blank cell, not an error. An SVG has no such dependency and looks the same in the
118
+ * dev harness and in the org.
119
+ */
120
+ export const BooleanCell: React.FC<{ on: boolean; label: string }> = ({ on, label }) => (
121
+ <div style={{ ...fill, gap: 6 }}>
122
+ <svg
123
+ width="14"
124
+ height="14"
125
+ viewBox="0 0 16 16"
126
+ aria-hidden="true"
127
+ focusable="false"
128
+ style={{ flex: '0 0 auto' }}
129
+ >
130
+ <path
131
+ d={on ? 'M6.2 12.4 2 8.2l1.3-1.3 2.9 2.9 6.5-6.5L14 4.6z' : 'M12.7 4.3 11.3 2.9 8 6.2 4.7 2.9 3.3 4.3 6.6 7.6 3.3 10.9l1.4 1.4L8 9l3.3 3.3 1.4-1.4-3.3-3.3z'}
132
+ fill={on ? '#107c10' : '#a19f9d'}
133
+ />
134
+ </svg>
135
+ <span>{label}</span>
136
+ </div>
137
+ );
@@ -0,0 +1,84 @@
1
+ /**
2
+ * ★ THE ONE FILE YOU EDIT to retarget this customizer at your own columns.
3
+ *
4
+ * It is plain data, deliberately: a grid customizer cannot be configured by a maker at
5
+ * bind time. The grid's "Customizer control" field references this control by NAME only,
6
+ * so any `usage="input"` manifest property arrives `undefined` at runtime. Changing
7
+ * behaviour means editing this file and redeploying with a bumped manifest version.
8
+ *
9
+ * If you need runtime-changeable rules instead, read them from a Dataverse environment
10
+ * variable in `index.ts` — see the README. (If you have the `claude-workspace` checkout,
11
+ * `shared/pcf-gallery/controls/color-grid` is a full worked implementation, including the
12
+ * `<uses-feature name="WebAPI">` declaration that read requires.)
13
+ */
14
+
15
+ /**
16
+ * Columns rendered as a progress bar instead of a plain number. Empty by default: without
17
+ * an explicit list, EVERY numeric column in every grid this customizer touches would turn
18
+ * into a progress bar, which is almost never what you want.
19
+ *
20
+ * Use column LOGICAL names, e.g. `['dvk_percentcomplete']`.
21
+ */
22
+ export const PROGRESS_COLUMNS: readonly string[] = [];
23
+
24
+ /** Values a progress column is scaled against. A value of `PROGRESS_MAX` fills the bar. */
25
+ export const PROGRESS_MAX = 100;
26
+
27
+ /**
28
+ * Colour for a status / option-set label, matched case-insensitively against the
29
+ * platform-formatted TEXT of the cell (not the numeric option value) — so the same map
30
+ * works across tables whose option values differ but whose labels agree.
31
+ *
32
+ * Anything not listed falls back to a stable colour derived from the label itself, so a
33
+ * new option is still visually distinct without a code change.
34
+ */
35
+ export const STATUS_COLORS: Readonly<Record<string, string>> = {
36
+ active: '#107c10',
37
+ inactive: '#605e5c',
38
+ draft: '#0078d4',
39
+ new: '#0078d4',
40
+ pending: '#c19c00',
41
+ inprogress: '#0078d4',
42
+ approved: '#107c10',
43
+ completed: '#107c10',
44
+ done: '#107c10',
45
+ rejected: '#a4262c',
46
+ failed: '#a4262c',
47
+ cancelled: '#605e5c',
48
+ canceled: '#605e5c',
49
+ blocked: '#a4262c',
50
+ };
51
+
52
+ /** Colour for a positive currency amount. */
53
+ export const CURRENCY_POSITIVE = '#107c10';
54
+ /** Colour for a negative currency amount — the reason Currency is overridden at all. */
55
+ export const CURRENCY_NEGATIVE = '#a4262c';
56
+
57
+ /**
58
+ * Deterministic fallback colour for an unmapped status label. Same input → same colour for
59
+ * the life of the label, so the grid does not reshuffle between renders.
60
+ */
61
+ export function stringToColor(str: string): string {
62
+ let hash = 0;
63
+ for (let i = 0; i < str.length; i++) {
64
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
65
+ hash |= 0; // keep it a 32-bit int so long labels don't drift into float imprecision
66
+ }
67
+ return `hsl(${Math.abs(hash) % 360}, 55%, 35%)`;
68
+ }
69
+
70
+ /** Resolve the badge colour for a formatted status label. */
71
+ export function getStatusColor(label: string): string {
72
+ const normalized = label.toLowerCase().replace(/[\s_-]+/g, '');
73
+ return STATUS_COLORS[normalized] ?? stringToColor(normalized || 'unknown');
74
+ }
75
+
76
+ /** Coerce a raw grid cell value to a number, or `undefined` when it isn't one. */
77
+ export function toNumber(value: unknown): number | undefined {
78
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
79
+ if (typeof value === 'string' && value.trim() !== '') {
80
+ const n = Number(value);
81
+ return Number.isFinite(n) ? n : undefined;
82
+ }
83
+ return undefined;
84
+ }
@@ -0,0 +1,81 @@
1
+ import type { IInputs, IOutputs } from './generated/ManifestTypes';
2
+ import * as React from 'react';
3
+ import { cellRendererOverrides } from './customizers/CellRendererOverrides';
4
+ import { cellEditorOverrides } from './customizers/CellEditorOverrides';
5
+ import type { PAOneGridCustomizer } from './types';
6
+
7
+ /**
8
+ * `context.factory` carries the grid's event channel but is absent from the typed
9
+ * `ComponentFramework.Context`, so it is declared structurally here rather than cast to
10
+ * `any` — the cast would also silence a genuine typo in the call.
11
+ */
12
+ interface CustomizerFactory {
13
+ fireEvent(eventName: string, payload: PAOneGridCustomizer): void;
14
+ }
15
+
16
+ export class {{componentName}} implements ComponentFramework.ReactControl<IInputs, IOutputs> {
17
+ // No constructor: the platform instantiates the control with no arguments and there is
18
+ // nothing to set up before init(). An explicit empty one would need an
19
+ // `@typescript-eslint/no-empty-function` disable, and that rule is not defined by the
20
+ // parser-only .eslintrc this template ships — `pcf-scripts build` fails on an unknown
21
+ // rule in a disable comment, not just on a rule violation.
22
+
23
+ /**
24
+ * Register the customizer with the host grid.
25
+ *
26
+ * This is the whole control. The grid passes an event name through the bound `EventName`
27
+ * property and then waits; firing that event with the override maps is what makes the
28
+ * grid paint custom cells. A customizer is initialised ONCE per grid, and the grid latches
29
+ * the FIRST payload it receives — later fires are ignored — so everything the customizer
30
+ * needs must be ready by the time this returns.
31
+ */
32
+ public init(
33
+ context: ComponentFramework.Context<IInputs>,
34
+ notifyOutputChanged: () => void,
35
+ state: ComponentFramework.Dictionary,
36
+ ): void {
37
+ // ★ The host SWALLOWS a throw from init: it tears the customizer down through a
38
+ // telemetry path that itself errors, which masks the original stack. The symptom is a
39
+ // grid that renders perfectly with default cells and no console error at all. Catch and
40
+ // log here or you will debug this blind.
41
+ try {
42
+ // ★ `?.` is load-bearing. A grid customizer receives ONLY the bound EventName; any
43
+ // other manifest property is undefined at runtime, and so is this one on a host that
44
+ // instantiates the control outside a grid. Reading `.raw` off undefined throws — see
45
+ // above for why that throw is invisible.
46
+ const eventName = context.parameters.EventName?.raw;
47
+ if (!eventName) return;
48
+
49
+ const customizer: PAOneGridCustomizer = {
50
+ cellRendererOverrides,
51
+ cellEditorOverrides,
52
+ };
53
+
54
+ (context as unknown as { factory: CustomizerFactory }).factory.fireEvent(
55
+ eventName,
56
+ customizer,
57
+ );
58
+ } catch (e) {
59
+ // eslint-disable-next-line no-console
60
+ console.error('[{{componentName}}] init failed', e);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * A customizer has no surface of its own — the grid it decorates owns the DOM. Returning
66
+ * an empty Fragment satisfies the virtual-control contract without adding a wrapper
67
+ * element to the grid's layout.
68
+ */
69
+ public updateView(context: ComponentFramework.Context<IInputs>): React.ReactElement {
70
+ return React.createElement(React.Fragment);
71
+ }
72
+
73
+ public getOutputs(): IOutputs {
74
+ return {};
75
+ }
76
+
77
+ public destroy(): void {
78
+ // Nothing to release: no timers, no listeners, no subscriptions. If you add a runtime
79
+ // config read (see README), cancel it here.
80
+ }
81
+ }
@@ -0,0 +1,56 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <root>
3
+ <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
4
+ <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
5
+ <xsd:element name="root" msdata:IsDataSet="true">
6
+ <xsd:complexType>
7
+ <xsd:choice maxOccurs="unbounded">
8
+ <xsd:element name="data">
9
+ <xsd:complexType>
10
+ <xsd:sequence>
11
+ <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
12
+ <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
13
+ </xsd:sequence>
14
+ <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
15
+ <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
16
+ <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
17
+ <xsd:attribute ref="xml:space" />
18
+ </xsd:complexType>
19
+ </xsd:element>
20
+ <xsd:element name="resheader">
21
+ <xsd:complexType>
22
+ <xsd:sequence>
23
+ <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
24
+ </xsd:sequence>
25
+ <xsd:attribute name="name" type="xsd:string" use="required" />
26
+ </xsd:complexType>
27
+ </xsd:element>
28
+ </xsd:choice>
29
+ </xsd:complexType>
30
+ </xsd:element>
31
+ </xsd:schema>
32
+ <resheader name="resmimetype">
33
+ <value>text/microsoft-resx</value>
34
+ </resheader>
35
+ <resheader name="version">
36
+ <value>2.0</value>
37
+ </resheader>
38
+ <resheader name="reader">
39
+ <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
40
+ </resheader>
41
+ <resheader name="writer">
42
+ <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
43
+ </resheader>
44
+ <data name="{{namespace}}_{{componentName}}" xml:space="preserve">
45
+ <value>{{componentName}}</value>
46
+ </data>
47
+ <data name="{{namespace}}_{{componentName}}_Desc" xml:space="preserve">
48
+ <value>Editable-grid cell renderer overrides for Power Apps OneGrid.</value>
49
+ </data>
50
+ <data name="EventName" xml:space="preserve">
51
+ <value>Event Name</value>
52
+ </data>
53
+ <data name="EventName_Desc" xml:space="preserve">
54
+ <value>Bound by the host grid. The customizer is published back to the grid through this event.</value>
55
+ </data>
56
+ </root>