@khester/create-dynamics-app 2.1.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.
- package/dist/artifacts/registry.d.ts +4 -3
- package/dist/artifacts/registry.d.ts.map +1 -1
- package/dist/artifacts/registry.js +121 -11
- package/dist/artifacts/registry.js.map +1 -1
- package/dist/artifacts/types.d.ts +1 -1
- package/dist/artifacts/types.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/injectDevTools.d.ts.map +1 -1
- package/dist/injectDevTools.js +4 -2
- package/dist/injectDevTools.js.map +1 -1
- package/dist/scaffold.d.ts +1 -0
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +3 -1
- package/dist/scaffold.js.map +1 -1
- package/package.json +3 -2
- package/templates/grid-starter/ARCHITECTURE.md +66 -0
- package/templates/grid-starter/README.md +122 -0
- package/templates/grid-starter/env.example +16 -0
- package/templates/grid-starter/gitignore +6 -0
- package/templates/grid-starter/index.html +16 -0
- package/templates/grid-starter/package.json +39 -0
- package/templates/grid-starter/src/App.tsx +23 -0
- package/templates/grid-starter/src/core/services/FetchApiService.ts +117 -0
- package/templates/grid-starter/src/core/services/IApiService.ts +37 -0
- package/templates/grid-starter/src/core/services/MockApiService.ts +72 -0
- package/templates/grid-starter/src/core/services/ServiceFactory.ts +58 -0
- package/templates/grid-starter/src/core/services/XrmApiService.ts +135 -0
- package/templates/grid-starter/src/core/services/crudLogging.ts +52 -0
- package/templates/grid-starter/src/dev-tools/DevPanel.tsx +239 -0
- package/templates/grid-starter/src/grid/GridPage.tsx +119 -0
- package/templates/grid-starter/src/index.tsx +18 -0
- package/templates/grid-starter/src/vite-env.d.ts +15 -0
- package/templates/grid-starter/tools/deploy/deploy-webresource.cjs +117 -0
- package/templates/grid-starter/tsconfig.json +19 -0
- package/templates/grid-starter/vite.config.ts +76 -0
- package/templates/pcf-field/_variants/ValueInput.boolean.tsx +2 -0
- package/templates/pcf-field/_variants/ValueInput.date.tsx +2 -0
- package/templates/pcf-field/_variants/ValueInput.number.tsx +2 -0
- package/templates/pcf-field/_variants/ValueInput.optionset.tsx +77 -0
- package/templates/pcf-field/_variants/ValueInput.text.tsx +2 -0
- package/templates/pcf-field/index.ts +1 -1
- package/templates/pcf-field/package.json +3 -1
- package/templates/pcf-field/{{componentName}}Component.tsx +2 -0
- package/templates/react-custom-page/ARCHITECTURE.md +75 -0
- package/templates/react-custom-page/README.md +74 -568
- package/templates/react-custom-page/env.example +16 -0
- package/templates/react-custom-page/gitignore +1 -0
- package/templates/react-custom-page/index.html +16 -0
- package/templates/react-custom-page/package.json +21 -49
- package/templates/react-custom-page/src/App.tsx +26 -0
- package/templates/react-custom-page/src/core/recordContext.test.ts +30 -0
- package/templates/react-custom-page/src/core/recordContext.ts +51 -0
- package/templates/react-custom-page/src/core/services/FetchApiService.ts +117 -0
- package/templates/react-custom-page/src/core/services/IApiService.ts +37 -0
- package/templates/react-custom-page/src/core/services/MockApiService.ts +73 -0
- package/templates/react-custom-page/src/core/services/ServiceFactory.ts +58 -0
- package/templates/react-custom-page/src/core/services/XrmApiService.ts +135 -0
- package/templates/react-custom-page/src/core/services/crudLogging.ts +52 -0
- package/templates/react-custom-page/src/dev-tools/DevPanel.tsx +238 -0
- package/templates/react-custom-page/src/domain/diff.test.ts +87 -0
- package/templates/react-custom-page/src/domain/diff.ts +38 -0
- package/templates/react-custom-page/src/example/ExamplePage.tsx +140 -0
- package/templates/react-custom-page/src/example/exampleError.ts +36 -0
- package/templates/react-custom-page/src/example/hooks/useExampleData.ts +40 -0
- package/templates/react-custom-page/src/example/hooks/useExampleForm.ts +99 -0
- package/templates/react-custom-page/src/example/mappers/accountMapper.test.ts +38 -0
- package/templates/react-custom-page/src/example/mappers/accountMapper.ts +55 -0
- package/templates/react-custom-page/src/example/models/Account.ts +74 -0
- package/templates/react-custom-page/src/index.tsx +18 -128
- package/templates/react-custom-page/src/vite-env.d.ts +15 -0
- package/templates/react-custom-page/tools/deploy/deploy-webresource.cjs +117 -0
- package/templates/react-custom-page/tsconfig.json +12 -22
- package/templates/react-custom-page/vite.config.ts +76 -0
- package/templates/starter-page/README.md +38 -0
- package/templates/starter-page/_variants/App.dashboard.v8.tsx +46 -0
- package/templates/starter-page/_variants/App.form.v8.tsx +59 -0
- package/templates/starter-page/_variants/App.master-detail.v8.tsx +61 -0
- package/templates/starter-page/_variants/App.panel.v8.tsx +99 -0
- package/templates/starter-page/gitignore +5 -0
- package/templates/starter-page/package.json +27 -0
- package/templates/starter-page/public/index.html +11 -0
- package/templates/starter-page/src/index.tsx +10 -0
- package/templates/starter-page/src/services/dataverse.ts +30 -0
- package/templates/starter-page/tsconfig.json +15 -0
- package/templates/starter-page/webpack.config.js +17 -0
- package/templates/react-custom-page/deployment/README.md +0 -484
- package/templates/react-custom-page/docs/ARCHITECTURE_OVERVIEW.md +0 -506
- package/templates/react-custom-page/docs/BEST_PRACTICES.md +0 -723
- package/templates/react-custom-page/docs/MIGRATION_GUIDE.md +0 -447
- package/templates/react-custom-page/public/index.html +0 -15
- package/templates/react-custom-page/scripts/custom-build.js +0 -255
- package/templates/react-custom-page/src/components/AccountForm.css +0 -71
- package/templates/react-custom-page/src/components/AccountForm.tsx +0 -541
- package/templates/react-custom-page/src/components/AccountManagement.css +0 -86
- package/templates/react-custom-page/src/components/AccountManagement.tsx +0 -370
- package/templates/react-custom-page/src/components/ContactForm.css +0 -48
- package/templates/react-custom-page/src/components/ContactForm.tsx +0 -327
- package/templates/react-custom-page/src/components/ContactManagement.css +0 -86
- package/templates/react-custom-page/src/components/ContactManagement.tsx +0 -357
- package/templates/react-custom-page/src/components/Logging/LogDialog.tsx +0 -291
- package/templates/react-custom-page/src/components/Logging/LoggingContext.tsx +0 -166
- package/templates/react-custom-page/src/components/Logging/LoggingDebugPanel.css +0 -192
- package/templates/react-custom-page/src/components/Logging/LoggingDebugPanel.tsx +0 -177
- package/templates/react-custom-page/src/components/Logging/LoggingProvider.tsx +0 -3
- package/templates/react-custom-page/src/components/Logging/logger.ts +0 -193
- package/templates/react-custom-page/src/constants/account.ts +0 -410
- package/templates/react-custom-page/src/constants/contact.ts +0 -362
- package/templates/react-custom-page/src/models/Account.ts +0 -480
- package/templates/react-custom-page/src/models/BaseEntity.ts +0 -204
- package/templates/react-custom-page/src/models/Contact.ts +0 -580
- package/templates/react-custom-page/src/pcf/ContactControlWrapper.tsx +0 -107
- package/templates/react-custom-page/src/pcf/MultiEntityControlWrapper.tsx +0 -205
- package/templates/react-custom-page/src/providers/DynamicsProvider.tsx +0 -353
- package/templates/react-custom-page/src/services/MockApiService.ts +0 -260
- package/templates/react-custom-page/src/services/ServiceFactory.ts +0 -65
- package/templates/react-custom-page/src/services/XrmApiService.ts +0 -213
- package/templates/react-custom-page/src/styles/index.css +0 -171
- package/templates/react-custom-page/tools/metadata-sync/index.js +0 -152
- package/templates/react-custom-page/webpack.config.js +0 -57
- /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
|
+
}
|