Développement

TypeScript pour le scripting côté client Dynamics 365

14 janvier 2025
Sabhat Qureshi
9 min de lecture

Pourquoi TypeScript pour Dynamics 365 ?

TypeScript apporte la vérification de type statique, les fonctionnalités JavaScript modernes et une excellente prise en charge par les IDE au développement client-side de Dynamics 365. Bien que Dynamics 365 exécute du JavaScript, développer en TypeScript détecte les erreurs à la compilation, fournit IntelliSense pour l'API Xrm et rend le code plus maintenable. Référence API client.

Avantages de TypeScript

  • Sécurité de type : Détecter les erreurs avant l'exécution
  • IntelliSense : Autocomplétion complète pour les API Xrm
  • Refactoring : Renommage et restructuration sécurisés
  • Documentation : Les types servent de documentation inline
  • Fonctionnalités modernes : Utiliser async/await, classes, modules

Configuration du projet

Initialiser le projet TypeScript

# Create project folder
mkdir D365ClientScripts
cd D365ClientScripts

# Initialize npm and TypeScript
npm init -y
npm install typescript --save-dev
npm install @types/xrm --save-dev

# Initialize TypeScript configuration
npx tsc --init

Configuration TypeScript

Configurer tsconfig.json pour le développement Dynamics 365 :

{
  "compilerOptions": {
    "target": "ES2017",
    "module": "ES2020",
    "lib": ["ES2017", "DOM"],
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "sourceMap": true,
    "types": ["xrm"]
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Structure du projet

D365ClientScripts/
├── src/
│   ├── forms/
│   │   ├── account.form.ts
│   │   ├── contact.form.ts
│   │   └── opportunity.form.ts
│   ├── ribbons/
│   │   └── account.ribbon.ts
│   ├── common/
│   │   ├── notifications.ts
│   │   └── validation.ts
│   └── types/
│       └── custom-types.d.ts
├── dist/                    # Compiled JavaScript output
├── package.json
└── tsconfig.json

Scripts de formulaire

Gestionnaires d'événements de formulaire basiques

Créer des gestionnaires d'événements de formulaire typés avec le contexte Xrm approprié :

// src/forms/account.form.ts
export namespace AccountForm {

    // Form OnLoad event handler
    export function onLoad(executionContext: Xrm.Events.EventContext): void {
        const formContext = executionContext.getFormContext();

        // Set up field change handlers
        const nameAttribute = formContext.getAttribute("name");
        if (nameAttribute) {
            nameAttribute.addOnChange(onNameChange);
        }

        // Check form type
        const formType = formContext.ui.getFormType();
        if (formType === XrmEnum.FormType.Create) {
            setDefaultValues(formContext);
        }

        // Initialize business logic
        validateRequiredFields(formContext);
    }

    // Form OnSave event handler
    export function onSave(executionContext: Xrm.Events.SaveEventContext): void {
        const formContext = executionContext.getFormContext();
        const saveEventArgs = executionContext.getEventArgs();

        // Validate before save
        if (!validateBeforeSave(formContext)) {
            saveEventArgs.preventDefault();
            return;
        }

        // Custom save logic
        console.log("Account saved successfully");
    }

    // Field change handler
    function onNameChange(executionContext: Xrm.Events.EventContext): void {
        const formContext = executionContext.getFormContext();
        const nameAttribute = formContext.getAttribute("name");

        if (nameAttribute) {
            const nameValue = nameAttribute.getValue();
            // Perform validation or dependent logic
            if (nameValue && nameValue.length > 100) {
                formContext.ui.setFormNotification(
                    "Account name is very long. Consider shortening it.",
                    "WARNING",
                    "nameWarning"
                );
            } else {
                formContext.ui.clearFormNotification("nameWarning");
            }
        }
    }

    function setDefaultValues(formContext: Xrm.FormContext): void {
        // Set default account type
        const accountTypeAttr = formContext.getAttribute("accountcategorycode");
        if (accountTypeAttr && !accountTypeAttr.getValue()) {
            accountTypeAttr.setValue(1); // Standard
        }
    }

    function validateRequiredFields(formContext: Xrm.FormContext): boolean {
        let isValid = true;
        const requiredFields = ["name", "telephone1", "emailaddress1"];

        requiredFields.forEach(fieldName => {
            const attribute = formContext.getAttribute(fieldName);
            const control = formContext.getControl(fieldName) as Xrm.Controls.StandardControl;

            if (attribute && !attribute.getValue()) {
                if (control) {
                    control.setNotification("This field is required", fieldName + "_required");
                }
                isValid = false;
            }
        });

        return isValid;
    }

    function validateBeforeSave(formContext: Xrm.FormContext): boolean {
        // Clear previous notifications
        formContext.ui.clearFormNotification("saveValidation");

        // Perform validation
        const emailAttr = formContext.getAttribute("emailaddress1");
        if (emailAttr) {
            const email = emailAttr.getValue();
            if (email && !isValidEmail(email)) {
                formContext.ui.setFormNotification(
                    "Please enter a valid email address",
                    "ERROR",
                    "saveValidation"
                );
                return false;
            }
        }

        return true;
    }

    function isValidEmail(email: string): boolean {
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        return emailRegex.test(email);
    }
}

Travailler avec les lookups

// Get and set lookup values
export function handlePrimaryContactChange(
    executionContext: Xrm.Events.EventContext
): void {
    const formContext = executionContext.getFormContext();

    // Get lookup value
    const primaryContactAttr = formContext.getAttribute("primarycontactid");
    if (!primaryContactAttr) return;

    const lookupValue = primaryContactAttr.getValue();
    if (lookupValue && lookupValue.length > 0) {
        const contactId = lookupValue[0].id;
        const contactName = lookupValue[0].name;
        const entityType = lookupValue[0].entityType;

        console.log(`Selected contact: ${contactName} (${contactId})`);

        // Fetch additional contact details
        fetchContactDetails(formContext, contactId);
    }
}

async function fetchContactDetails(
    formContext: Xrm.FormContext,
    contactId: string
): Promise<void> {
    try {
        const result = await Xrm.WebApi.retrieveRecord(
            "contact",
            contactId,
            "?$select=emailaddress1,telephone1,jobtitle"
        );

        // Update form fields with contact info
        const emailAttr = formContext.getAttribute("emailaddress1");
        if (emailAttr && result.emailaddress1) {
            emailAttr.setValue(result.emailaddress1);
        }
    } catch (error) {
        console.error("Error fetching contact details:", error);
    }
}

// Set lookup value programmatically
export function setParentAccount(
    formContext: Xrm.FormContext,
    accountId: string,
    accountName: string
): void {
    const parentAccountAttr = formContext.getAttribute("parentaccountid");
    if (parentAccountAttr) {
        const lookupValue: Xrm.LookupValue[] = [{
            id: accountId,
            name: accountName,
            entityType: "account"
        }];
        parentAccountAttr.setValue(lookupValue);
    }
}

Commandes ribbon

Règles d'activation et commandes

// src/ribbons/account.ribbon.ts
export namespace AccountRibbon {

    // Enable rule - determines if button is enabled
    export function enableApproveButton(
        primaryControl: Xrm.FormContext
    ): boolean {
        // Check form type - only enable on existing records
        if (primaryControl.ui.getFormType() === XrmEnum.FormType.Create) {
            return false;
        }

        // Check status
        const statusAttr = primaryControl.getAttribute("statuscode");
        if (statusAttr) {
            const status = statusAttr.getValue();
            // Only enable for "Pending Approval" status
            return status === 100000001;
        }

        return false;
    }

    // Visibility rule
    export function showForManagers(
        primaryControl: Xrm.FormContext
    ): boolean {
        // Check user's security roles
        const userRoles = Xrm.Utility.getGlobalContext().userSettings.roles;
        const managerRoleId = "00000000-0000-0000-0000-000000000001"; // Replace with actual role ID

        return userRoles.get().some(role => role.id === managerRoleId);
    }

    // Command action
    export async function approveAccount(
        primaryControl: Xrm.FormContext
    ): Promise<void> {
        const recordId = primaryControl.data.entity.getId().replace(/[{}]/g, '');

        // Confirm action
        const confirmResult = await Xrm.Navigation.openConfirmDialog({
            title: "Approve Account",
            text: "Are you sure you want to approve this account?",
            confirmButtonLabel: "Approve",
            cancelButtonLabel: "Cancel"
        });

        if (!confirmResult.confirmed) return;

        try {
            // Update status
            await Xrm.WebApi.updateRecord("account", recordId, {
                statuscode: 100000002 // Approved
            });

            // Show success message
            await Xrm.Navigation.openAlertDialog({
                title: "Success",
                text: "Account has been approved."
            });

            // Refresh the form
            primaryControl.data.refresh(false);

        } catch (error) {
            console.error("Error approving account:", error);
            await Xrm.Navigation.openAlertDialog({
                title: "Error",
                text: "Failed to approve account. Please try again."
            });
        }
    }

    // Command with selected records from grid
    export async function bulkApprove(
        selectedEntityReferences: Xrm.LookupValue[]
    ): Promise<void> {
        if (!selectedEntityReferences || selectedEntityReferences.length === 0) {
            await Xrm.Navigation.openAlertDialog({
                text: "Please select at least one record."
            });
            return;
        }

        const confirmResult = await Xrm.Navigation.openConfirmDialog({
            title: "Bulk Approve",
            text: `Are you sure you want to approve ${selectedEntityReferences.length} account(s)?`
        });

        if (!confirmResult.confirmed) return;

        let successCount = 0;
        for (const record of selectedEntityReferences) {
            try {
                await Xrm.WebApi.updateRecord("account", record.id, {
                    statuscode: 100000002
                });
                successCount++;
            } catch (error) {
                console.error(`Error approving ${record.id}:`, error);
            }
        }

        await Xrm.Navigation.openAlertDialog({
            text: `Successfully approved ${successCount} of ${selectedEntityReferences.length} accounts.`
        });
    }
}

Utilitaires communs

Helper de notification

// src/common/notifications.ts
export namespace NotificationHelper {

    export function showFormError(
        formContext: Xrm.FormContext,
        message: string,
        notificationId: string = "formError"
    ): void {
        formContext.ui.setFormNotification(message, "ERROR", notificationId);
    }

    export function showFormWarning(
        formContext: Xrm.FormContext,
        message: string,
        notificationId: string = "formWarning"
    ): void {
        formContext.ui.setFormNotification(message, "WARNING", notificationId);
    }

    export function showFormInfo(
        formContext: Xrm.FormContext,
        message: string,
        notificationId: string = "formInfo"
    ): void {
        formContext.ui.setFormNotification(message, "INFO", notificationId);
    }

    export function clearNotification(
        formContext: Xrm.FormContext,
        notificationId: string
    ): void {
        formContext.ui.clearFormNotification(notificationId);
    }

    export function setFieldError(
        formContext: Xrm.FormContext,
        fieldName: string,
        message: string
    ): void {
        const control = formContext.getControl(fieldName) as Xrm.Controls.StandardControl;
        if (control && control.setNotification) {
            control.setNotification(message, fieldName + "_error");
        }
    }

    export function clearFieldError(
        formContext: Xrm.FormContext,
        fieldName: string
    ): void {
        const control = formContext.getControl(fieldName) as Xrm.Controls.StandardControl;
        if (control && control.clearNotification) {
            control.clearNotification(fieldName + "_error");
        }
    }
}

Build et déploiement

Scripts de build

// package.json scripts
{
  "scripts": {
    "build": "tsc",
    "watch": "tsc --watch",
    "bundle": "webpack --config webpack.config.js",
    "deploy": "npm run build && pac webresource push"
  }
}

Configuration Webpack (Optionnel)

Regrouper plusieurs fichiers en un seul fichier JavaScript :

// webpack.config.js
const path = require('path');

module.exports = {
  entry: {
    'account.form': './src/forms/account.form.ts',
    'contact.form': './src/forms/contact.form.ts',
    'account.ribbon': './src/ribbons/account.ribbon.ts'
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'dist'),
    library: {
      type: 'var',
      name: '[name]'
    }
  },
  resolve: {
    extensions: ['.ts', '.js']
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: 'ts-loader',
        exclude: /node_modules/
      }
    ]
  },
  mode: 'production'
};

Bonnes pratiques

  • Utiliser des namespaces : Organiser le code par entité et objectif
  • Activer le mode strict : Détecter les erreurs potentielles à la compilation
  • Typer tout : Éviter le type 'any' ; créer des interfaces personnalisées
  • Gérer les nulls : Utiliser le chaînage optionnel et les vérifications null
  • Async/Await : Utiliser les modèles async modernes pour les appels Web API
  • Centraliser la logique commune : Créer des utilitaires partagés pour les notifications, la validation
  • Documenter les fonctions publiques : Utiliser les commentaires JSDoc pour les fonctions exportées

Conclusion

TypeScript améliore significativement l'expérience de développement client-side de Dynamics 365. Avec un typage approprié, vous détectez les erreurs avant le déploiement, écrivez du code plus maintenable et bénéficiez d'une excellente prise en charge par les IDE. Combiné avec des outils de build modernes, TypeScript crée un workflow de développement professionnel qui évolue avec la complexité de votre projet.

Besoin d'aide pour votre projet Dynamics 365 ?

Nous pouvons vous aider à introduire TypeScript et un outillage moderne dans vos projets Dynamics 365.