Wednesday, December 24, 2025

Mastering Default Dimensions in D365 F&O

Mastering Default Dimensions in D365 F&O: X++ Helper Methods to Create, Read, and Update

One of the most common yet complex tasks in X++ development for Dynamics 365 Finance and Operations is manipulating Financial Dimensions. Whether you are importing data, integrating with external systems, or simply updating a record based on business logic, interacting with the DimensionAttributeValueSet framework is inevitable.

To save you time, I have put together a set of static helper methods that handle the "heavy lifting" of the DimensionAttributeValueSetStorage class.

Below are three essential methods to Create, Read, and Update Default Dimensions.


1. Creating a New Default Dimension

If you have a list of dimension names and their corresponding values (for example, coming from a CSV import or an integration), you need to generate a new RecId for the DefaultDimension field.

This method accepts two containers: one for dimension names (e.g., Department, CostCenter) and one for values.


public static DimensionDefault createDefaultDimension(container _attributes, container _values)
{
    DimensionAttributeValueSetStorage valueSetStorage = new DimensionAttributeValueSetStorage();
    DimensionDefault            ret;
    int                         itemsCounter;
    DimensionAttribute          dimensionAttribute;
    DimensionAttributeValue     dimensionAttributeValue;
    str                         dimValue;

    for (itemsCounter = 1; itemsCounter <= conLen(_attributes); itemsCounter++)
    {
        // 1. Find the Dimension Attribute by Name (e.g., "Department")
        dimensionAttribute = dimensionAttribute::findByName(conPeek(_attributes, itemsCounter));

        if (dimensionAttribute.RecId == 0)
        {
            continue;
        }

        // 2. Get the value from the container
        dimValue = conPeek(_values, itemsCounter);

        if (dimValue != "")
        {
            // 3. Find the Value record. 
            // The last two parms (false, true) ensure we don't create new names, but we create values if missing.
            dimensionAttributeValue = dimensionAttributeValue::findByDimensionAttributeAndValue(dimensionAttribute, dimValue, false, true);
            
            // 4. Add to storage
            valueSetStorage.addItem(dimensionAttributeValue);
        }
    }

    // 5. Save and return the RecId
    ret = valueSetStorage.save();

    return ret;
}

2. Reading a Specific Dimension Value

Sometimes you don't need to change anything; you just need to know: "What is the Cost Center on this Sales Order?"

This method takes the DefaultDimension RecId and the name of the dimension you want to inspect, returning the display string.


public static str getDimensionDisplayValue(RecId _defaultDimension, Name _dimName)
{
    DimensionAttributeValueSetStorage dimStorage;
    
    // 1. Load the existing dimension set
    dimStorage = DimensionAttributeValueSetStorage::find(_defaultDimension);
    
    // 2. Return the value specific to the Dimension Name provided
    return dimStorage.getDisplayValueByDimensionAttribute(DimensionAttribute::findByName(_dimName).RecId);
}

3. Updating an Existing Dimension

A common requirement is to take an existing record (like a Customer or Vendor) and update just one specific dimension while keeping the others distinct.

This method loads the existing set, adds (or overwrites) the specific dimension value, and returns the new DefaultDimension RecId.


public static DimensionDefault updateDimensionValue(DefaultDimension _currentDim, Name _dimName, DimensionValue _dimValue)
{
    DimensionAttributeValueSetStorage   dimStorage = new DimensionAttributeValueSetStorage();
    DimensionAttribute                  dimAttribute;
    DimensionAttributeValue             dimAttributeValue;
    DimensionDefault                    defaultDimension;

    ttsBegin;
    // 1. Load the existing dimension set
    dimStorage = DimensionAttributeValueSetStorage::find(_currentDim);

    // 2. Find the attribute to update
    dimAttribute = DimensionAttribute::findByName(_dimName);

    if (dimAttribute)
    {
        // 3. Find or Create the new value
        dimAttributeValue = DimensionAttributeValue::findByDimensionAttributeAndValue(dimAttribute, _dimValue, true, true);
    }

    if (dimAttributeValue)
    {
        // 4. Add item (this overwrites the existing value for this attribute if it exists)
        dimStorage.addItem(dimAttributeValue);
        
        // 5. Save to get the new RecId
        defaultDimension = dimStorage.save();
    }
    ttsCommit;

    return defaultDimension;
}

Summary

  • createDefaultDimension: Great for data migration or creating new master data records.
  • getDimensionDisplayValue: Useful for reporting or conditional logic based on dimensions.
  • updateDimensionValue: Essential for patching specific values on existing transactions or records.

Feel free to wrap these in a utility class (e.g., GlobalDimensionHelper) to make them accessible across your project!

Thursday, December 18, 2025

Handling Temporary Files and Browser Downloads in D365FO

Deep Dive: Handling Temporary Files and Browser Downloads in D365FO

In the transition from Dynamics AX 2012 to Dynamics 365 Finance & Operations, file handling has undergone a paradigm shift. Because the application is cloud-hosted, developers can no longer rely on local file system paths like C:\Temp.

Instead, professional X++ development requires understanding System.IO.Streams and Azure Blob Storage interaction. This article explores how to generate files efficiently, manage memory, and force specific browser behaviors.

1. The Architecture: Where does the file go?

When you generate a file in X++, it exists initially in the AOS server's memory. To get it to the user's browser, the system follows this sequence:

  1. Stream Generation: Data is written to a .NET MemoryStream.
  2. Azure Upload: The system uploads this stream to a temporary Azure Blob Storage container via the File::SendFileToTempStore method.
  3. SAS Token: A secure URL (with a Shared Access Signature) is generated.
  4. Navigation: The browser is directed to this URL to initiate the download.
Pro Tip: Unlike legacy AX, the file never touches the local disk of the server. It moves directly from Memory (RAM) to the Cloud Storage.

2. The Professional Extension: Force "New Tab" Download

The standard File::SendStringAsFileToUser is useful for quick debugging, but for a polished user experience, we often want to open generated reports or logs in a new tab without interrupting the user's current session.

Below is a robust extension class. Notice the use of the using statement pattern. In .NET (and X++), this ensures that the memory stream is disposed of correctly, preventing memory leaks on the AOS.

[ExtensionOf(classStr(File))]
final class My_File_Extension
{
    /// <summary>
    /// Converts a string content to a stream and sends it to a new browser tab.
    /// </summary>
    public static void SendStringAsFileToUserNewTab(
        str _content, 
        str _fileName, 
        System.Text.Encoding _encoding = System.Text.Encoding::get_UTF8(), 
        ClassName _strategy = classstr(FileUploadTemporaryStorageStrategy))
    {
        // Convert string to byte array
        System.Byte[] byteArray = _encoding.GetBytes(_content);
        
        // "using" pattern ensures the stream is closed/disposed after use
        using (System.IO.MemoryStream stream = new System.IO.MemoryStream(byteArray))
        {
            File::SendFileToUserNewTab(stream, _fileName, _strategy);
        }
    }

    /// <summary>
    /// Uploads a stream to temp storage and navigates the browser to the URL in a new tab.
    /// </summary>
    public static void SendFileToUserNewTab(
        System.IO.Stream _stream, 
        str _fileName, 
        ClassName _strategy = classstr(FileUploadTemporaryStorageStrategy))
    {
        Browser browser = new Browser();
        str downloadUrl;
        
        // Upload the stream to Azure Temp Storage and get the SAS URL.
        // The 4th parameter 'true' requests a downloadable URL link.
        downloadUrl = File::SendFileToTempStore(_stream, _fileName, _strategy, true);
        
        if (downloadUrl != "")
        {
            // Navigate: (URL, OpenInNewTab = true, ShowLoading = false)
            browser.navigate(downloadUrl, true, false);
        }
        else
        {
            warning("@ApplicationPlatform:DownloadFailed");
        }
    }
}

3. How to Consume This Code

You can now utilize this extension in any Runnable Class (Job) or Service Controller. This is ideal for generating XML logs, simple TXT exports, or CSV files on the fly.

class Job_TestFileDownload
{
    public static void main(Args _args)
    {
        str logContent = "Log Entry: " + DateTimeUtil::toStr(DateTimeUtil::utcNow()) + "\nStatus: Success";
        
        // This will trigger the browser to open a new tab and download 'LogFile.txt'
        File::SendStringAsFileToUserNewTab(logContent, 'LogFile.txt');

        info("Download initiated in background.");
    }
}

4. Summary: Key Takeaways

  • Do not use hardcoded paths: System.IO.Path::GetTempPath() returns a server-side path, which is inaccessible to the client browser directly.
  • Use Streams: For any file larger than a few kilobytes, prefer Streams over Strings to manage AOS memory pressure.
  • Temporary Storage Strategy: The FileUploadTemporaryStorageStrategy automatically handles the cleanup of these files in Azure, so you don't need to write code to delete the temp file after the user downloads it.

Thursday, December 11, 2025

D365F&O: Create and Use Custom Lookups (Updated Guide)

D365F&O: Create and Use Custom Lookups

In this article, we will cover how to create a lookup form and link it with an EDT or String field in Dynamics 365 Finance & Operations using Extensions and Chain of Command.

Technical Prerequisites

  • Chain of Command (CoC): We will use extension classes to override form control behavior.
  • Form Patterns: We will use the "Lookup Basic" pattern to ensure the form renders correctly as a popup in the web browser.

Scenario

We need to override the system lookup to apply custom logic, filters, or use a completely custom Form design as a lookup for a specific field.


1. Standard String/EDT Field Lookup (via Chain of Command)

Use this when you want to filter a specific field using X++ query logic without creating a new custom lookup form.

Class: Form Data Source Field Extension

// Override lookup for CustGroup field
[ExtensionOf(formDataFieldStr(CustTable, CustTable, CustGroup))]
final class CustTableFormDataField_Extension
{
    public void lookup(FormControl _formControl, str _filterStr)
    {
        Query query = new Query();
        QueryBuildDataSource qbds;
        SysTableLookup sysTableLookup;

        // Lookup on CustGroup table (Correct)
        sysTableLookup = SysTableLookup::newParameters(tableNum(CustGroup), _formControl);

        // Fields to show
        sysTableLookup.addLookupfield(fieldNum(CustGroup, CustGroup));
        sysTableLookup.addLookupfield(fieldNum(CustGroup, Name));

        // Build query
        qbds = query.addDataSource(tableNum(CustGroup));

        // Optional filtering example:
        // qbds.addRange(fieldNum(CustGroup, CustGroup)).value("10*");

        sysTableLookup.parmQuery(query);
        sysTableLookup.performFormLookup();
    }
}

2. Reference Group Lookup (RecId-based fields)

This applies when the lookup control is a Reference Group and you want to override its lookup logic.

Class: Form Control Extension

// Example control name: PayrollEarningCode
[ExtensionOf(formControlStr(HcmPosition, PayrollEarningCode))]
final class HcmPosition_PayrollEarningCode_Extension
{
    public Common lookupReference()
    {
        Query query = new Query();
        QueryBuildDataSource qbds;
        SysReferenceTableLookup sysRefTableLookup;

        FormReferenceGroupControl referenceControl = this;

        sysRefTableLookup = SysReferenceTableLookup::newParameters(
            tableNum(PayrollEarningCode),
            referenceControl
        );

        sysRefTableLookup.addLookupfield(fieldNum(PayrollEarningCode, EarningCode));
        sysRefTableLookup.addLookupfield(fieldNum(PayrollEarningCode, QuantityUnit));

        qbds = query.addDataSource(tableNum(PayrollEarningCode));

        sysRefTableLookup.parmQuery(query);

        return sysRefTableLookup.performFormLookup();
    }
}

3. Using a Custom Form as a Lookup

Use this method when you have built a custom lookup form (e.g., CustLookupForm) for complex UI behavior.

Class: Form Control Extension

[ExtensionOf(formControlStr(SalesTable, CustAccount))]
final class SalesTable_CustAccount_Extension
{
    public void lookup(FormControl _formControl, str _filterStr)
    {
        Args args = new Args();
        FormRun custLookup;

        args.name(formStr(CustLookupForm)); // Custom lookup form name
        args.caller(_formControl);

        custLookup = classFactory.formRunClass(args);
        custLookup.init();
        custLookup.run(); // Recommended for stability

        _formControl.performFormLookup(custLookup);
    }
}

How to Design a Lookup Form in D365

A lookup form must follow specific design patterns to render correctly in the browser.

  1. Create the Form: Create a new Form named CustLookupForm.
  2. Apply Form Pattern:
    Right-click the Design node → Apply PatternLookup Basic.
  3. Data Sources:
    • Add CustTable
    • Add DirPartyTable and join it to CustTable
    • Set AllowEdit, AllowCreate, AllowDelete to No
  4. Design Properties:
    Set Style = Lookup.
  5. Grid Layout:
    The Lookup Basic pattern requires:
    Design → Group → Grid
  6. Form Logic (init):
  7. public void init()
    {
        super();
        element.selectMode(CustTable_AccountNum); // Returns the row's value to caller when clicked
    }
    
  8. Form Logic (run): Handle filtering
  9. public void run()
    {
        FormStringControl callerControl;
        boolean filterLookup;
    
        callerControl = SysTableLookup::getCallerStringControl(element.args());
    
        // Filter before run()
        filterLookup = SysTableLookup::filterLookupPreRun(
            callerControl,
            CustTable_AccountNum,
            CustTable_ds
        );
    
        super();
    
        // Apply filter after form loads
        SysTableLookup::filterLookupPostRun(
            filterLookup,
            callerControl.text(),
            CustTable_AccountNum,
            CustTable_ds
        );
    }
    

Search This Blog