@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,239 @@
|
|
|
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 set a record GUID in the URL (?id= + reload). The grid starter loads the full
|
|
26
|
+
// list and ignores ?id=; wire it into GridPage if you add a record-detail view.
|
|
27
|
+
|
|
28
|
+
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
29
|
+
const RECENT_KEY = "cda-dev-panel-recent";
|
|
30
|
+
|
|
31
|
+
const stripBraces = (s: string) => s.replace(/[{}]/g, "").trim();
|
|
32
|
+
|
|
33
|
+
function isLocalhost(): boolean {
|
|
34
|
+
return (
|
|
35
|
+
typeof window !== "undefined" &&
|
|
36
|
+
(window.location.hostname === "localhost" ||
|
|
37
|
+
window.location.hostname === "127.0.0.1")
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getRecent(): string[] {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function addRecent(id: string): string[] {
|
|
50
|
+
const next = [id, ...getRecent().filter((x) => x !== id)].slice(0, 5);
|
|
51
|
+
try {
|
|
52
|
+
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
|
|
53
|
+
} catch {
|
|
54
|
+
/* ignore */
|
|
55
|
+
}
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const fabStyle: React.CSSProperties = {
|
|
60
|
+
position: "fixed",
|
|
61
|
+
bottom: 20,
|
|
62
|
+
right: 20,
|
|
63
|
+
zIndex: 2147483000,
|
|
64
|
+
width: 48,
|
|
65
|
+
height: 48,
|
|
66
|
+
borderRadius: "50%",
|
|
67
|
+
backgroundColor: "#0078d4",
|
|
68
|
+
color: "#ffffff",
|
|
69
|
+
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.26)",
|
|
70
|
+
display: "flex",
|
|
71
|
+
alignItems: "center",
|
|
72
|
+
justifyContent: "center",
|
|
73
|
+
border: "none",
|
|
74
|
+
cursor: "pointer",
|
|
75
|
+
fontSize: 20,
|
|
76
|
+
padding: 0,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const EnvRow: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
|
80
|
+
<div
|
|
81
|
+
style={{
|
|
82
|
+
display: "flex",
|
|
83
|
+
justifyContent: "space-between",
|
|
84
|
+
gap: 12,
|
|
85
|
+
padding: "2px 0",
|
|
86
|
+
fontSize: 12,
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<span style={{ color: "#605e5c", fontWeight: 600 }}>{label}</span>
|
|
90
|
+
<span
|
|
91
|
+
title={value}
|
|
92
|
+
style={{
|
|
93
|
+
color: "#323130",
|
|
94
|
+
maxWidth: 210,
|
|
95
|
+
overflow: "hidden",
|
|
96
|
+
textOverflow: "ellipsis",
|
|
97
|
+
whiteSpace: "nowrap",
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{value}
|
|
101
|
+
</span>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
export const DevPanel: React.FC = () => {
|
|
106
|
+
const [open, setOpen] = useState(false);
|
|
107
|
+
const [recordId, setRecordId] = useState(
|
|
108
|
+
() => new URLSearchParams(window.location.search).get("id") || "",
|
|
109
|
+
);
|
|
110
|
+
const [guidError, setGuidError] = useState<string | null>(null);
|
|
111
|
+
const [recent, setRecent] = useState<string[]>(getRecent);
|
|
112
|
+
const [logs, setLogs] = useState<string | null>(null);
|
|
113
|
+
|
|
114
|
+
const mode = useMemo(
|
|
115
|
+
() =>
|
|
116
|
+
import.meta.env.VITE_USE_PROXY
|
|
117
|
+
? "Token-proxy (live Dataverse)"
|
|
118
|
+
: "Mock (in-memory)",
|
|
119
|
+
[],
|
|
120
|
+
);
|
|
121
|
+
const targetUrl = import.meta.env.VITE_DYNAMICS_URL || "";
|
|
122
|
+
|
|
123
|
+
const openRecord = useCallback((id: string) => {
|
|
124
|
+
const clean = stripBraces(id);
|
|
125
|
+
if (!GUID_RE.test(clean)) {
|
|
126
|
+
setGuidError("Invalid GUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
setRecent(addRecent(clean));
|
|
130
|
+
const url = new URL(window.location.href);
|
|
131
|
+
url.searchParams.set("id", clean);
|
|
132
|
+
window.location.href = url.toString(); // reload with ?id= (consume it in GridPage if needed)
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
// Localhost-only. (Hooks run unconditionally above this guard.)
|
|
136
|
+
if (!isLocalhost()) return null;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<>
|
|
140
|
+
<button
|
|
141
|
+
style={fabStyle}
|
|
142
|
+
title="Dev Tools"
|
|
143
|
+
aria-label="Open dev tools panel"
|
|
144
|
+
onClick={() => setOpen(true)}
|
|
145
|
+
>
|
|
146
|
+
🔧
|
|
147
|
+
</button>
|
|
148
|
+
<Panel
|
|
149
|
+
isOpen={open}
|
|
150
|
+
onDismiss={() => setOpen(false)}
|
|
151
|
+
type={PanelType.smallFixedFar}
|
|
152
|
+
headerText="Dev Tools"
|
|
153
|
+
isLightDismiss
|
|
154
|
+
styles={{ main: { maxWidth: 360 } }}
|
|
155
|
+
>
|
|
156
|
+
<Stack tokens={{ childrenGap: 16 }} styles={{ root: { paddingTop: 12 } }}>
|
|
157
|
+
<Stack tokens={{ childrenGap: 8 }}>
|
|
158
|
+
<Text variant="smallPlus" styles={{ root: { fontWeight: 600, color: "#605e5c" } }}>
|
|
159
|
+
Open record
|
|
160
|
+
</Text>
|
|
161
|
+
<TextField
|
|
162
|
+
label="Record ID (GUID)"
|
|
163
|
+
placeholder="00000000-0000-0000-0000-000000000000"
|
|
164
|
+
value={recordId}
|
|
165
|
+
onChange={(_, v) => {
|
|
166
|
+
setRecordId(v || "");
|
|
167
|
+
setGuidError(null);
|
|
168
|
+
}}
|
|
169
|
+
errorMessage={guidError || undefined}
|
|
170
|
+
/>
|
|
171
|
+
<PrimaryButton
|
|
172
|
+
text="Open"
|
|
173
|
+
disabled={!recordId.trim()}
|
|
174
|
+
onClick={() => openRecord(recordId)}
|
|
175
|
+
/>
|
|
176
|
+
<Text variant="tiny" styles={{ root: { color: "#a19f9d" } }}>
|
|
177
|
+
Adds ?id= and reloads. In mock mode the seeded record is always returned.
|
|
178
|
+
</Text>
|
|
179
|
+
</Stack>
|
|
180
|
+
|
|
181
|
+
{recent.length > 0 && (
|
|
182
|
+
<>
|
|
183
|
+
<Separator />
|
|
184
|
+
<Stack tokens={{ childrenGap: 4 }}>
|
|
185
|
+
<Text variant="smallPlus" styles={{ root: { fontWeight: 600, color: "#605e5c" } }}>
|
|
186
|
+
Recent
|
|
187
|
+
</Text>
|
|
188
|
+
{recent.map((id) => (
|
|
189
|
+
<Link key={id} title={id} onClick={() => openRecord(id)}>
|
|
190
|
+
<Icon iconName="OpenFile" styles={{ root: { fontSize: 12, marginRight: 6 } }} />
|
|
191
|
+
{id.substring(0, 8)}…
|
|
192
|
+
</Link>
|
|
193
|
+
))}
|
|
194
|
+
</Stack>
|
|
195
|
+
</>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
<Separator />
|
|
199
|
+
<Stack tokens={{ childrenGap: 2 }}>
|
|
200
|
+
<Text variant="smallPlus" styles={{ root: { fontWeight: 600, color: "#605e5c" } }}>
|
|
201
|
+
Environment
|
|
202
|
+
</Text>
|
|
203
|
+
<EnvRow label="Mode" value={mode} />
|
|
204
|
+
{targetUrl && <EnvRow label="Target" value={targetUrl} />}
|
|
205
|
+
<EnvRow
|
|
206
|
+
label="Token"
|
|
207
|
+
value={import.meta.env.VITE_USE_PROXY ? "Configured (server-side)" : "Not set"}
|
|
208
|
+
/>
|
|
209
|
+
<MessageBar messageBarType={MessageBarType.info} isMultiline styles={{ root: { marginTop: 8 } }}>
|
|
210
|
+
For live data: run <strong>npm run auth:token -- --url https://your-org.crm.dynamics.com</strong>, then restart <strong>npm run dev</strong>.
|
|
211
|
+
</MessageBar>
|
|
212
|
+
</Stack>
|
|
213
|
+
|
|
214
|
+
<Separator />
|
|
215
|
+
<Stack tokens={{ childrenGap: 6 }}>
|
|
216
|
+
<Text variant="smallPlus" styles={{ root: { fontWeight: 600, color: "#605e5c" } }}>
|
|
217
|
+
Logs
|
|
218
|
+
</Text>
|
|
219
|
+
<Stack horizontal tokens={{ childrenGap: 8 }}>
|
|
220
|
+
<DefaultButton text="Refresh" onClick={() => setLogs(logger.dump())} />
|
|
221
|
+
<DefaultButton
|
|
222
|
+
text="Clear"
|
|
223
|
+
onClick={() => {
|
|
224
|
+
logger.clear();
|
|
225
|
+
setLogs("");
|
|
226
|
+
}}
|
|
227
|
+
/>
|
|
228
|
+
</Stack>
|
|
229
|
+
{logs !== null && (
|
|
230
|
+
<TextField multiline rows={6} readOnly value={logs || "(no entries)"} />
|
|
231
|
+
)}
|
|
232
|
+
</Stack>
|
|
233
|
+
</Stack>
|
|
234
|
+
</Panel>
|
|
235
|
+
</>
|
|
236
|
+
);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
export default DevPanel;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
initializeIcons,
|
|
4
|
+
MessageBar,
|
|
5
|
+
MessageBarType,
|
|
6
|
+
Spinner,
|
|
7
|
+
Text,
|
|
8
|
+
} from "@fluentui/react";
|
|
9
|
+
import { DataGrid, createCellRegistry } from "@dataverse-kit/grid-kit";
|
|
10
|
+
import type { ColumnDef } from "@dataverse-kit/grid-kit";
|
|
11
|
+
import type { IApiService } from "../core/services/IApiService";
|
|
12
|
+
|
|
13
|
+
initializeIcons();
|
|
14
|
+
|
|
15
|
+
/** Display row shape — map your Dataverse attributes to this in `mapAccount`. */
|
|
16
|
+
interface AccountRow extends Record<string, unknown> {
|
|
17
|
+
key: string;
|
|
18
|
+
name: string;
|
|
19
|
+
accountnumber: string;
|
|
20
|
+
telephone1: string;
|
|
21
|
+
revenue: number;
|
|
22
|
+
statuscode: string;
|
|
23
|
+
rating: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
27
|
+
Active: "#107c10",
|
|
28
|
+
"On hold": "#f7e600",
|
|
29
|
+
Inactive: "#d13438",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// grid-kit columns. Swap `rendererType` / `rendererConfig` to change a cell's
|
|
33
|
+
// look; add `editorType` to make a column inline-editable.
|
|
34
|
+
const columns: ColumnDef<AccountRow>[] = [
|
|
35
|
+
{ key: "name", fieldName: "name", name: "Account", rendererType: "text", editorType: "text", minWidth: 180, isSortable: true },
|
|
36
|
+
{ key: "accountnumber", fieldName: "accountnumber", name: "Number", rendererType: "text", minWidth: 110 },
|
|
37
|
+
{ key: "telephone1", fieldName: "telephone1", name: "Phone", rendererType: "text", minWidth: 150 },
|
|
38
|
+
{ key: "revenue", fieldName: "revenue", name: "Revenue", rendererType: "currency", minWidth: 130, isSortable: true, rendererConfig: { currencyCode: "USD" } },
|
|
39
|
+
{ key: "statuscode", fieldName: "statuscode", name: "Status", rendererType: "coloredCell", minWidth: 110, rendererConfig: { colorMap: STATUS_COLORS } },
|
|
40
|
+
{ key: "rating", fieldName: "rating", name: "Rating", rendererType: "rating", minWidth: 120, rendererConfig: { max: 5 } },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
44
|
+
function mapAccount(e: any): AccountRow {
|
|
45
|
+
return {
|
|
46
|
+
key: e.accountid,
|
|
47
|
+
name: e.name ?? "",
|
|
48
|
+
accountnumber: e.accountnumber ?? "",
|
|
49
|
+
telephone1: e.telephone1 ?? "",
|
|
50
|
+
revenue: Number(e.revenue ?? 0),
|
|
51
|
+
// NOTE: the mock seeds a display string. Real Dataverse `statuscode` is an
|
|
52
|
+
// int optionset — its label is in the `statuscode@OData.Community.Display.V1.FormattedValue`
|
|
53
|
+
// sibling key. In production prefer that label (or switch this column to
|
|
54
|
+
// rendererType 'optionset' with options) so STATUS_COLORS resolves.
|
|
55
|
+
statuscode: e["statuscode@OData.Community.Display.V1.FormattedValue"] ?? e.statuscode ?? "Active",
|
|
56
|
+
rating: Number(e.rating ?? 0),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
60
|
+
|
|
61
|
+
export const GridPage: React.FC<{ api: IApiService }> = ({ api }) => {
|
|
62
|
+
const [rows, setRows] = useState<AccountRow[]>([]);
|
|
63
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
64
|
+
const [error, setError] = useState<string | null>(null);
|
|
65
|
+
const [search, setSearch] = useState("");
|
|
66
|
+
const registry = useMemo(() => createCellRegistry(), []);
|
|
67
|
+
|
|
68
|
+
const filteredRows = useMemo(() => {
|
|
69
|
+
const q = search.trim().toLowerCase();
|
|
70
|
+
if (!q) return rows;
|
|
71
|
+
return rows.filter((r) =>
|
|
72
|
+
[r.name, r.accountnumber, r.telephone1, r.statuscode].some((v) =>
|
|
73
|
+
String(v).toLowerCase().includes(q),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
}, [rows, search]);
|
|
77
|
+
|
|
78
|
+
const load = useCallback(() => {
|
|
79
|
+
setIsLoading(true);
|
|
80
|
+
setError(null);
|
|
81
|
+
api
|
|
82
|
+
.retrieveMultipleRecords("accounts", "")
|
|
83
|
+
.then((res) => setRows(res.entities.map(mapAccount)))
|
|
84
|
+
.catch((e) => setError(e?.message ?? String(e)))
|
|
85
|
+
.finally(() => setIsLoading(false));
|
|
86
|
+
}, [api]);
|
|
87
|
+
|
|
88
|
+
useEffect(load, [load]);
|
|
89
|
+
|
|
90
|
+
if (isLoading) return <Spinner label="Loading accounts…" />;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div style={{ maxWidth: 1100, margin: "16px auto" }}>
|
|
94
|
+
<Text variant="xLarge" block styles={{ root: { marginBottom: 12 } }}>
|
|
95
|
+
Accounts
|
|
96
|
+
</Text>
|
|
97
|
+
{error && (
|
|
98
|
+
<MessageBar messageBarType={MessageBarType.error} styles={{ root: { marginBottom: 12 } }}>
|
|
99
|
+
{error}
|
|
100
|
+
</MessageBar>
|
|
101
|
+
)}
|
|
102
|
+
<DataGrid<AccountRow>
|
|
103
|
+
items={filteredRows}
|
|
104
|
+
columns={columns}
|
|
105
|
+
registry={registry}
|
|
106
|
+
selectionMode="multiple"
|
|
107
|
+
getKey={(r) => r.key}
|
|
108
|
+
toolbar={{
|
|
109
|
+
showSearch: true,
|
|
110
|
+
searchPlaceholder: "Search accounts",
|
|
111
|
+
onSearch: setSearch,
|
|
112
|
+
commands: [{ key: "refresh", text: "Refresh", iconName: "Refresh", onClick: load }],
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export default GridPage;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { initLogging } from "@khester/reusable-components";
|
|
4
|
+
import { App } from "./App";
|
|
5
|
+
|
|
6
|
+
// Wire up the localhost-only troubleshooting hooks (window.dumpAppLogs()).
|
|
7
|
+
// No-op in a live org. Fluent's initializeIcons() is called inside the component
|
|
8
|
+
// library (via MessageBarComponent) — do NOT call it again here.
|
|
9
|
+
initLogging();
|
|
10
|
+
|
|
11
|
+
const container = document.getElementById("root");
|
|
12
|
+
if (!container) throw new Error('Root element "#root" not found');
|
|
13
|
+
|
|
14
|
+
createRoot(container).render(
|
|
15
|
+
<React.StrictMode>
|
|
16
|
+
<App />
|
|
17
|
+
</React.StrictMode>,
|
|
18
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
// Custom env keys exposed via `define` in vite.config.ts. `tsc` trusts THIS
|
|
4
|
+
// declaration (it does not evaluate `define`), so the type must match the
|
|
5
|
+
// runtime value the config injects (a boolean).
|
|
6
|
+
interface ImportMetaEnv {
|
|
7
|
+
/** True when DYNAMICS_URL is configured (token-proxy dev mode). */
|
|
8
|
+
readonly VITE_USE_PROXY: boolean;
|
|
9
|
+
/** The org URL (not the token) for the dev panel readout; "" in production. */
|
|
10
|
+
readonly VITE_DYNAMICS_URL: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ImportMeta {
|
|
14
|
+
readonly env: ImportMetaEnv;
|
|
15
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build + deploy: upload the single-file `dist/index.html` (from `npm run
|
|
4
|
+
* build:d365`) to Dataverse as an HTML web resource, then publish it.
|
|
5
|
+
*
|
|
6
|
+
* Reads DYNAMICS_URL + DYNAMICS_TOKEN from `.env` (run `npm run auth:token`
|
|
7
|
+
* first, or use `npm run deploy`, which refreshes the token for you).
|
|
8
|
+
*
|
|
9
|
+
* The web-resource unique name MUST start with a publisher prefix that exists in
|
|
10
|
+
* your org (e.g. `cr1a2_` or `new_`). Set it via WEBRESOURCE_NAME in `.env`, or:
|
|
11
|
+
* node tools/deploy/deploy-webresource.cjs --name cr1a2_/myapp/index.html --solution MySolution
|
|
12
|
+
*/
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
|
|
16
|
+
function arg(name) {
|
|
17
|
+
const i = process.argv.indexOf(name);
|
|
18
|
+
return i !== -1 ? process.argv[i + 1] : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readEnv(file) {
|
|
22
|
+
if (!fs.existsSync(file)) return {};
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
25
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
26
|
+
if (m) out[m[1]] = m[2];
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const root = process.cwd();
|
|
32
|
+
const env = readEnv(path.join(root, ".env"));
|
|
33
|
+
const url = (arg("--url") || process.env.DYNAMICS_URL || env.DYNAMICS_URL || "").replace(/\/+$/, "");
|
|
34
|
+
const token = arg("--token") || process.env.DYNAMICS_TOKEN || env.DYNAMICS_TOKEN;
|
|
35
|
+
|
|
36
|
+
let pkgName = "custompage";
|
|
37
|
+
try {
|
|
38
|
+
pkgName = require(path.join(root, "package.json")).name || pkgName;
|
|
39
|
+
} catch {
|
|
40
|
+
/* keep default */
|
|
41
|
+
}
|
|
42
|
+
const name = arg("--name") || process.env.WEBRESOURCE_NAME || env.WEBRESOURCE_NAME || `new_/${pkgName}/index.html`;
|
|
43
|
+
const solution = arg("--solution") || process.env.WEBRESOURCE_SOLUTION || env.WEBRESOURCE_SOLUTION;
|
|
44
|
+
|
|
45
|
+
if (!url || !token) {
|
|
46
|
+
console.error("✗ Missing DYNAMICS_URL / DYNAMICS_TOKEN. Run `npm run auth:token -- --url https://<org>.crm.dynamics.com` first.");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const file = path.join(root, "dist", "index.html");
|
|
51
|
+
if (!fs.existsSync(file)) {
|
|
52
|
+
console.error("✗ dist/index.html not found. Run `npm run build:d365` first.");
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
const content = fs.readFileSync(file).toString("base64");
|
|
56
|
+
|
|
57
|
+
const api = `${url}/api/data/v9.2`;
|
|
58
|
+
const headers = {
|
|
59
|
+
Authorization: `Bearer ${token}`,
|
|
60
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
61
|
+
Accept: "application/json",
|
|
62
|
+
"OData-Version": "4.0",
|
|
63
|
+
"OData-MaxVersion": "4.0",
|
|
64
|
+
};
|
|
65
|
+
if (solution) headers["MSCRM.SolutionUniqueName"] = solution;
|
|
66
|
+
|
|
67
|
+
async function req(method, urlPath, body) {
|
|
68
|
+
const res = await fetch(urlPath.startsWith("http") ? urlPath : `${api}/${urlPath}`, {
|
|
69
|
+
method,
|
|
70
|
+
headers,
|
|
71
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const text = await res.text().catch(() => "");
|
|
75
|
+
throw new Error(`${method} ${urlPath} → ${res.status}: ${text}`);
|
|
76
|
+
}
|
|
77
|
+
return res;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function main() {
|
|
81
|
+
console.log(`→ Deploying "${name}" to ${url} ...`);
|
|
82
|
+
|
|
83
|
+
// 1. Upsert the web resource (find by unique name, then PATCH or POST).
|
|
84
|
+
const findRes = await req("GET", `webresourceset?$select=webresourceid&$filter=name eq '${encodeURIComponent(name)}'`);
|
|
85
|
+
const found = (await findRes.json()).value || [];
|
|
86
|
+
let id;
|
|
87
|
+
if (found.length) {
|
|
88
|
+
id = found[0].webresourceid;
|
|
89
|
+
await req("PATCH", `webresourceset(${id})`, { content });
|
|
90
|
+
console.log(`✓ Updated existing web resource (${id}).`);
|
|
91
|
+
} else {
|
|
92
|
+
const createRes = await req("POST", "webresourceset", {
|
|
93
|
+
name,
|
|
94
|
+
displayname: name,
|
|
95
|
+
webresourcetype: 1, // HTML
|
|
96
|
+
content,
|
|
97
|
+
});
|
|
98
|
+
const loc = createRes.headers.get("OData-EntityId") || "";
|
|
99
|
+
const match = /\(([^)]+)\)/.exec(loc);
|
|
100
|
+
id = match ? match[1] : null;
|
|
101
|
+
console.log(`✓ Created web resource (${id}).`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. Publish it.
|
|
105
|
+
const paramXml =
|
|
106
|
+
"<importexportxml><webresources><webresource>{" + id + "}</webresource></webresources></importexportxml>";
|
|
107
|
+
await req("POST", "PublishXml", { ParameterXml: paramXml });
|
|
108
|
+
console.log("✓ Published.");
|
|
109
|
+
|
|
110
|
+
console.log(`\nOpen it: ${url}/WebResources/${name}`);
|
|
111
|
+
console.log("Or host it on a model-driven custom page / open via Xrm.Navigation.navigateTo.");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
main().catch((e) => {
|
|
115
|
+
console.error("✗ Deploy failed:", e.message);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"resolveJsonModule": true,
|
|
9
|
+
"isolatedModules": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"strict": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
|
+
"types": ["vite/client", "vitest/globals"]
|
|
17
|
+
},
|
|
18
|
+
"include": ["src"]
|
|
19
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/// <reference types="vitest/config" />
|
|
2
|
+
import { defineConfig, loadEnv } from "vite";
|
|
3
|
+
import react from "@vitejs/plugin-react";
|
|
4
|
+
import { viteSingleFile } from "vite-plugin-singlefile";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read a single key from .env / .env.local at request time. Re-reading per
|
|
10
|
+
* request (rather than capturing it at config eval) means a refreshed
|
|
11
|
+
* `npm run auth:token` takes effect WITHOUT restarting the dev server.
|
|
12
|
+
*/
|
|
13
|
+
function readEnvValue(key: string): string | undefined {
|
|
14
|
+
for (const file of [".env.local", ".env"]) {
|
|
15
|
+
const p = path.resolve(process.cwd(), file);
|
|
16
|
+
if (!fs.existsSync(p)) continue;
|
|
17
|
+
for (const line of fs.readFileSync(p, "utf8").split(/\r?\n/)) {
|
|
18
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
19
|
+
if (m && m[1] === key) return m[2];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default defineConfig(({ mode }) => {
|
|
26
|
+
// DYNAMICS_URL / DYNAMICS_TOKEN are written to .env by `npm run auth:token`.
|
|
27
|
+
// Empty prefix loads unprefixed keys; we read ONLY the two below by name and
|
|
28
|
+
// never spread `env` into `define` (that would leak the whole environment).
|
|
29
|
+
const env = loadEnv(mode, process.cwd(), "");
|
|
30
|
+
const dynamicsUrl = env.DYNAMICS_URL;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
// Relative base — the built bundle is served from the Dataverse web-resource
|
|
34
|
+
// CDN under a long prefixed path, where absolute /assets URLs would 404.
|
|
35
|
+
base: "./",
|
|
36
|
+
plugins: [react(), ...(mode === "d365" ? [viteSingleFile()] : [])],
|
|
37
|
+
server: {
|
|
38
|
+
port: 3000,
|
|
39
|
+
// Token-proxy mode: forward /api/data/* to Dataverse and inject the bearer
|
|
40
|
+
// SERVER-SIDE, so the token is never read by or bundled into the client.
|
|
41
|
+
proxy: dynamicsUrl
|
|
42
|
+
? {
|
|
43
|
+
"/api/data": {
|
|
44
|
+
target: dynamicsUrl,
|
|
45
|
+
changeOrigin: true,
|
|
46
|
+
secure: true,
|
|
47
|
+
configure: (proxy) => {
|
|
48
|
+
proxy.on("proxyReq", (proxyReq) => {
|
|
49
|
+
const token = readEnvValue("DYNAMICS_TOKEN");
|
|
50
|
+
if (token) proxyReq.setHeader("Authorization", `Bearer ${token}`);
|
|
51
|
+
proxyReq.setHeader("OData-Version", "4.0");
|
|
52
|
+
proxyReq.setHeader("OData-MaxVersion", "4.0");
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
: undefined,
|
|
58
|
+
},
|
|
59
|
+
define: {
|
|
60
|
+
// Client flag for ServiceFactory — a real boolean (Vite stringifies it).
|
|
61
|
+
// True only when a Dataverse URL is configured (token-proxy dev mode).
|
|
62
|
+
"import.meta.env.VITE_USE_PROXY": Boolean(dynamicsUrl),
|
|
63
|
+
// The org URL (NOT the token) for the dev panel's Environment readout. Empty
|
|
64
|
+
// in a production build, so nothing leaks into a deployed web resource.
|
|
65
|
+
"import.meta.env.VITE_DYNAMICS_URL": JSON.stringify(dynamicsUrl ?? ""),
|
|
66
|
+
},
|
|
67
|
+
build: {
|
|
68
|
+
outDir: "dist",
|
|
69
|
+
},
|
|
70
|
+
test: {
|
|
71
|
+
// Pure layers only (diff, mappers) — no DOM needed.
|
|
72
|
+
globals: true,
|
|
73
|
+
include: ["src/**/*.test.ts"],
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
});
|
|
@@ -7,6 +7,8 @@ export interface ValueInputProps {
|
|
|
7
7
|
disabled?: boolean;
|
|
8
8
|
onChange: (value: boolean) => void;
|
|
9
9
|
onBlur: () => void;
|
|
10
|
+
/** Optional PCF context (forwarded uniformly; only the optionset variant reads it). */
|
|
11
|
+
context?: ComponentFramework.Context<any>;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export const ValueInput: React.FC<ValueInputProps> = ({
|
|
@@ -7,6 +7,8 @@ export interface ValueInputProps {
|
|
|
7
7
|
disabled?: boolean;
|
|
8
8
|
onChange: (value: Date | null) => void;
|
|
9
9
|
onBlur: () => void;
|
|
10
|
+
/** Optional PCF context (forwarded uniformly; only the optionset variant reads it). */
|
|
11
|
+
context?: ComponentFramework.Context<any>;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export const ValueInput: React.FC<ValueInputProps> = ({
|