@xrmforge/devkit 0.7.13 → 0.7.14

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.
@@ -15,16 +15,12 @@ steps:
15
15
  - script: npm ci
16
16
  displayName: 'Install dependencies'
17
17
 
18
- # Connection + auth come from the pipeline variables below. Entity scope
19
- # (--entities or --solutions) and --output belong in xrmforge.config.json.
18
+ # Connection + credentials come from the XRMFORGE_* environment below; the CLI reads
19
+ # them directly, so the secret never appears as a command-line argument (and stays
20
+ # out of the process list). Entity scope (--entities or --solutions) and --output
21
+ # belong in xrmforge.config.json.
20
22
  # See "npx xrmforge generate --help" for all options (--actions, labels, ...).
21
- - script: >
22
- npx xrmforge generate
23
- --url "$XRMFORGE_URL"
24
- --auth client-credentials
25
- --tenant-id "$XRMFORGE_TENANT_ID"
26
- --client-id "$XRMFORGE_CLIENT_ID"
27
- --client-secret "$XRMFORGE_CLIENT_SECRET"
23
+ - script: npx xrmforge generate --auth client-credentials
28
24
  displayName: 'Generate types from Dataverse'
29
25
  env:
30
26
  XRMFORGE_URL: $(XRMFORGE_URL)
@@ -1,32 +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
- }
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
+ }
@@ -1,96 +1,96 @@
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
- import { FormNotificationLevel } from '@xrmforge/helpers';
8
-
9
- type EventHandler = (ctx: Xrm.Events.EventContext, ...args: never[]) => unknown;
10
-
11
- /**
12
- * Wrap a form event handler with error handling.
13
- *
14
- * Catches both sync and async errors, logs them, and shows a form notification.
15
- * The original handler is never rethrown, so form execution continues.
16
- *
17
- * @param name - Handler name for logging (e.g. 'MyApp.Account.onLoad')
18
- * @param logger - Logger instance for error reporting
19
- * @param handler - The actual event handler function
20
- */
21
- export function wrapHandler(name: string, logger: Logger, handler: EventHandler): EventHandler {
22
- const wrapped: EventHandler = (ctx, ...args) => {
23
- try {
24
- const result = handler(ctx, ...args);
25
- if (result && typeof (result as Promise<unknown>).then === 'function') {
26
- return (result as Promise<unknown>).catch((err: unknown) => {
27
- logAndNotify(ctx, name, logger, err);
28
- });
29
- }
30
- return result;
31
- } catch (err: unknown) {
32
- logAndNotify(ctx, name, logger, err);
33
- }
34
- };
35
- return wrapped;
36
- }
37
-
38
- /**
39
- * Wrap a ribbon command handler with error handling.
40
- *
41
- * Unlike wrapHandler, this accepts a FormContext directly (not an EventContext),
42
- * which is the calling convention for ribbon/command bar handlers.
43
- *
44
- * @param name - Handler name for logging
45
- * @param logger - Logger instance for error reporting
46
- * @param handler - The actual command handler function
47
- */
48
- export function wrapCommand(
49
- name: string,
50
- logger: Logger,
51
- handler: (formContext: Xrm.FormContext, ...args: never[]) => unknown,
52
- ): (formContext: Xrm.FormContext, ...args: never[]) => unknown {
53
- return (formContext, ...args) => {
54
- try {
55
- const result = handler(formContext, ...args);
56
- if (result && typeof (result as Promise<unknown>).then === 'function') {
57
- return (result as Promise<unknown>).catch((err: unknown) => {
58
- logAndNotifyForm(formContext, name, logger, err);
59
- });
60
- }
61
- return result;
62
- } catch (err: unknown) {
63
- logAndNotifyForm(formContext, name, logger, err);
64
- }
65
- };
66
- }
67
-
68
- function logAndNotify(
69
- ctx: Xrm.Events.EventContext,
70
- name: string,
71
- logger: Logger,
72
- err: unknown,
73
- ): void {
74
- const message = err instanceof Error ? err.message : String(err);
75
- logger.error(`${name} failed`, { err });
76
- try {
77
- ctx.getFormContext().ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
78
- } catch {
79
- /* ignore */
80
- }
81
- }
82
-
83
- function logAndNotifyForm(
84
- formContext: Xrm.FormContext,
85
- name: string,
86
- logger: Logger,
87
- err: unknown,
88
- ): void {
89
- const message = err instanceof Error ? err.message : String(err);
90
- logger.error(`${name} failed`, { err });
91
- try {
92
- formContext.ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
93
- } catch {
94
- /* ignore */
95
- }
96
- }
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
+ import { FormNotificationLevel } from '@xrmforge/helpers';
8
+
9
+ type EventHandler = (ctx: Xrm.Events.EventContext, ...args: never[]) => unknown;
10
+
11
+ /**
12
+ * Wrap a form event handler with error handling.
13
+ *
14
+ * Catches both sync and async errors, logs them, and shows a form notification.
15
+ * The original handler is never rethrown, so form execution continues.
16
+ *
17
+ * @param name - Handler name for logging (e.g. 'MyApp.Account.onLoad')
18
+ * @param logger - Logger instance for error reporting
19
+ * @param handler - The actual event handler function
20
+ */
21
+ export function wrapHandler(name: string, logger: Logger, handler: EventHandler): EventHandler {
22
+ const wrapped: EventHandler = (ctx, ...args) => {
23
+ try {
24
+ const result = handler(ctx, ...args);
25
+ if (result && typeof (result as Promise<unknown>).then === 'function') {
26
+ return (result as Promise<unknown>).catch((err: unknown) => {
27
+ logAndNotify(ctx, name, logger, err);
28
+ });
29
+ }
30
+ return result;
31
+ } catch (err: unknown) {
32
+ logAndNotify(ctx, name, logger, err);
33
+ }
34
+ };
35
+ return wrapped;
36
+ }
37
+
38
+ /**
39
+ * Wrap a ribbon command handler with error handling.
40
+ *
41
+ * Unlike wrapHandler, this accepts a FormContext directly (not an EventContext),
42
+ * which is the calling convention for ribbon/command bar handlers.
43
+ *
44
+ * @param name - Handler name for logging
45
+ * @param logger - Logger instance for error reporting
46
+ * @param handler - The actual command handler function
47
+ */
48
+ export function wrapCommand(
49
+ name: string,
50
+ logger: Logger,
51
+ handler: (formContext: Xrm.FormContext, ...args: never[]) => unknown,
52
+ ): (formContext: Xrm.FormContext, ...args: never[]) => unknown {
53
+ return (formContext, ...args) => {
54
+ try {
55
+ const result = handler(formContext, ...args);
56
+ if (result && typeof (result as Promise<unknown>).then === 'function') {
57
+ return (result as Promise<unknown>).catch((err: unknown) => {
58
+ logAndNotifyForm(formContext, name, logger, err);
59
+ });
60
+ }
61
+ return result;
62
+ } catch (err: unknown) {
63
+ logAndNotifyForm(formContext, name, logger, err);
64
+ }
65
+ };
66
+ }
67
+
68
+ function logAndNotify(
69
+ ctx: Xrm.Events.EventContext,
70
+ name: string,
71
+ logger: Logger,
72
+ err: unknown,
73
+ ): void {
74
+ const message = err instanceof Error ? err.message : String(err);
75
+ logger.error(`${name} failed`, { err });
76
+ try {
77
+ ctx.getFormContext().ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
78
+ } catch {
79
+ /* ignore */
80
+ }
81
+ }
82
+
83
+ function logAndNotifyForm(
84
+ formContext: Xrm.FormContext,
85
+ name: string,
86
+ logger: Logger,
87
+ err: unknown,
88
+ ): void {
89
+ const message = err instanceof Error ? err.message : String(err);
90
+ logger.error(`${name} failed`, { err });
91
+ try {
92
+ formContext.ui.setFormNotification(message, FormNotificationLevel.Error, NOTIFICATION_IDS.genericError);
93
+ } catch {
94
+ /* ignore */
95
+ }
96
+ }
@@ -1,21 +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
+ 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,33 +1,33 @@
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. 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
- // createFormMock<TForm>() takes the generated FORM interface and returns a
22
- // FormMock: use mock.asEventContext() for the handler and mock.ui /
23
- // mock.getControl() for assertions (plain mocks, not vi.fn() spies).
24
- //
25
- // import { createFormMock } from '@xrmforge/testing';
26
- // import type { ExampleForm } from '../../generated/forms/example.js';
27
- //
28
- // it('should show notification on load', () => {
29
- // const mock = createFormMock<ExampleForm>({ name: 'Contoso Ltd' });
30
- // onLoad(mock.asEventContext());
31
- // expect(mock.ui.getFormNotification('example-load')?.message).toBe('Form loaded');
32
- // });
33
- });
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. 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
+ // createFormMock<TForm>() takes the generated FORM interface and returns a
22
+ // FormMock: use mock.asEventContext() for the handler and mock.ui /
23
+ // mock.getControl() for assertions (plain mocks, not vi.fn() spies).
24
+ //
25
+ // import { createFormMock } from '@xrmforge/testing';
26
+ // import type { ExampleForm } from '../../generated/forms/example.js';
27
+ //
28
+ // it('should show notification on load', () => {
29
+ // const mock = createFormMock<ExampleForm>({ name: 'Contoso Ltd' });
30
+ // onLoad(mock.asEventContext());
31
+ // expect(mock.ui.getFormNotification('example-load')?.message).toBe('Form loaded');
32
+ // });
33
+ });
@@ -1,77 +1,77 @@
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 via $context, controls proxy for control access
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
- // For Web API queries and lookups also import: select, formLookupId
21
- import { typedForm, FormNotificationLevel } from '@xrmforge/helpers';
22
- // TODO: After 'xrmforge generate', replace with your actual imports:
23
- // import type { ExampleFormTypeInfo } from '../../generated/forms/example.js';
24
- // import { ExampleFormFieldsEnum as Fields } from '../../generated/forms/example.js';
25
- // import { ExampleFields } from '../../generated/fields/example.js';
26
- // import { EntityNames } from '../../generated/entity-names.js';
27
-
28
- const logger = createLogger('{{namespace}}.Example');
29
-
30
- /**
31
- * Called when the form loads.
32
- */
33
- export const onLoad = wrapHandler('{{namespace}}.Example.onLoad', logger, async (ctx) => {
34
- // TODO: Replace 'Xrm.FormContext' with your generated <Form>TypeInfo type:
35
- // const form = typedForm<ExampleFormTypeInfo>(ctx.getFormContext());
36
- const form = typedForm<Xrm.FormContext>(ctx.getFormContext());
37
-
38
- // Direct field access via typedForm proxy (fully typed):
39
- // const name = form.name.getValue(); // string | null
40
- // form.revenue.setValue(150000); // NumberAttribute
41
-
42
- // addOnChange uses Fields Enum via $context:
43
- // form.$context.getAttribute(Fields.Name).addOnChange(() => {
44
- // logger.debug('Name changed', { value: form.name.getValue() });
45
- // });
46
-
47
- // Web API queries use entity-level Fields:
48
- // const result = await Xrm.WebApi.retrieveRecord(
49
- // EntityNames.Account, id,
50
- // select(ExampleFields.Name, ExampleFields.WebsiteUrl)
51
- // );
52
-
53
- // Lookup access:
54
- // const parentId = formLookupId(form.parentaccountid);
55
-
56
- // Localized UI strings:
57
- const lang = pickLang(
58
- Xrm.Utility.getGlobalContext().userSettings.languageId,
59
- MESSAGES,
60
- );
61
- logger.info('Form loaded', { language: lang });
62
-
63
- // Form notifications use constants:
64
- form.$context.ui.setFormNotification(
65
- 'Form loaded',
66
- FormNotificationLevel.Info,
67
- 'example-load',
68
- );
69
- });
70
-
71
- /**
72
- * Called when the form is saved.
73
- */
74
- export const onSave = wrapHandler('{{namespace}}.Example.onSave', logger, (ctx) => {
75
- const form = typedForm<Xrm.FormContext>(ctx.getFormContext());
76
- form.$context.ui.clearFormNotification('example-load');
77
- });
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 via $context, controls proxy for control access
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
+ // For Web API queries and lookups also import: select, formLookupId
21
+ import { typedForm, FormNotificationLevel } from '@xrmforge/helpers';
22
+ // TODO: After 'xrmforge generate', replace with your actual imports:
23
+ // import type { ExampleFormTypeInfo } from '../../generated/forms/example.js';
24
+ // import { ExampleFormFieldsEnum as Fields } from '../../generated/forms/example.js';
25
+ // import { ExampleFields } from '../../generated/fields/example.js';
26
+ // import { EntityNames } from '../../generated/entity-names.js';
27
+
28
+ const logger = createLogger('{{namespace}}.Example');
29
+
30
+ /**
31
+ * Called when the form loads.
32
+ */
33
+ export const onLoad = wrapHandler('{{namespace}}.Example.onLoad', logger, async (ctx) => {
34
+ // TODO: Replace 'Xrm.FormContext' with your generated <Form>TypeInfo type:
35
+ // const form = typedForm<ExampleFormTypeInfo>(ctx.getFormContext());
36
+ const form = typedForm<Xrm.FormContext>(ctx.getFormContext());
37
+
38
+ // Direct field access via typedForm proxy (fully typed):
39
+ // const name = form.name.getValue(); // string | null
40
+ // form.revenue.setValue(150000); // NumberAttribute
41
+
42
+ // addOnChange uses Fields Enum via $context:
43
+ // form.$context.getAttribute(Fields.Name).addOnChange(() => {
44
+ // logger.debug('Name changed', { value: form.name.getValue() });
45
+ // });
46
+
47
+ // Web API queries use entity-level Fields:
48
+ // const result = await Xrm.WebApi.retrieveRecord(
49
+ // EntityNames.Account, id,
50
+ // select(ExampleFields.Name, ExampleFields.WebsiteUrl)
51
+ // );
52
+
53
+ // Lookup access:
54
+ // const parentId = formLookupId(form.parentaccountid);
55
+
56
+ // Localized UI strings:
57
+ const lang = pickLang(
58
+ Xrm.Utility.getGlobalContext().userSettings.languageId,
59
+ MESSAGES,
60
+ );
61
+ logger.info('Form loaded', { language: lang });
62
+
63
+ // Form notifications use constants:
64
+ form.$context.ui.setFormNotification(
65
+ 'Form loaded',
66
+ FormNotificationLevel.Info,
67
+ 'example-load',
68
+ );
69
+ });
70
+
71
+ /**
72
+ * Called when the form is saved.
73
+ */
74
+ export const onSave = wrapHandler('{{namespace}}.Example.onSave', logger, (ctx) => {
75
+ const form = typedForm<Xrm.FormContext>(ctx.getFormContext());
76
+ form.$context.ui.clearFormNotification('example-load');
77
+ });
@@ -20,16 +20,12 @@ jobs:
20
20
  - run: npm ci
21
21
 
22
22
  - name: Generate types from Dataverse
23
- # Connection + auth come from the repository secrets below. Entity scope
24
- # (--entities or --solutions) and --output belong in xrmforge.config.json.
23
+ # Connection + credentials come from the XRMFORGE_* environment below; the CLI
24
+ # reads them directly, so the secret never appears as a command-line argument
25
+ # (and stays out of the process list). Entity scope (--entities or --solutions)
26
+ # and --output belong in xrmforge.config.json.
25
27
  # See "npx xrmforge generate --help" for all options (--actions, labels, ...).
26
- run: >
27
- npx xrmforge generate
28
- --url "$XRMFORGE_URL"
29
- --auth client-credentials
30
- --tenant-id "$XRMFORGE_TENANT_ID"
31
- --client-id "$XRMFORGE_CLIENT_ID"
32
- --client-secret "$XRMFORGE_CLIENT_SECRET"
28
+ run: npx xrmforge generate --auth client-credentials
33
29
  env:
34
30
  XRMFORGE_URL: ${{ secrets.XRMFORGE_URL }}
35
31
  XRMFORGE_TENANT_ID: ${{ secrets.XRMFORGE_TENANT_ID }}