@khester/create-dynamics-app 2.0.0 → 2.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 (122) hide show
  1. package/README.md +28 -0
  2. package/dist/artifacts/registry.d.ts +4 -3
  3. package/dist/artifacts/registry.d.ts.map +1 -1
  4. package/dist/artifacts/registry.js +145 -11
  5. package/dist/artifacts/registry.js.map +1 -1
  6. package/dist/artifacts/types.d.ts +10 -1
  7. package/dist/artifacts/types.d.ts.map +1 -1
  8. package/dist/index.js +19 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/injectDevTools.d.ts.map +1 -1
  11. package/dist/injectDevTools.js +4 -2
  12. package/dist/injectDevTools.js.map +1 -1
  13. package/dist/scaffold.d.ts +23 -1
  14. package/dist/scaffold.d.ts.map +1 -1
  15. package/dist/scaffold.js +27 -1
  16. package/dist/scaffold.js.map +1 -1
  17. package/package.json +3 -2
  18. package/templates/grid-starter/ARCHITECTURE.md +66 -0
  19. package/templates/grid-starter/README.md +122 -0
  20. package/templates/grid-starter/env.example +16 -0
  21. package/templates/grid-starter/gitignore +6 -0
  22. package/templates/grid-starter/index.html +16 -0
  23. package/templates/grid-starter/package.json +39 -0
  24. package/templates/grid-starter/src/App.tsx +23 -0
  25. package/templates/grid-starter/src/core/services/FetchApiService.ts +117 -0
  26. package/templates/grid-starter/src/core/services/IApiService.ts +37 -0
  27. package/templates/grid-starter/src/core/services/MockApiService.ts +72 -0
  28. package/templates/grid-starter/src/core/services/ServiceFactory.ts +58 -0
  29. package/templates/grid-starter/src/core/services/XrmApiService.ts +135 -0
  30. package/templates/grid-starter/src/core/services/crudLogging.ts +52 -0
  31. package/templates/grid-starter/src/dev-tools/DevPanel.tsx +239 -0
  32. package/templates/grid-starter/src/grid/GridPage.tsx +119 -0
  33. package/templates/grid-starter/src/index.tsx +18 -0
  34. package/templates/grid-starter/src/vite-env.d.ts +15 -0
  35. package/templates/grid-starter/tools/deploy/deploy-webresource.cjs +117 -0
  36. package/templates/grid-starter/tsconfig.json +19 -0
  37. package/templates/grid-starter/vite.config.ts +76 -0
  38. package/templates/pcf-field/_variants/ValueInput.boolean.tsx +2 -0
  39. package/templates/pcf-field/_variants/ValueInput.date.tsx +2 -0
  40. package/templates/pcf-field/_variants/ValueInput.number.tsx +2 -0
  41. package/templates/pcf-field/_variants/ValueInput.optionset.tsx +77 -0
  42. package/templates/pcf-field/_variants/ValueInput.text.tsx +2 -0
  43. package/templates/pcf-field/index.ts +1 -1
  44. package/templates/pcf-field/package.json +3 -1
  45. package/templates/pcf-field/{{componentName}}Component.tsx +2 -0
  46. package/templates/react-custom-page/ARCHITECTURE.md +75 -0
  47. package/templates/react-custom-page/README.md +74 -568
  48. package/templates/react-custom-page/env.example +16 -0
  49. package/templates/react-custom-page/gitignore +1 -0
  50. package/templates/react-custom-page/index.html +16 -0
  51. package/templates/react-custom-page/package.json +21 -49
  52. package/templates/react-custom-page/src/App.tsx +26 -0
  53. package/templates/react-custom-page/src/core/recordContext.test.ts +30 -0
  54. package/templates/react-custom-page/src/core/recordContext.ts +51 -0
  55. package/templates/react-custom-page/src/core/services/FetchApiService.ts +117 -0
  56. package/templates/react-custom-page/src/core/services/IApiService.ts +37 -0
  57. package/templates/react-custom-page/src/core/services/MockApiService.ts +73 -0
  58. package/templates/react-custom-page/src/core/services/ServiceFactory.ts +58 -0
  59. package/templates/react-custom-page/src/core/services/XrmApiService.ts +135 -0
  60. package/templates/react-custom-page/src/core/services/crudLogging.ts +52 -0
  61. package/templates/react-custom-page/src/dev-tools/DevPanel.tsx +238 -0
  62. package/templates/react-custom-page/src/domain/diff.test.ts +87 -0
  63. package/templates/react-custom-page/src/domain/diff.ts +38 -0
  64. package/templates/react-custom-page/src/example/ExamplePage.tsx +140 -0
  65. package/templates/react-custom-page/src/example/exampleError.ts +36 -0
  66. package/templates/react-custom-page/src/example/hooks/useExampleData.ts +40 -0
  67. package/templates/react-custom-page/src/example/hooks/useExampleForm.ts +99 -0
  68. package/templates/react-custom-page/src/example/mappers/accountMapper.test.ts +38 -0
  69. package/templates/react-custom-page/src/example/mappers/accountMapper.ts +55 -0
  70. package/templates/react-custom-page/src/example/models/Account.ts +74 -0
  71. package/templates/react-custom-page/src/index.tsx +18 -128
  72. package/templates/react-custom-page/src/vite-env.d.ts +15 -0
  73. package/templates/react-custom-page/tools/deploy/deploy-webresource.cjs +117 -0
  74. package/templates/react-custom-page/tsconfig.json +12 -22
  75. package/templates/react-custom-page/vite.config.ts +76 -0
  76. package/templates/starter-page/README.md +38 -0
  77. package/templates/starter-page/_variants/App.dashboard.v8.tsx +46 -0
  78. package/templates/starter-page/_variants/App.form.v8.tsx +59 -0
  79. package/templates/starter-page/_variants/App.master-detail.v8.tsx +61 -0
  80. package/templates/starter-page/_variants/App.panel.v8.tsx +99 -0
  81. package/templates/starter-page/gitignore +5 -0
  82. package/templates/starter-page/package.json +27 -0
  83. package/templates/starter-page/public/index.html +11 -0
  84. package/templates/starter-page/src/index.tsx +10 -0
  85. package/templates/starter-page/src/services/dataverse.ts +30 -0
  86. package/templates/starter-page/tsconfig.json +15 -0
  87. package/templates/starter-page/webpack.config.js +17 -0
  88. package/templates/react-custom-page/deployment/README.md +0 -484
  89. package/templates/react-custom-page/docs/ARCHITECTURE_OVERVIEW.md +0 -506
  90. package/templates/react-custom-page/docs/BEST_PRACTICES.md +0 -723
  91. package/templates/react-custom-page/docs/MIGRATION_GUIDE.md +0 -447
  92. package/templates/react-custom-page/public/index.html +0 -15
  93. package/templates/react-custom-page/scripts/custom-build.js +0 -255
  94. package/templates/react-custom-page/src/components/AccountForm.css +0 -71
  95. package/templates/react-custom-page/src/components/AccountForm.tsx +0 -541
  96. package/templates/react-custom-page/src/components/AccountManagement.css +0 -86
  97. package/templates/react-custom-page/src/components/AccountManagement.tsx +0 -370
  98. package/templates/react-custom-page/src/components/ContactForm.css +0 -48
  99. package/templates/react-custom-page/src/components/ContactForm.tsx +0 -327
  100. package/templates/react-custom-page/src/components/ContactManagement.css +0 -86
  101. package/templates/react-custom-page/src/components/ContactManagement.tsx +0 -357
  102. package/templates/react-custom-page/src/components/Logging/LogDialog.tsx +0 -291
  103. package/templates/react-custom-page/src/components/Logging/LoggingContext.tsx +0 -166
  104. package/templates/react-custom-page/src/components/Logging/LoggingDebugPanel.css +0 -192
  105. package/templates/react-custom-page/src/components/Logging/LoggingDebugPanel.tsx +0 -177
  106. package/templates/react-custom-page/src/components/Logging/LoggingProvider.tsx +0 -3
  107. package/templates/react-custom-page/src/components/Logging/logger.ts +0 -193
  108. package/templates/react-custom-page/src/constants/account.ts +0 -410
  109. package/templates/react-custom-page/src/constants/contact.ts +0 -362
  110. package/templates/react-custom-page/src/models/Account.ts +0 -480
  111. package/templates/react-custom-page/src/models/BaseEntity.ts +0 -204
  112. package/templates/react-custom-page/src/models/Contact.ts +0 -580
  113. package/templates/react-custom-page/src/pcf/ContactControlWrapper.tsx +0 -107
  114. package/templates/react-custom-page/src/pcf/MultiEntityControlWrapper.tsx +0 -205
  115. package/templates/react-custom-page/src/providers/DynamicsProvider.tsx +0 -353
  116. package/templates/react-custom-page/src/services/MockApiService.ts +0 -260
  117. package/templates/react-custom-page/src/services/ServiceFactory.ts +0 -65
  118. package/templates/react-custom-page/src/services/XrmApiService.ts +0 -213
  119. package/templates/react-custom-page/src/styles/index.css +0 -171
  120. package/templates/react-custom-page/tools/metadata-sync/index.js +0 -152
  121. package/templates/react-custom-page/webpack.config.js +0 -57
  122. /package/templates/_shared/dev-tools/auth/{get-token.js → get-token.cjs} +0 -0
@@ -0,0 +1,52 @@
1
+ // Binds the generic `withCrudLog` wrapper (from @khester/reusable-components) to
2
+ // the Dataverse error shapes thrown by the IApiService implementations, so each
3
+ // service method can be instrumented with a single `logCrud(meta, fn)` call.
4
+ //
5
+ // `parseServiceError` lives here (app/template code), not in the library — the
6
+ // library's logger stays backend-agnostic and the app injects this parser.
7
+
8
+ import { withCrudLog, type CrudMeta } from "@khester/reusable-components";
9
+
10
+ /**
11
+ * Extract a numeric status + human message from the error shapes the services
12
+ * throw: an `Error("… 400: <body>")` whose body may embed a Dataverse error
13
+ * JSON blob, or a plain `json.error` object with a CDS InnerError message.
14
+ */
15
+ function parseServiceError(err: unknown): { status?: number; message?: string } {
16
+ if (err && typeof err === "object" && !(err instanceof Error)) {
17
+ const obj = err as Record<string, unknown>;
18
+ const inner = obj["@Microsoft.PowerApps.CDS.InnerError.Message"];
19
+ const message =
20
+ (typeof inner === "string" && inner) ||
21
+ (typeof obj.message === "string" && obj.message) ||
22
+ undefined;
23
+ const status = typeof obj.status === "number" ? obj.status : undefined;
24
+ return { status, message: message || undefined };
25
+ }
26
+
27
+ if (err instanceof Error) {
28
+ const statusMatch = err.message.match(/\b(\d{3})\b/);
29
+ const status = statusMatch ? Number(statusMatch[1]) : undefined;
30
+
31
+ const jsonStart = err.message.indexOf("{");
32
+ if (jsonStart !== -1) {
33
+ try {
34
+ const parsed = JSON.parse(err.message.substring(jsonStart));
35
+ const inner =
36
+ parsed?.error?.["@Microsoft.PowerApps.CDS.InnerError.Message"];
37
+ const message = inner || parsed?.error?.message || err.message;
38
+ return { status, message };
39
+ } catch {
40
+ // not JSON — fall through to the raw message
41
+ }
42
+ }
43
+ return { status, message: err.message };
44
+ }
45
+
46
+ return { message: String(err) };
47
+ }
48
+
49
+ /** Run a CRUD operation, emitting one structured `[CRUD] …` log line for it. */
50
+ export function logCrud<T>(meta: CrudMeta, fn: () => Promise<T>): Promise<T> {
51
+ return withCrudLog(meta, fn, { parseError: parseServiceError });
52
+ }
@@ -0,0 +1,238 @@
1
+ import React, { useCallback, useMemo, useState } from "react";
2
+ import {
3
+ Panel,
4
+ PanelType,
5
+ TextField,
6
+ PrimaryButton,
7
+ DefaultButton,
8
+ Stack,
9
+ Text,
10
+ Separator,
11
+ MessageBar,
12
+ MessageBarType,
13
+ Link,
14
+ Icon,
15
+ } from "@fluentui/react";
16
+ import { logger } from "@khester/reusable-components";
17
+
18
+ // Floating dev-tools panel for local development — a 🔧 FAB (bottom-right) that
19
+ // opens a Fluent Panel. Localhost-gated: it renders nothing once deployed, so a
20
+ // live custom page never shows it. Modeled on the original import_weights dev tool,
21
+ // adapted to Vite + this minimal Account example.
22
+ //
23
+ // It does NOT authenticate — "live data" is still the token-proxy env
24
+ // (npm run auth:token + DYNAMICS_URL). The panel surfaces the active mode and lets
25
+ // you load a specific record by GUID (sets ?id= and reloads, which useExampleData reads).
26
+
27
+ const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
+ const RECENT_KEY = "cda-dev-panel-recent";
29
+
30
+ const stripBraces = (s: string) => s.replace(/[{}]/g, "").trim();
31
+
32
+ function isLocalhost(): boolean {
33
+ return (
34
+ typeof window !== "undefined" &&
35
+ (window.location.hostname === "localhost" ||
36
+ window.location.hostname === "127.0.0.1")
37
+ );
38
+ }
39
+
40
+ function getRecent(): string[] {
41
+ try {
42
+ return JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
43
+ } catch {
44
+ return [];
45
+ }
46
+ }
47
+
48
+ function addRecent(id: string): string[] {
49
+ const next = [id, ...getRecent().filter((x) => x !== id)].slice(0, 5);
50
+ try {
51
+ localStorage.setItem(RECENT_KEY, JSON.stringify(next));
52
+ } catch {
53
+ /* ignore */
54
+ }
55
+ return next;
56
+ }
57
+
58
+ const fabStyle: React.CSSProperties = {
59
+ position: "fixed",
60
+ bottom: 20,
61
+ right: 20,
62
+ zIndex: 2147483000,
63
+ width: 48,
64
+ height: 48,
65
+ borderRadius: "50%",
66
+ backgroundColor: "#0078d4",
67
+ color: "#ffffff",
68
+ boxShadow: "0 2px 8px rgba(0, 0, 0, 0.26)",
69
+ display: "flex",
70
+ alignItems: "center",
71
+ justifyContent: "center",
72
+ border: "none",
73
+ cursor: "pointer",
74
+ fontSize: 20,
75
+ padding: 0,
76
+ };
77
+
78
+ const EnvRow: React.FC<{ label: string; value: string }> = ({ label, value }) => (
79
+ <div
80
+ style={{
81
+ display: "flex",
82
+ justifyContent: "space-between",
83
+ gap: 12,
84
+ padding: "2px 0",
85
+ fontSize: 12,
86
+ }}
87
+ >
88
+ <span style={{ color: "#605e5c", fontWeight: 600 }}>{label}</span>
89
+ <span
90
+ title={value}
91
+ style={{
92
+ color: "#323130",
93
+ maxWidth: 210,
94
+ overflow: "hidden",
95
+ textOverflow: "ellipsis",
96
+ whiteSpace: "nowrap",
97
+ }}
98
+ >
99
+ {value}
100
+ </span>
101
+ </div>
102
+ );
103
+
104
+ export const DevPanel: React.FC = () => {
105
+ const [open, setOpen] = useState(false);
106
+ const [recordId, setRecordId] = useState(
107
+ () => new URLSearchParams(window.location.search).get("id") || "",
108
+ );
109
+ const [guidError, setGuidError] = useState<string | null>(null);
110
+ const [recent, setRecent] = useState<string[]>(getRecent);
111
+ const [logs, setLogs] = useState<string | null>(null);
112
+
113
+ const mode = useMemo(
114
+ () =>
115
+ import.meta.env.VITE_USE_PROXY
116
+ ? "Token-proxy (live Dataverse)"
117
+ : "Mock (in-memory)",
118
+ [],
119
+ );
120
+ const targetUrl = import.meta.env.VITE_DYNAMICS_URL || "";
121
+
122
+ const openRecord = useCallback((id: string) => {
123
+ const clean = stripBraces(id);
124
+ if (!GUID_RE.test(clean)) {
125
+ setGuidError("Invalid GUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).");
126
+ return;
127
+ }
128
+ setRecent(addRecent(clean));
129
+ const url = new URL(window.location.href);
130
+ url.searchParams.set("id", clean);
131
+ window.location.href = url.toString(); // reload → useExampleData reads ?id=
132
+ }, []);
133
+
134
+ // Localhost-only. (Hooks run unconditionally above this guard.)
135
+ if (!isLocalhost()) return null;
136
+
137
+ return (
138
+ <>
139
+ <button
140
+ style={fabStyle}
141
+ title="Dev Tools"
142
+ aria-label="Open dev tools panel"
143
+ onClick={() => setOpen(true)}
144
+ >
145
+ 🔧
146
+ </button>
147
+ <Panel
148
+ isOpen={open}
149
+ onDismiss={() => setOpen(false)}
150
+ type={PanelType.smallFixedFar}
151
+ headerText="Dev Tools"
152
+ isLightDismiss
153
+ styles={{ main: { maxWidth: 360 } }}
154
+ >
155
+ <Stack tokens={{ childrenGap: 16 }} styles={{ root: { paddingTop: 12 } }}>
156
+ <Stack tokens={{ childrenGap: 8 }}>
157
+ <Text variant="smallPlus" styles={{ root: { fontWeight: 600, color: "#605e5c" } }}>
158
+ Open record
159
+ </Text>
160
+ <TextField
161
+ label="Record ID (GUID)"
162
+ placeholder="00000000-0000-0000-0000-000000000000"
163
+ value={recordId}
164
+ onChange={(_, v) => {
165
+ setRecordId(v || "");
166
+ setGuidError(null);
167
+ }}
168
+ errorMessage={guidError || undefined}
169
+ />
170
+ <PrimaryButton
171
+ text="Open"
172
+ disabled={!recordId.trim()}
173
+ onClick={() => openRecord(recordId)}
174
+ />
175
+ <Text variant="tiny" styles={{ root: { color: "#a19f9d" } }}>
176
+ Adds ?id= and reloads. In mock mode the seeded record is always returned.
177
+ </Text>
178
+ </Stack>
179
+
180
+ {recent.length > 0 && (
181
+ <>
182
+ <Separator />
183
+ <Stack tokens={{ childrenGap: 4 }}>
184
+ <Text variant="smallPlus" styles={{ root: { fontWeight: 600, color: "#605e5c" } }}>
185
+ Recent
186
+ </Text>
187
+ {recent.map((id) => (
188
+ <Link key={id} title={id} onClick={() => openRecord(id)}>
189
+ <Icon iconName="OpenFile" styles={{ root: { fontSize: 12, marginRight: 6 } }} />
190
+ {id.substring(0, 8)}…
191
+ </Link>
192
+ ))}
193
+ </Stack>
194
+ </>
195
+ )}
196
+
197
+ <Separator />
198
+ <Stack tokens={{ childrenGap: 2 }}>
199
+ <Text variant="smallPlus" styles={{ root: { fontWeight: 600, color: "#605e5c" } }}>
200
+ Environment
201
+ </Text>
202
+ <EnvRow label="Mode" value={mode} />
203
+ {targetUrl && <EnvRow label="Target" value={targetUrl} />}
204
+ <EnvRow
205
+ label="Token"
206
+ value={import.meta.env.VITE_USE_PROXY ? "Configured (server-side)" : "Not set"}
207
+ />
208
+ <MessageBar messageBarType={MessageBarType.info} isMultiline styles={{ root: { marginTop: 8 } }}>
209
+ For live data: run <strong>npm run auth:token -- --url https://your-org.crm.dynamics.com</strong>, then restart <strong>npm run dev</strong>.
210
+ </MessageBar>
211
+ </Stack>
212
+
213
+ <Separator />
214
+ <Stack tokens={{ childrenGap: 6 }}>
215
+ <Text variant="smallPlus" styles={{ root: { fontWeight: 600, color: "#605e5c" } }}>
216
+ Logs
217
+ </Text>
218
+ <Stack horizontal tokens={{ childrenGap: 8 }}>
219
+ <DefaultButton text="Refresh" onClick={() => setLogs(logger.dump())} />
220
+ <DefaultButton
221
+ text="Clear"
222
+ onClick={() => {
223
+ logger.clear();
224
+ setLogs("");
225
+ }}
226
+ />
227
+ </Stack>
228
+ {logs !== null && (
229
+ <TextField multiline rows={6} readOnly value={logs || "(no entries)"} />
230
+ )}
231
+ </Stack>
232
+ </Stack>
233
+ </Panel>
234
+ </>
235
+ );
236
+ };
237
+
238
+ export default DevPanel;
@@ -0,0 +1,87 @@
1
+ import { changedKeys, hasChanged, changedFields } from "./diff";
2
+
3
+ interface Sample {
4
+ a: number;
5
+ b: string;
6
+ c: boolean;
7
+ }
8
+
9
+ const KEYS = ["a", "b", "c"] as const;
10
+
11
+ describe("changedKeys", () => {
12
+ it("treats every key as changed when there is no baseline (initial = null)", () => {
13
+ const current: Sample = { a: 1, b: "x", c: true };
14
+ expect(changedKeys(current, null, KEYS)).toEqual(["a", "b", "c"]);
15
+ });
16
+
17
+ it("returns only the keys whose value differs", () => {
18
+ const current: Sample = { a: 1, b: "x", c: true };
19
+ const initial: Sample = { a: 2, b: "x", c: false };
20
+ expect(changedKeys(current, initial, KEYS)).toEqual(["a", "c"]);
21
+ });
22
+
23
+ it("returns [] when nothing changed", () => {
24
+ const value: Sample = { a: 1, b: "x", c: true };
25
+ expect(changedKeys(value, { ...value }, KEYS)).toEqual([]);
26
+ });
27
+
28
+ it("only inspects the keys it is given", () => {
29
+ const current: Sample = { a: 1, b: "changed", c: true };
30
+ const initial: Sample = { a: 1, b: "original", c: true };
31
+ expect(changedKeys(current, initial, ["a", "c"] as const)).toEqual([]);
32
+ });
33
+
34
+ it("compares by strict reference (distinct objects of equal shape count as changed)", () => {
35
+ interface Holder {
36
+ v: { n: number };
37
+ }
38
+ const shared = { n: 1 };
39
+ expect(changedKeys({ v: { n: 1 } }, { v: shared }, ["v"] as const)).toEqual([
40
+ "v",
41
+ ]);
42
+ expect(changedKeys({ v: shared }, { v: shared }, ["v"] as const)).toEqual(
43
+ [],
44
+ );
45
+ });
46
+ });
47
+
48
+ describe("hasChanged", () => {
49
+ it("is true when any key differs", () => {
50
+ expect(hasChanged({ a: 1 }, { a: 2 }, ["a"] as const)).toBe(true);
51
+ });
52
+
53
+ it("is false when all compared keys are equal", () => {
54
+ expect(hasChanged({ a: 1, b: 2 }, { a: 1, b: 9 }, ["a"] as const)).toBe(
55
+ false,
56
+ );
57
+ });
58
+
59
+ it("is true when there is no baseline", () => {
60
+ expect(hasChanged({ a: 1 }, null, ["a"] as const)).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe("changedFields", () => {
65
+ it("returns a partial containing only the changed keys and their current values", () => {
66
+ const current: Sample = { a: 1, b: "new", c: true };
67
+ const initial: Sample = { a: 1, b: "old", c: false };
68
+ expect(changedFields(current, initial, KEYS)).toEqual({
69
+ b: "new",
70
+ c: true,
71
+ });
72
+ });
73
+
74
+ it("returns every key (with current values) when there is no baseline", () => {
75
+ const current: Sample = { a: 1, b: "x", c: true };
76
+ expect(changedFields(current, null, KEYS)).toEqual({
77
+ a: 1,
78
+ b: "x",
79
+ c: true,
80
+ });
81
+ });
82
+
83
+ it("returns {} when nothing changed", () => {
84
+ const value: Sample = { a: 1, b: "x", c: true };
85
+ expect(changedFields(value, { ...value }, KEYS)).toEqual({});
86
+ });
87
+ });
@@ -0,0 +1,38 @@
1
+ // Generic, framework-free change detection. Pure — unit-testable without React.
2
+ // The form layer uses this to send ONLY the modified fields to Dataverse.
3
+
4
+ /**
5
+ * Returns the subset of `keys` whose value differs between `current` and
6
+ * `initial`. When `initial` is null (no snapshot yet) every key is considered
7
+ * changed — matching "treat as dirty until initialized" behavior.
8
+ */
9
+ export function changedKeys<T>(
10
+ current: T,
11
+ initial: T | null,
12
+ keys: ReadonlyArray<keyof T>,
13
+ ): Array<keyof T> {
14
+ if (!initial) return [...keys];
15
+ return keys.filter((key) => current[key] !== initial[key]);
16
+ }
17
+
18
+ /** True when any of `keys` differ between `current` and `initial`. */
19
+ export function hasChanged<T>(
20
+ current: T,
21
+ initial: T | null,
22
+ keys: ReadonlyArray<keyof T>,
23
+ ): boolean {
24
+ return changedKeys(current, initial, keys).length > 0;
25
+ }
26
+
27
+ /** A partial object containing only the changed keys (and their current values). */
28
+ export function changedFields<T>(
29
+ current: T,
30
+ initial: T | null,
31
+ keys: ReadonlyArray<keyof T>,
32
+ ): Partial<T> {
33
+ const result: Partial<T> = {};
34
+ for (const key of changedKeys(current, initial, keys)) {
35
+ result[key] = current[key];
36
+ }
37
+ return result;
38
+ }
@@ -0,0 +1,140 @@
1
+ import React from "react";
2
+ import { Spinner, MessageBarType, CommandBarButton } from "@fluentui/react";
3
+ import {
4
+ Form,
5
+ Avatar,
6
+ ReusableCard,
7
+ FieldRow,
8
+ CustomTextfield,
9
+ ButtonDefaultComponent,
10
+ MessageBarComponent,
11
+ } from "@khester/reusable-components";
12
+ import type { IApiService } from "../core/services/IApiService";
13
+ import { useExampleData } from "./hooks/useExampleData";
14
+ import { useExampleForm } from "./hooks/useExampleForm";
15
+ import type { AccountFormField } from "./mappers/accountMapper";
16
+
17
+ export interface ExamplePageProps {
18
+ api: IApiService;
19
+ /** Record id resolved from the page context (URL / hosting form). */
20
+ recordId?: string;
21
+ }
22
+
23
+ type Field = {
24
+ key: AccountFormField;
25
+ label: string;
26
+ required?: boolean;
27
+ icon?: string;
28
+ };
29
+
30
+ // Two sections laid out in the Form's 12-column grid (span 6 each).
31
+ const ACCOUNT_FIELDS: Field[] = [
32
+ { key: "name", label: "Account name", required: true },
33
+ { key: "accountNumber", label: "Account number" },
34
+ { key: "phone", label: "Phone", icon: "Phone" },
35
+ ];
36
+ const CONTACT_FIELDS: Field[] = [
37
+ { key: "email", label: "Email", icon: "Mail" },
38
+ { key: "website", label: "Website", icon: "Globe" },
39
+ ];
40
+
41
+ /**
42
+ * Thin form: hooks + JSX, no CRUD. A page-variant <Form> renders a Dynamics-style
43
+ * top command bar + record header; two section cards (ReusableCard, span 6) sit in
44
+ * the responsive grid body. Swap out example/ to build your own page.
45
+ */
46
+ export const ExamplePage: React.FC<ExamplePageProps> = ({ api, recordId }) => {
47
+ const { account, isLoading, error, reload } = useExampleData(api, recordId);
48
+ const { form, isDirty, isSaving, saveError, saveSucceeded, setField, save, reset } =
49
+ useExampleForm(api, account);
50
+
51
+ if (isLoading) {
52
+ return <Spinner label="Loading account…" />;
53
+ }
54
+
55
+ if (error) {
56
+ return (
57
+ <div style={{ padding: 16 }}>
58
+ <MessageBarComponent messageBarType={MessageBarType.error} subtext={error} />
59
+ <div style={{ marginTop: 12 }}>
60
+ <ButtonDefaultComponent text="Retry" onClick={reload} />
61
+ </div>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ if (!account) {
67
+ return (
68
+ <div style={{ padding: 16 }}>
69
+ <MessageBarComponent
70
+ messageBarType={MessageBarType.warning}
71
+ subtext="No account to display."
72
+ />
73
+ </div>
74
+ );
75
+ }
76
+
77
+ const renderField = (f: Field) => (
78
+ <FieldRow key={f.key} label={f.label} required={f.required}>
79
+ <CustomTextfield
80
+ value={form[f.key]}
81
+ onChange={(_, v) => setField(f.key, v ?? "")}
82
+ iconProps={f.icon ? { iconName: f.icon } : undefined}
83
+ />
84
+ </FieldRow>
85
+ );
86
+
87
+ // Form-level save result, shown above the sections.
88
+ const notification = saveError ? (
89
+ <MessageBarComponent messageBarType={MessageBarType.error} subtext={saveError} />
90
+ ) : saveSucceeded ? (
91
+ <MessageBarComponent messageBarType={MessageBarType.success} subtext="Account saved." />
92
+ ) : undefined;
93
+
94
+ // Top command bar (page variant) — flat Dynamics-style commands.
95
+ const commands = (
96
+ <>
97
+ <CommandBarButton
98
+ iconProps={{ iconName: "Save" }}
99
+ text="Save"
100
+ onClick={save}
101
+ disabled={!isDirty || isSaving}
102
+ />
103
+ <CommandBarButton
104
+ iconProps={{ iconName: "Undo" }}
105
+ text="Reset"
106
+ onClick={reset}
107
+ disabled={!isDirty || isSaving}
108
+ />
109
+ <CommandBarButton
110
+ iconProps={{ iconName: "Refresh" }}
111
+ text="Refresh"
112
+ onClick={reload}
113
+ disabled={isSaving}
114
+ />
115
+ </>
116
+ );
117
+
118
+ return (
119
+ <div style={{ maxWidth: 1100, margin: "16px auto" }}>
120
+ <Form
121
+ variant="page"
122
+ title={account.name || "Account"}
123
+ avatar={<Avatar name={account.name || "Account"} />}
124
+ status={isDirty ? "Unsaved" : "Saved"}
125
+ subtitle="Account · Account"
126
+ actions={commands}
127
+ notification={notification}
128
+ >
129
+ <ReusableCard title="Account" columnSpan={6}>
130
+ {ACCOUNT_FIELDS.map(renderField)}
131
+ </ReusableCard>
132
+ <ReusableCard title="Contact" columnSpan={6}>
133
+ {CONTACT_FIELDS.map(renderField)}
134
+ </ReusableCard>
135
+ </Form>
136
+ </div>
137
+ );
138
+ };
139
+
140
+ export default ExamplePage;
@@ -0,0 +1,36 @@
1
+ // Normalizes the error shapes thrown by the IApiService implementations into a
2
+ // single human-readable string for the UI. (The service layer's own logging
3
+ // parser lives in core/services/crudLogging.ts; this one is for user messages.)
4
+
5
+ /**
6
+ * Extracts the most meaningful message from an error thrown by an IApiService,
7
+ * falling back to `fallback` when nothing useful is found. Handles plain
8
+ * `json.error` objects (XrmApiService) and Error instances whose message embeds
9
+ * a Dataverse error JSON blob (FetchApiService).
10
+ */
11
+ export function normalizeDataverseError(err: unknown, fallback: string): string {
12
+ if (err && typeof err === "object" && !(err instanceof Error)) {
13
+ const obj = err as Record<string, unknown>;
14
+ const innerMsg = obj["@Microsoft.PowerApps.CDS.InnerError.Message"];
15
+ if (typeof innerMsg === "string" && innerMsg) return innerMsg;
16
+ if (typeof obj.message === "string" && obj.message) return obj.message;
17
+ }
18
+
19
+ if (err instanceof Error) {
20
+ try {
21
+ const jsonStart = err.message.indexOf("{");
22
+ if (jsonStart !== -1) {
23
+ const parsed = JSON.parse(err.message.substring(jsonStart));
24
+ const innerMsg =
25
+ parsed?.error?.["@Microsoft.PowerApps.CDS.InnerError.Message"];
26
+ if (innerMsg) return innerMsg;
27
+ if (parsed?.error?.message) return parsed.error.message;
28
+ }
29
+ } catch {
30
+ // JSON parse failed — fall through to the raw message
31
+ }
32
+ return err.message || fallback;
33
+ }
34
+
35
+ return fallback;
36
+ }
@@ -0,0 +1,40 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import type { IApiService } from "../../core/services/IApiService";
3
+ import { Account, IAccount } from "../models/Account";
4
+ import { normalizeDataverseError } from "../exampleError";
5
+
6
+ export interface ExampleData {
7
+ account: IAccount | null;
8
+ isLoading: boolean;
9
+ error: string | null;
10
+ reload: () => void;
11
+ }
12
+
13
+ /**
14
+ * Loads the single account this page edits (data layer only — no form state).
15
+ * `recordId` is resolved by the shell from the page context (URL / hosting form);
16
+ * in mock mode it's ignored and the seeded record is returned.
17
+ */
18
+ export function useExampleData(
19
+ api: IApiService,
20
+ recordId?: string,
21
+ ): ExampleData {
22
+ const [account, setAccount] = useState<IAccount | null>(null);
23
+ const [isLoading, setIsLoading] = useState(true);
24
+ const [error, setError] = useState<string | null>(null);
25
+
26
+ const load = useCallback(() => {
27
+ setIsLoading(true);
28
+ setError(null);
29
+ Account.retrieveOne(api, recordId)
30
+ .then(setAccount)
31
+ .catch((e) =>
32
+ setError(normalizeDataverseError(e, "Failed to load account.")),
33
+ )
34
+ .finally(() => setIsLoading(false));
35
+ }, [api, recordId]);
36
+
37
+ useEffect(load, [load]);
38
+
39
+ return { account, isLoading, error, reload: load };
40
+ }