@xrmforge/devkit 0.5.2 → 0.5.4

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.
@@ -1,32 +1,32 @@
1
- trigger:
2
- branches:
3
- include:
4
- - main
5
-
6
- pool:
7
- vmImage: 'ubuntu-latest'
8
-
9
- steps:
10
- - task: NodeTool@0
11
- inputs:
12
- versionSpec: '20.x'
13
- displayName: 'Install Node.js'
14
-
15
- - script: npm ci
16
- displayName: 'Install dependencies'
17
-
18
- - script: npx xrmforge generate --from-config
19
- displayName: 'Generate types from Dataverse'
20
- env:
21
- XRMFORGE_CLIENT_ID: $(XRMFORGE_CLIENT_ID)
22
- XRMFORGE_CLIENT_SECRET: $(XRMFORGE_CLIENT_SECRET)
23
- XRMFORGE_TENANT_ID: $(XRMFORGE_TENANT_ID)
24
-
25
- - script: npx tsc --noEmit
26
- displayName: 'Type check'
27
-
28
- - script: npx vitest run
29
- displayName: 'Test'
30
-
31
- - script: npx xrmforge build
32
- displayName: 'Build WebResources'
1
+ trigger:
2
+ branches:
3
+ include:
4
+ - main
5
+
6
+ pool:
7
+ vmImage: 'ubuntu-latest'
8
+
9
+ steps:
10
+ - task: NodeTool@0
11
+ inputs:
12
+ versionSpec: '20.x'
13
+ displayName: 'Install Node.js'
14
+
15
+ - script: npm ci
16
+ displayName: 'Install dependencies'
17
+
18
+ - script: npx xrmforge generate --from-config
19
+ displayName: 'Generate types from Dataverse'
20
+ env:
21
+ XRMFORGE_CLIENT_ID: $(XRMFORGE_CLIENT_ID)
22
+ XRMFORGE_CLIENT_SECRET: $(XRMFORGE_CLIENT_SECRET)
23
+ XRMFORGE_TENANT_ID: $(XRMFORGE_TENANT_ID)
24
+
25
+ - script: npx tsc --noEmit
26
+ displayName: 'Type check'
27
+
28
+ - script: npx vitest run
29
+ displayName: 'Test'
30
+
31
+ - script: npx xrmforge build
32
+ displayName: 'Build WebResources'
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Central constants for notifications and messages.
3
+ */
4
+
5
+ /** Unique IDs for form-level notifications. */
6
+ export const NOTIFICATION_IDS = {
7
+ genericError: '{{namespace}}.notification.generic-error'.toLowerCase(),
8
+ } as const;
9
+
10
+ /** Localized message strings (extend as needed). */
11
+ export const MESSAGES = {
12
+ de: {
13
+ unsavedRecord: 'Der Datensatz muss zuerst gespeichert werden.',
14
+ },
15
+ en: {
16
+ unsavedRecord: 'The record must be saved first.',
17
+ },
18
+ } as const;
19
+
20
+ /**
21
+ * Pick the correct language table based on the user's D365 language setting.
22
+ *
23
+ * @param languageId - LCID from Xrm.Utility.getGlobalContext().userSettings.languageId
24
+ * @param table - Object with 'de' and 'en' keys containing the same message keys
25
+ * @returns The matching language table (defaults to English)
26
+ */
27
+ export function pickLang<K extends string>(
28
+ languageId: number,
29
+ table: { de: Record<K, string>; en: Record<K, string> },
30
+ ): Record<K, string> {
31
+ return languageId === 1031 ? table.de : table.en;
32
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Unified error handling for D365 form event handlers.
3
+ * Wraps sync and async handlers with try/catch and form notifications.
4
+ */
5
+ import type { Logger } from './logger.js';
6
+ import { NOTIFICATION_IDS } from './constants.js';
7
+
8
+ type EventHandler = (ctx: Xrm.Events.EventContext, ...args: never[]) => unknown;
9
+
10
+ /**
11
+ * Wrap a form event handler with error handling.
12
+ *
13
+ * Catches both sync and async errors, logs them, and shows a form notification.
14
+ * The original handler is never rethrown, so form execution continues.
15
+ *
16
+ * @param name - Handler name for logging (e.g. 'MyApp.Account.onLoad')
17
+ * @param logger - Logger instance for error reporting
18
+ * @param handler - The actual event handler function
19
+ */
20
+ export function wrapHandler(name: string, logger: Logger, handler: EventHandler): EventHandler {
21
+ const wrapped: EventHandler = (ctx, ...args) => {
22
+ try {
23
+ const result = handler(ctx, ...args);
24
+ if (result && typeof (result as Promise<unknown>).then === 'function') {
25
+ return (result as Promise<unknown>).catch((err: unknown) => {
26
+ logAndNotify(ctx, name, logger, err);
27
+ });
28
+ }
29
+ return result;
30
+ } catch (err: unknown) {
31
+ logAndNotify(ctx, name, logger, err);
32
+ }
33
+ };
34
+ return wrapped;
35
+ }
36
+
37
+ /**
38
+ * Wrap a ribbon command handler with error handling.
39
+ *
40
+ * Unlike wrapHandler, this accepts a FormContext directly (not an EventContext),
41
+ * which is the calling convention for ribbon/command bar handlers.
42
+ *
43
+ * @param name - Handler name for logging
44
+ * @param logger - Logger instance for error reporting
45
+ * @param handler - The actual command handler function
46
+ */
47
+ export function wrapCommand(
48
+ name: string,
49
+ logger: Logger,
50
+ handler: (formContext: Xrm.FormContext, ...args: never[]) => unknown,
51
+ ): (formContext: Xrm.FormContext, ...args: never[]) => unknown {
52
+ return (formContext, ...args) => {
53
+ try {
54
+ const result = handler(formContext, ...args);
55
+ if (result && typeof (result as Promise<unknown>).then === 'function') {
56
+ return (result as Promise<unknown>).catch((err: unknown) => {
57
+ logAndNotifyForm(formContext, name, logger, err);
58
+ });
59
+ }
60
+ return result;
61
+ } catch (err: unknown) {
62
+ logAndNotifyForm(formContext, name, logger, err);
63
+ }
64
+ };
65
+ }
66
+
67
+ function logAndNotify(
68
+ ctx: Xrm.Events.EventContext,
69
+ name: string,
70
+ logger: Logger,
71
+ err: unknown,
72
+ ): void {
73
+ const message = err instanceof Error ? err.message : String(err);
74
+ logger.error(`${name} failed`, { err });
75
+ try {
76
+ ctx.getFormContext().ui.setFormNotification(message, 'ERROR', NOTIFICATION_IDS.genericError);
77
+ } catch {
78
+ /* ignore */
79
+ }
80
+ }
81
+
82
+ function logAndNotifyForm(
83
+ formContext: Xrm.FormContext,
84
+ name: string,
85
+ logger: Logger,
86
+ err: unknown,
87
+ ): void {
88
+ const message = err instanceof Error ? err.message : String(err);
89
+ logger.error(`${name} failed`, { err });
90
+ try {
91
+ formContext.ui.setFormNotification(message, 'ERROR', NOTIFICATION_IDS.genericError);
92
+ } catch {
93
+ /* ignore */
94
+ }
95
+ }
@@ -0,0 +1,21 @@
1
+ import tseslint from '@typescript-eslint/eslint-plugin';
2
+ import tsparser from '@typescript-eslint/parser';
3
+ import xrmforge from '@xrmforge/eslint-plugin';
4
+
5
+ export default [
6
+ {
7
+ files: ['src/**/*.ts'],
8
+ languageOptions: {
9
+ parser: tsparser,
10
+ parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
11
+ },
12
+ plugins: { '@typescript-eslint': tseslint },
13
+ rules: {
14
+ '@typescript-eslint/no-explicit-any': 'error',
15
+ '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
16
+ },
17
+ },
18
+ xrmforge.configs.recommended,
19
+ { rules: { 'no-console': ['error'] } },
20
+ { files: ['src/shared/logger.ts'], rules: { 'no-console': 'off' } },
21
+ ];
@@ -1,19 +1,19 @@
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 } 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,40 +1,40 @@
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.
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,36 +1,36 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
-
9
- jobs:
10
- build:
11
- runs-on: ubuntu-latest
12
-
13
- steps:
14
- - uses: actions/checkout@v4
15
-
16
- - uses: actions/setup-node@v4
17
- with:
18
- node-version: 20
19
-
20
- - run: npm ci
21
-
22
- - name: Generate types from Dataverse
23
- run: npx xrmforge generate --from-config
24
- env:
25
- XRMFORGE_CLIENT_ID: ${{ secrets.XRMFORGE_CLIENT_ID }}
26
- XRMFORGE_CLIENT_SECRET: ${{ secrets.XRMFORGE_CLIENT_SECRET }}
27
- XRMFORGE_TENANT_ID: ${{ secrets.XRMFORGE_TENANT_ID }}
28
-
29
- - name: Type check
30
- run: npx tsc --noEmit
31
-
32
- - name: Test
33
- run: npx vitest run
34
-
35
- - name: Build WebResources
36
- run: npx xrmforge build
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-node@v4
17
+ with:
18
+ node-version: 20
19
+
20
+ - run: npm ci
21
+
22
+ - name: Generate types from Dataverse
23
+ run: npx xrmforge generate --from-config
24
+ env:
25
+ XRMFORGE_CLIENT_ID: ${{ secrets.XRMFORGE_CLIENT_ID }}
26
+ XRMFORGE_CLIENT_SECRET: ${{ secrets.XRMFORGE_CLIENT_SECRET }}
27
+ XRMFORGE_TENANT_ID: ${{ secrets.XRMFORGE_TENANT_ID }}
28
+
29
+ - name: Type check
30
+ run: npx tsc --noEmit
31
+
32
+ - name: Test
33
+ run: npx vitest run
34
+
35
+ - name: Build WebResources
36
+ run: npx xrmforge build
@@ -1,19 +1,19 @@
1
- # Dependencies
2
- node_modules/
3
-
4
- # Build output
5
- dist/
6
-
7
- # XrmForge cache
8
- .xrmforge/
9
-
10
- # IDE
11
- .vscode/settings.json
12
- .idea/
13
-
14
- # OS
15
- .DS_Store
16
- Thumbs.db
17
-
18
- # Logs
19
- *.log
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # Build output
5
+ dist/
6
+
7
+ # XrmForge cache
8
+ .xrmforge/
9
+
10
+ # IDE
11
+ .vscode/settings.json
12
+ .idea/
13
+
14
+ # OS
15
+ .DS_Store
16
+ Thumbs.db
17
+
18
+ # Logs
19
+ *.log
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Structured logger for D365 form scripts.
3
+ * This is the ONLY file allowed to use console.* directly.
4
+ */
5
+
6
+ /** Logger interface for structured logging with namespace prefix. */
7
+ export interface Logger {
8
+ debug(message: string, data?: unknown): void;
9
+ info(message: string, data?: unknown): void;
10
+ warn(message: string, data?: unknown): void;
11
+ error(message: string, data?: unknown): void;
12
+ }
13
+
14
+ const DEBUG_STORAGE_KEY = '{{namespace}}.debug'.toLowerCase();
15
+
16
+ /** Check if the current host is a dev/test environment. */
17
+ function isDebugHost(): boolean {
18
+ try {
19
+ const url = Xrm.Utility.getGlobalContext().getClientUrl() ?? '';
20
+ if (url.includes('-dev') || url.includes('-test')) return true;
21
+ } catch {
22
+ /* ignore */
23
+ }
24
+ return false;
25
+ }
26
+
27
+ /** Check if debug mode is enabled via localStorage. */
28
+ function isDebugStorage(): boolean {
29
+ try {
30
+ return window?.localStorage?.getItem(DEBUG_STORAGE_KEY) === '1';
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Create a namespaced logger instance.
38
+ *
39
+ * Debug messages are only shown in dev/test environments or when
40
+ * localStorage key is set to '1'.
41
+ *
42
+ * @param namespace - Prefix for all log messages (e.g. 'MyApp.Account')
43
+ */
44
+ export function createLogger(namespace: string): Logger {
45
+ const prefix = `[${namespace}]`;
46
+ const debugEnabled = isDebugHost() || isDebugStorage();
47
+
48
+ return {
49
+ debug(message, data) {
50
+ if (!debugEnabled) return;
51
+ if (data !== undefined) console.debug(prefix, message, data);
52
+ else console.debug(prefix, message);
53
+ },
54
+ info(message, data) {
55
+ if (data !== undefined) console.info(prefix, message, data);
56
+ else console.info(prefix, message);
57
+ },
58
+ warn(message, data) {
59
+ if (data !== undefined) console.warn(prefix, message, data);
60
+ else console.warn(prefix, message);
61
+ },
62
+ error(message, data) {
63
+ if (data !== undefined) console.error(prefix, message, data);
64
+ else console.error(prefix, message);
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bash
2
+ # XrmForge Self-Check - Pattern Compliance Verification
3
+ # Run this before tests to catch common violations.
4
+ # Exit code: 0 = all clean, 1 = violations found
5
+
6
+ set -uo pipefail
7
+
8
+ RED='\033[0;31m'
9
+ GREEN='\033[0;32m'
10
+ YELLOW='\033[0;33m'
11
+ NC='\033[0m'
12
+
13
+ violations=0
14
+ category_count=0
15
+
16
+ check() {
17
+ local label="$1"
18
+ shift
19
+ local count
20
+ count=$("$@" 2>/dev/null | wc -l | tr -d ' ')
21
+ category_count=$((category_count + 1))
22
+ if [ "$count" -gt 0 ]; then
23
+ echo -e "${RED}FAIL${NC} [$count] $label"
24
+ "$@" 2>/dev/null | head -20
25
+ violations=$((violations + count))
26
+ else
27
+ echo -e "${GREEN}OK${NC} [0] $label"
28
+ fi
29
+ }
30
+
31
+ echo "=== XrmForge Self-Check ==="
32
+ echo ""
33
+ echo "--- Pattern Compliance ---"
34
+
35
+ check "Raw field strings in getAttribute/getControl (must use Fields Enum)" \
36
+ bash -c 'grep -rn "getAttribute('" '"'"'" src/forms/ --include="*.ts" | grep -v "Fields\." ; grep -rn "getControl('" '"'"'" src/forms/ --include="*.ts" | grep -v "Fields\."'
37
+
38
+ check "Magic numbers in OptionSet comparisons (must use OptionSet Enum)" \
39
+ bash -c 'grep -rn "getValue() ===" src/ --include="*.ts" | grep -E "[0-9]{3,}"'
40
+
41
+ check "Direct _value access instead of parseLookup (Web API responses)" \
42
+ bash -c 'grep -rn "_value\b" src/ --include="*.ts" | grep -v "generated/" | grep -v "parseLookup" | grep -v "getValue"'
43
+
44
+ check "Raw entity names in WebApi calls (must use EntityNames)" \
45
+ bash -c 'grep -rn "retrieveRecord\|retrieveMultipleRecords\|deleteRecord\|createRecord\|updateRecord" src/ --include="*.ts" | grep "'"'"'[a-z]" | grep -v "EntityNames"'
46
+
47
+ check "Raw \$select strings (must use select() from @xrmforge/helpers)" \
48
+ bash -c 'grep -rn '"'"'\$select'"'"' src/ --include="*.ts" | grep -v "select(" | grep -v "generated/"'
49
+
50
+ check "Missing FormContext cast in onLoad (must have 'as <Generated>Form')" \
51
+ bash -c 'grep -rn "getFormContext()" src/forms/ --include="*.ts" | grep -v " as "'
52
+
53
+ check "Exported handlers without wrapHandler" \
54
+ bash -c 'grep -rn "^export const\|^export async function\|^export function" src/forms/ --include="*.ts" | grep -v "wrapHandler"'
55
+
56
+ echo ""
57
+ echo "--- Code Quality ---"
58
+
59
+ check "console.* outside logger.ts" \
60
+ bash -c 'grep -rn "console\." src/ --include="*.ts" | grep -v "logger.ts"'
61
+
62
+ check "Xrm.Page (deprecated since D365 v9.0)" \
63
+ bash -c 'grep -rn "Xrm\.Page" src/ --include="*.ts"'
64
+
65
+ check "var declarations" \
66
+ bash -c 'grep -rnE "^\s*var " src/ --include="*.ts"'
67
+
68
+ check "eval()" \
69
+ bash -c 'grep -rn "\beval(" src/ --include="*.ts"'
70
+
71
+ check "XMLHttpRequest" \
72
+ bash -c 'grep -rn "XMLHttpRequest" src/ --include="*.ts"'
73
+
74
+ check "as any without eslint-disable comment" \
75
+ bash -c 'grep -rn "as any" src/ --include="*.ts" | grep -v "eslint-disable"'
76
+
77
+ check "Import from @xrmforge/typegen in browser code (use @xrmforge/helpers)" \
78
+ bash -c 'grep -rn "from.*@xrmforge/typegen" src/ --include="*.ts" | grep -v "generated/"'
79
+
80
+ echo ""
81
+ echo "--- Test Completeness ---"
82
+
83
+ missing_tests=0
84
+ for f in src/forms/*.ts; do
85
+ [ -f "$f" ] || continue
86
+ base=$(basename "$f" .ts)
87
+ if [ ! -f "tests/forms/${base}.test.ts" ]; then
88
+ echo -e "${YELLOW}WARN${NC} No test file: $f"
89
+ missing_tests=$((missing_tests + 1))
90
+ fi
91
+ done
92
+ if [ "$missing_tests" -eq 0 ]; then
93
+ echo -e "${GREEN}OK${NC} All form scripts have test files"
94
+ else
95
+ echo -e "${YELLOW}WARN${NC} $missing_tests form scripts without tests"
96
+ fi
97
+
98
+ echo ""
99
+ echo "=== Results ==="
100
+ if [ "$violations" -eq 0 ]; then
101
+ echo -e "${GREEN}All $category_count checks passed. 0 violations.${NC}"
102
+ exit 0
103
+ else
104
+ echo -e "${RED}$violations violations found across $category_count checks.${NC}"
105
+ exit 1
106
+ fi
@@ -1,8 +1,8 @@
1
- import { defineConfig } from 'vitest/config';
2
-
3
- export default defineConfig({
4
- test: {
5
- globals: false,
6
- include: ['tests/**/*.test.ts'],
7
- },
8
- });
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: false,
6
+ include: ['tests/**/*.test.ts'],
7
+ },
8
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xrmforge/devkit",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Build orchestration and project tooling for Dynamics 365 WebResources",
5
5
  "keywords": [
6
6
  "dynamics-365",
@@ -25,7 +25,8 @@
25
25
  "types": "./dist/index.d.ts",
26
26
  "import": "./dist/index.js",
27
27
  "default": "./dist/index.js"
28
- }
28
+ },
29
+ "./package.json": "./package.json"
29
30
  },
30
31
  "main": "./dist/index.js",
31
32
  "types": "./dist/index.d.ts",