@xrmforge/devkit 0.5.7 → 0.7.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.
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import type { Logger } from './logger.js';
6
6
  import { NOTIFICATION_IDS } from './constants.js';
7
+ import { FormNotificationLevel } from '@xrmforge/helpers';
7
8
 
8
9
  type EventHandler = (ctx: Xrm.Events.EventContext, ...args: never[]) => unknown;
9
10
 
@@ -73,7 +74,7 @@ function logAndNotify(
73
74
  const message = err instanceof Error ? err.message : String(err);
74
75
  logger.error(`${name} failed`, { err });
75
76
  try {
76
- ctx.getFormContext().ui.setFormNotification(message, 'ERROR', NOTIFICATION_IDS.genericError);
77
+ ctx.getFormContext().ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
77
78
  } catch {
78
79
  /* ignore */
79
80
  }
@@ -88,7 +89,7 @@ function logAndNotifyForm(
88
89
  const message = err instanceof Error ? err.message : String(err);
89
90
  logger.error(`${name} failed`, { err });
90
91
  try {
91
- formContext.ui.setFormNotification(message, 'ERROR', NOTIFICATION_IDS.genericError);
92
+ formContext.ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
92
93
  } catch {
93
94
  /* ignore */
94
95
  }
@@ -1,19 +1,34 @@
1
- import { describe, it, expect } from 'vitest';
2
-
3
- /**
4
- * Example test for the form script.
5
- *
6
- * Uses @xrmforge/testing for type-safe mocking once you have
7
- * generated types. For now, this is a placeholder.
8
- */
9
- describe('{{namespace}}.Example', () => {
10
- it('should export onLoad function', async () => {
11
- const mod = await import('../../src/forms/example-form.js');
12
- expect(typeof mod.onLoad).toBe('function');
13
- });
14
-
15
- it('should export onSave function', async () => {
16
- const mod = await import('../../src/forms/example-form.js');
17
- expect(typeof mod.onSave).toBe('function');
18
- });
19
- });
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ /**
4
+ * Example test for the form script.
5
+ *
6
+ * Uses @xrmforge/testing for type-safe mocking once you have
7
+ * generated types. Replace with real form tests after 'xrmforge generate'.
8
+ */
9
+ describe('{{namespace}}.Example', () => {
10
+ it('should export onLoad handler', async () => {
11
+ const mod = await import('../../src/forms/example-form.js');
12
+ expect(typeof mod.onLoad).toBe('function');
13
+ });
14
+
15
+ it('should export onSave handler', async () => {
16
+ const mod = await import('../../src/forms/example-form.js');
17
+ expect(typeof mod.onSave).toBe('function');
18
+ });
19
+
20
+ // TODO: After running 'xrmforge generate', add real form tests:
21
+ //
22
+ // import { createFormMock } from '@xrmforge/testing';
23
+ // import type { ExampleFormMockValues } from '../../generated/entities/Example.js';
24
+ //
25
+ // it('should show notification on load', () => {
26
+ // const { executionContext, formContext } = createFormMock<ExampleFormMockValues>({
27
+ // name: 'Contoso Ltd',
28
+ // });
29
+ // onLoad(executionContext);
30
+ // expect(formContext.ui.setFormNotification).toHaveBeenCalledWith(
31
+ // 'Form loaded successfully', 'INFO', 'example-load-notification'
32
+ // );
33
+ // });
34
+ });
@@ -1,40 +1,76 @@
1
- /**
2
- * Example Form Script for Dynamics 365.
3
- *
4
- * Register in D365 as: {{namespace}}.Example.onLoad
5
- *
6
- * Replace this with your actual form logic.
7
- */
8
-
9
- /**
10
- * Called when the form loads.
11
- */
12
- export function onLoad(executionContext: Xrm.Events.EventContext): void {
13
- const formContext = executionContext.getFormContext();
14
-
15
- // Example: show a notification on the form
16
- formContext.ui.setFormNotification(
17
- 'Form loaded successfully',
18
- 'INFO',
19
- 'example-notification',
20
- );
21
-
22
- // Example: read a field value
23
- // TODO: Replace with generated Fields enum after running 'xrmforge generate'
24
- // Example: formContext.getAttribute(Fields.Name)
25
- const nameAttr = formContext.getAttribute('name');
26
- if (nameAttr) {
27
- const value = nameAttr.getValue();
28
- console.log('Name field value:', value);
29
- }
30
- }
31
-
32
- /**
33
- * Called when the form is saved.
34
- */
35
- export function onSave(executionContext: Xrm.Events.EventContext): void {
36
- const formContext = executionContext.getFormContext();
37
-
38
- // Clear the notification on save
39
- formContext.ui.clearFormNotification('example-notification');
40
- }
1
+ /**
2
+ * Example Form Script for Dynamics 365 using XrmForge best practices.
3
+ *
4
+ * Register in D365 as: {{namespace}}.Example.onLoad
5
+ *
6
+ * This template demonstrates the correct patterns:
7
+ * - typedForm() for direct typed field access
8
+ * - Fields Enum for addOnChange and $control
9
+ * - Entity-level Fields for Web API select() queries
10
+ * - wrapHandler for unified error handling
11
+ * - Logger instead of console.log
12
+ * - FormNotificationLevel constant instead of raw string
13
+ * - pickLang() for localized UI strings
14
+ *
15
+ * Replace this file with your actual form logic after running 'xrmforge generate'.
16
+ */
17
+ import { createLogger } from '../shared/logger.js';
18
+ import { wrapHandler } from '../shared/error-handler.js';
19
+ import { MESSAGES, pickLang } from '../shared/constants.js';
20
+ import { typedForm, FormNotificationLevel, select, formLookupId } from '@xrmforge/helpers';
21
+ // TODO: After 'xrmforge generate', replace with your actual imports:
22
+ // import type { ExampleForm } from '../../generated/forms/example.js';
23
+ // import { ExampleFormFieldsEnum as Fields } from '../../generated/forms/example.js';
24
+ // import { ExampleFields } from '../../generated/fields/example.js';
25
+ // import { EntityNames } from '../../generated/entity-names.js';
26
+
27
+ const logger = createLogger('{{namespace}}.Example');
28
+
29
+ /**
30
+ * Called when the form loads.
31
+ */
32
+ export const onLoad = wrapHandler('{{namespace}}.Example.onLoad', logger, async (ctx) => {
33
+ // TODO: Replace 'Xrm.FormContext' with your generated form type:
34
+ // const form = typedForm<ExampleForm>(ctx.getFormContext());
35
+ const form = typedForm<Xrm.FormContext>(ctx.getFormContext());
36
+
37
+ // Direct field access via typedForm proxy (fully typed):
38
+ // const name = form.name.getValue(); // string | null
39
+ // form.revenue.setValue(150000); // NumberAttribute
40
+
41
+ // addOnChange uses Fields Enum via $context:
42
+ // form.$context.getAttribute(Fields.Name).addOnChange(() => {
43
+ // logger.debug('Name changed', { value: form.name.getValue() });
44
+ // });
45
+
46
+ // Web API queries use entity-level Fields:
47
+ // const result = await Xrm.WebApi.retrieveRecord(
48
+ // EntityNames.Account, id,
49
+ // select(ExampleFields.Name, ExampleFields.WebsiteUrl)
50
+ // );
51
+
52
+ // Lookup access:
53
+ // const parentId = formLookupId(form.parentaccountid);
54
+
55
+ // Localized UI strings:
56
+ const lang = pickLang(
57
+ Xrm.Utility.getGlobalContext().userSettings.languageId,
58
+ MESSAGES,
59
+ );
60
+ logger.info('Form loaded', { language: lang });
61
+
62
+ // Form notifications use constants:
63
+ form.$context.ui.setFormNotification(
64
+ 'Form loaded',
65
+ FormNotificationLevel.Info,
66
+ 'example-load',
67
+ );
68
+ });
69
+
70
+ /**
71
+ * Called when the form is saved.
72
+ */
73
+ export const onSave = wrapHandler('{{namespace}}.Example.onSave', logger, (ctx) => {
74
+ const form = typedForm<Xrm.FormContext>(ctx.getFormContext());
75
+ form.$context.ui.clearFormNotification('example-load');
76
+ });
@@ -0,0 +1,263 @@
1
+ /**
2
+ * validate-form.mjs - Quality Gate for XrmForge Form Scripts
3
+ *
4
+ * Runs three checks in one command:
5
+ * 1. TypeScript Compiler (tsc --noEmit)
6
+ * 2. ESLint (src/ --max-warnings=0)
7
+ * 3. Pattern Compliance (critical rule violations via text search)
8
+ *
9
+ * Exit code: 0 = all green, 1 = errors found
10
+ *
11
+ * Usage:
12
+ * node scripts/validate-form.mjs
13
+ * npm run validate
14
+ */
15
+
16
+ import { execSync } from 'node:child_process';
17
+ import { readdirSync, readFileSync } from 'node:fs';
18
+ import { join, relative } from 'node:path';
19
+
20
+ const RED = '\x1b[31m';
21
+ const GREEN = '\x1b[32m';
22
+ const NC = '\x1b[0m';
23
+
24
+ let totalErrors = 0;
25
+
26
+ // ============================================================
27
+ // 1. TypeScript Compiler
28
+ // ============================================================
29
+
30
+ console.log('=== XrmForge Quality Gate ===\n');
31
+ console.log('--- TypeScript ---');
32
+
33
+ try {
34
+ execSync('npx tsc --noEmit', { stdio: 'pipe', encoding: 'utf-8' });
35
+ console.log(`${GREEN}OK${NC} tsc --noEmit`);
36
+ } catch (err) {
37
+ const output = (err.stdout || '') + (err.stderr || '');
38
+ const errorCount = (output.match(/error TS/g) || []).length;
39
+ console.log(`${RED}FAIL${NC} tsc --noEmit (${errorCount} errors)`);
40
+ console.log(output.split('\n').slice(0, 20).join('\n'));
41
+ totalErrors += errorCount || 1;
42
+ }
43
+
44
+ // ============================================================
45
+ // 2. ESLint
46
+ // ============================================================
47
+
48
+ console.log('\n--- ESLint ---');
49
+
50
+ try {
51
+ execSync('npx eslint src/ --max-warnings=0', { stdio: 'pipe', encoding: 'utf-8' });
52
+ console.log(`${GREEN}OK${NC} eslint src/ --max-warnings=0`);
53
+ } catch (err) {
54
+ const output = (err.stdout || '') + (err.stderr || '');
55
+ const problemMatch = output.match(/(\d+) problems?/);
56
+ const count = problemMatch ? problemMatch[1] : '?';
57
+ console.log(`${RED}FAIL${NC} eslint (${count} problems)`);
58
+ console.log(output.split('\n').slice(0, 20).join('\n'));
59
+ totalErrors += parseInt(count) || 1;
60
+ }
61
+
62
+ // ============================================================
63
+ // 3. Pattern Compliance (critical rules)
64
+ // ============================================================
65
+
66
+ console.log('\n--- Pattern Compliance ---');
67
+
68
+ /**
69
+ * Collect all .ts files recursively from a directory.
70
+ */
71
+ function collectTsFiles(dir) {
72
+ const results = [];
73
+ try {
74
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
75
+ const fullPath = join(dir, entry.name);
76
+ if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== 'dist') {
77
+ results.push(...collectTsFiles(fullPath));
78
+ } else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
79
+ results.push(fullPath);
80
+ }
81
+ }
82
+ } catch {
83
+ // Directory does not exist
84
+ }
85
+ return results;
86
+ }
87
+
88
+ /**
89
+ * Check a pattern rule against all files.
90
+ * @returns Number of violations
91
+ */
92
+ function checkPattern(label, files, regex, excludeFiles = []) {
93
+ const violations = [];
94
+ for (const file of files) {
95
+ const relPath = relative(process.cwd(), file);
96
+ if (excludeFiles.some((ex) => relPath.includes(ex))) continue;
97
+
98
+ const lines = readFileSync(file, 'utf-8').split('\n');
99
+ for (let i = 0; i < lines.length; i++) {
100
+ const line = lines[i];
101
+ const trimmed = line.trim();
102
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
103
+ if (regex.test(line)) {
104
+ violations.push(` ${relPath}:${i + 1}: ${trimmed}`);
105
+ }
106
+ }
107
+ }
108
+
109
+ if (violations.length === 0) {
110
+ console.log(`${GREEN}OK${NC} [0] ${label}`);
111
+ } else {
112
+ console.log(`${RED}FAIL${NC} [${violations.length}] ${label}`);
113
+ violations.slice(0, 10).forEach((v) => console.log(v));
114
+ if (violations.length > 10) {
115
+ console.log(` ... and ${violations.length - 10} more`);
116
+ }
117
+ totalErrors += violations.length;
118
+ }
119
+ return violations.length;
120
+ }
121
+
122
+ const formFiles = collectTsFiles('src/forms');
123
+ const allSrcFiles = collectTsFiles('src');
124
+
125
+ // ── Field Access ─────────────────────────────────────────────────────────────
126
+
127
+ // 3a. Raw strings in getAttribute/getControl (must use Fields Enum)
128
+ checkPattern(
129
+ 'Raw field strings in getAttribute/getControl',
130
+ formFiles,
131
+ /(?:getAttribute|getControl)\s*\(\s*['"][a-z]/,
132
+ );
133
+
134
+ // 3b. Raw strings in helper wrappers (getValue, setFieldValue, setDisabled, addOnChange, setVisible, setRequiredLevel)
135
+ checkPattern(
136
+ 'Raw field strings in helper functions (use typedForm or Fields Enum)',
137
+ allSrcFiles,
138
+ /(?:getValue|setFieldValue|setDisabled|addOnChange|setVisible|setRequiredLevel|addPreSearch)\s*\(\s*\w+\s*,\s*['"][a-z]/,
139
+ ['generated/', 'logger.ts'],
140
+ );
141
+
142
+ // 3c. Raw strings in select() (must use entity-level Fields Enum)
143
+ checkPattern(
144
+ 'Raw field strings in select() (use entity-level Fields Enum)',
145
+ allSrcFiles,
146
+ /\bselect\s*\(\s*['"][a-z]/,
147
+ ['generated/'],
148
+ );
149
+
150
+ // ── Entity Names ─────────────────────────────────────────────────────────────
151
+
152
+ // 3d. Raw entity name strings in WebApi calls (must use EntityNames Enum)
153
+ checkPattern(
154
+ 'Raw entity names in WebApi (must use EntityNames Enum)',
155
+ allSrcFiles,
156
+ /(?:retrieveRecord|retrieveMultipleRecords|createRecord|updateRecord|deleteRecord)\s*\(\s*['"]/,
157
+ ['generated/'],
158
+ );
159
+
160
+ // 3e. SystemEntities workaround (must extend generation instead)
161
+ checkPattern(
162
+ 'SystemEntities workaround (extend generation with --entities instead)',
163
+ allSrcFiles,
164
+ /SystemEntities\./,
165
+ );
166
+
167
+ // ── Magic Values ─────────────────────────────────────────────────────────────
168
+
169
+ // 3f. Magic numbers in OptionSet comparisons (must use OptionSet Enum)
170
+ checkPattern(
171
+ 'Magic numbers in value comparisons (use OptionSet Enum)',
172
+ allSrcFiles,
173
+ /getValue\(\)\s*===?\s*\d{3,}/,
174
+ ['generated/'],
175
+ );
176
+
177
+ // 3g. Magic number 86400000 (must use named constant MS_PER_DAY)
178
+ checkPattern(
179
+ 'Magic number 86400000 (use named constant MS_PER_DAY)',
180
+ allSrcFiles,
181
+ /86400000/,
182
+ );
183
+
184
+ // 3h. Raw notification level strings (must use FormNotificationLevel)
185
+ checkPattern(
186
+ 'Raw notification level strings (use FormNotificationLevel from @xrmforge/helpers)',
187
+ allSrcFiles,
188
+ /setFormNotification\s*\([^)]*['"](?:ERROR|WARNING|INFO)['"]/,
189
+ );
190
+
191
+ // ── Deprecated / Unsafe ──────────────────────────────────────────────────────
192
+
193
+ // 3i. Xrm.Page (deprecated since D365 v9.0)
194
+ checkPattern(
195
+ 'Xrm.Page (deprecated since D365 v9.0)',
196
+ allSrcFiles,
197
+ /\bXrm\.Page\b/,
198
+ );
199
+
200
+ // 3j. eval() usage
201
+ checkPattern(
202
+ 'eval() usage (use Number() or JSON.parse())',
203
+ allSrcFiles,
204
+ /\beval\s*\(/,
205
+ );
206
+
207
+ // 3k. console.log/warn/error outside logger.ts (must use Logger)
208
+ checkPattern(
209
+ 'console.* outside logger.ts (must use Logger)',
210
+ allSrcFiles,
211
+ /\bconsole\.(log|warn|error|info|debug)\b/,
212
+ ['logger.ts'],
213
+ );
214
+
215
+ // ── Handler Pattern ──────────────────────────────────────────────────────────
216
+
217
+ // 3l. Exported handlers without wrapHandler
218
+ checkPattern(
219
+ 'Exported handlers without wrapHandler',
220
+ formFiles,
221
+ /^export\s+(const|async\s+function|function)\s+\w+(?!.*wrapHandler)/,
222
+ );
223
+
224
+ // ── Raw $select ──────────────────────────────────────────────────────────────
225
+
226
+ // 3m. Raw $select strings (must use select() from @xrmforge/helpers)
227
+ checkPattern(
228
+ 'Raw $select strings (must use select() from @xrmforge/helpers)',
229
+ allSrcFiles,
230
+ /['"]\?\$select=/,
231
+ ['generated/'],
232
+ );
233
+
234
+ // ── FetchXML ─────────────────────────────────────────────────────────────────
235
+
236
+ // 3n. Raw field names in FetchXML attribute= (should use Fields Enum interpolation)
237
+ checkPattern(
238
+ 'Raw field names in FetchXML (use Fields Enum interpolation)',
239
+ allSrcFiles,
240
+ /attribute\s*=\s*'[a-z][a-z0-9_]+'/,
241
+ ['generated/'],
242
+ );
243
+
244
+ // 3o. Magic numbers in FetchXML <value> (should use OptionSet Enum)
245
+ checkPattern(
246
+ 'Magic numbers in FetchXML values (use OptionSet Enum)',
247
+ allSrcFiles,
248
+ /<value>\d{3,}<\/value>/,
249
+ ['generated/'],
250
+ );
251
+
252
+ // ============================================================
253
+ // Result
254
+ // ============================================================
255
+
256
+ console.log('\n=== Result ===');
257
+ if (totalErrors === 0) {
258
+ console.log(`${GREEN}All checks passed. 0 violations.${NC}`);
259
+ process.exit(0);
260
+ } else {
261
+ console.log(`${RED}${totalErrors} violations found.${NC}`);
262
+ process.exit(1);
263
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xrmforge/devkit",
3
- "version": "0.5.7",
3
+ "version": "0.7.0",
4
4
  "description": "Build orchestration and project tooling for Dynamics 365 WebResources",
5
5
  "keywords": [
6
6
  "dynamics-365",