Friday 12 May 2023

Microsoft Dynamics 365 Finance - Multi selection on forms with the SysOperation framework

Background

The SysOperation framework works with a query to fetch any data that will be processed.

In some cases, it is required to only process the selected (marked) records on a form but this is not possible without some extra coding.

I have solved this using a temporary (temp) table. The RecIDs  of the selected records are added to the temp table and then the temp table is joined with the query of the data contract class.


License/disclaimer: Free to use. Use at own risk. No rights or guarantees.


Let's start.


Temporary  table

The code below uses a temp table and you will to create a temp table with a single RefRecId field. The data type of the field must be Int64, you can then extend from RefRecId.


Make sure the Table Type property is TempDB and CreatedBy property is enabled because the temp table query will filter on the current user.

Note: You could use one of the standard tables that contains a RefRecId field as long as the Table Type of the table is TempDB and the RefRecId field data type is Int64.


With the SysOperation framework, I usually create a minumum of three classes, the data contract, controller and the business logic or service class. There is also a helper class which is used for the multi selection and joining with the query.


Data contract

[DataContractAttribute, SysOperationAlwaysInitializeAttribute]

public final class AVSysOpMultiSelectTemplateContract implements SysOperationInitializable
{
    private str packedQuery, packedSelectedRecIds;

    public void initialize() //always called because of SysOperationAlwaysInitializeAttribute
    {
    }

    public Query getQuery()
    {
        return new Query(SysOperationHelper::base64Decode(packedQuery));
    }

    public void setQuery(Query _query)
    {
        packedQuery = SysOperationHelper::base64Encode(_query.pack());
    }

    //the query specified here is the query shown in the SysOp dialog and also the
    //query used in the SysOp service class.
    [DataMemberAttribute, AifQueryTypeAttribute('_packedQuery', queryStr(AVCustTable))]
    public str parmQuery(str _packedQuery = packedQuery)
    {
        packedQuery = _packedQuery;
        return packedQuery;
    }

    public Set getSelectedRecIdSet()
    {
        if (!packedSelectedRecIds)
        {
            throw error("You have to set the selected RecIds first.");
        }
        return Set::create(SysOperationHelper::base64Decode(packedSelectedRecIds));
    }

    public void setSelectedRecIdSet(Set _selectedRecIdSet)
    {
        if (_selectedRecIdSet == null)
        {
            throw error("Argument %1 cannot be null.");
        }
        packedSelectedRecIds = SysOperationHelper::base64Encode(_selectedRecIdSet.pack());
    }

    public boolean isMultiSelected()
    {
        return packedSelectedRecIds ? true : false;
    }
}


Controller

[SysOperationJournaledParametersAttribute(true)]

public final class AVSysOpMultiSelectTemplateController extends SysOperationServiceController
{
    public static void main(Args _args)
    {
        AVSysOpMultiSelectTemplateController operation = AVSysOpMultiSelectTemplateController::construct();
        operation.parmLoadFromSysLastValue(false); //called from the UI so do not call getLast, we don't want previous selections etc.
        //operation.initializeFromArgs(_args); //can call initializeFromArgs() if menu item contains SysOp execution info. if not, it removes SysOp execution info.
        operation.parmShowDialog(true);
        AVSysOpMultiSelectTemplateContract contract = operation.getDataContractObject();
        Debug::assert(contract != null);

        if (AVMultiSelectionHelper::isMultiSelected(_args))
        {
            contract.setSelectedRecIdSet(AVMultiSelectionHelper::getSelectedRecIDSet(_args));
        }
        else
        {
            operation.AVModifyQueryFromArgs(_args);
        }

        if (operation.startOperation() == SysOperationStartResult::Started) //will return result because we are running synchronous.
        {
            Counter cntr;
            [cntr] = operation.operationReturnValue();
            Info(strFmt("Processed %1 selected records.", cntr));
            //refresh caller here
        }
    }

    public void new(IdentifierName _className = '', IdentifierName _methodName = '', SysOperationExecutionMode _executionMode = SysOperationExecutionMode::Asynchronous)
    {
        super(_className, _methodName, _executionMode);
        
        this.parmClassName(classStr(AVSysOpMultiSelectTemplateService));
        this.parmMethodName(methodStr(AVSysOpMultiSelectTemplateService, runQuery));
        this.parmExecutionMode(SysOperationExecutionMode::Synchronous); //ScheduledBatch: batch job remains visible. ReliableAsynchronous: job is deleted.
        this.parmDialogCaption("AV multi select"); //set dialog caption.
    }

    public static AVSysOpMultiSelectTemplateController construct()
    {
        return new AVSysOpMultiSelectTemplateController();
    }

    public void AVModifyQueryFromArgs(Args _args)
    {
        if (!_args)
        {
            throw error(strFmt("Argument %1 cannot be null.", identifierStr(_args)));
        }

        if (_args.dataset() != tableNum(CustTable))
        {
            throw error(strFmt("Caller buffer must be %1.", tableStr(CustTable)));
        }

        CustTable custTable = _args.record();

        if (custTable.RecId != 0)
        {
            AVSysOpMultiSelectTemplateContract contract = this.getDataContractObject();
            Debug::assert(contract != null);
            if (contract != null)
            {
                Query query = contract.getQuery();
                Debug::assert(query != null);
                if (query != null)
                {
                    QueryBuildDataSource custTable_ds = query.dataSourceTable(tableNum(CustTable));
                    SysQuery::findOrCreateRange(custTable_ds, fieldNum(CustTable, AccountNum)).value(queryValue(custTable.AccountNum));
                    contract.setQuery(query);
                }
            }
        }
    }

    public boolean mustGoBatch()
    {
        boolean ret;
    
        ret = super();
    
        return ret;
    }

    public boolean showQuerySelectButton(str parameterName)
    {
        boolean ret;
    
        ret = super(parameterName);
        AVSysOpMultiSelectTemplateContract contract = this.getDataContractObject();
        Debug::assert(contract != null);

        if (contract.isMultiSelected())
        {
            ret = false;
        }
    
        return ret;
    }

    public boolean showQueryValues(str parameterName)
    {
        boolean ret;
    
        ret = super(parameterName);
        AVSysOpMultiSelectTemplateContract contract = this.getDataContractObject();
        Debug::assert(contract != null);

        if (contract.isMultiSelected())
        {
            ret = false;
        }
    
        return ret;
    }
}


Business logic

public final class AVSysOpMultiSelectTemplateService
{
    //the service method is a class method with a contract as argument. the contract contains the parameters and query (if specified) for the operation.
    //the contract specified here as argument is the contract for our SysOp controller and service classes.
    //this method does not have to return anything but it does return a container to illustrate how to return 'something' from the service method.
    public container runQuery(AVSysOpMultiSelectTemplateContract data)
    {
        Counter         cntr;
        AVTmpRecId      tmpRecId;    //table containing RecIds of the selected records.
        Set             tmpRecIdSet; //set containing the RecIds of the selected records.
        Query           query = data.getQuery();

        if (data.isMultiSelected())
        {
            tmpRecIdSet = data.getSelectedRecIdSet(); //get selected RecIds as a Set.
            AVMultiSelectionHelper::populateTmpRecIdFromSet(tmpRecIdSet, tmpRecId); //populate our temp table from the set that contains the RecIds.
            AVMultiSelectionHelper::modifyQueryAddJoinToTmpTable(query); //modify the query to join with our temp table.
        }

        QueryRun queryRun = new QueryRun(query);

        if (data.isMultiSelected())
        {
            queryRun.setRecord(tmpRecId); //if joined with a temp table, we must set the record on the queryRun object.
        }

        while (queryRun.next())
        {
            CustTable custTable = queryRun.get(tableNum(CustTable));
            info(custTable.AccountNum);
            cntr++;
        }

        return [cntr];
    }

}


Helper class

public final class AVMultiSelectionHelper
{
    public static Set getSelectedRecIDSet(Args _args)
    {
        Set selectedRecIdSet;

        if (!_args)
        {
            throw error(strFmt("Argument %1 cannot be null.", identifierStr(_args)));
        }

        if (!_args.dataset())
        {
            throw error("Must be called with a caller buffer.");
        }

        if (!_args.record().isFormDataSource())
        {
            throw error("The caller buffer must be form datasource.");
        }

        selectedRecIdSet = new Set(Types::Int64);

        MultiSelectionHelper multiSelectionHelper = MultiSelectionHelper::construct();
        multiSelectionHelper.parmDatasource(_args.record().dataSource());

        Common buffer = multiSelectionHelper.getFirst();

        while (buffer.RecId != 0)
        {
            selectedRecIdSet.add(buffer.RecId);
            buffer = multiSelectionHelper.getNext();
        }

        return selectedRecIdSet;
    }

    public static boolean isMultiSelected(Args _args)
    {
        Set selectedRecIdSet = AVMultiSelectionHelper::getSelectedRecIDSet(_args); //arg checks are done in getSelectedRecIDSet().
        return selectedRecIdSet.elements() > 1 ? true : false;
    }

    public static void modifyQueryAddJoinToTmpTable(Query    _query)
    {
        QueryBuildDataSource    qbds;
        SysDictTable            dictTable;
        QueryBuildDataSource    tmpRecId_ds;

        if (!_query)
        {
            throw error(strFmt("Argument %1 cannot be null.", identifierStr(_query)));
        }

        qbds = _query.dataSourceNo(1); //join on first datasource in query.
        dictTable = SysDictTable::newTableId(qbds.table());
        Debug::assert(dictTable != null);

        tmpRecId_ds = qbds.addDataSource(tableNum(AVTmpRecId), tableStr(AVTmpRecId));
        tmpRecId_ds.addLink(dictTable.fieldName2Id(identifierStr(RecId)), fieldNum(AVTmpRecId, RefRecId));
        qbds.clearRanges(); //joined so clear all existing ranges.

        SysQuery::findOrCreateRange(tmpRecId_ds, fieldNum(AVTmpRecId, CreatedBy)).value(curUserId());                                    
        SysQuery::findOrCreateRange(tmpRecId_ds, fieldNum(AVTmpRecId, CreatedTransactionId)).value(queryValue(appl.curTransactionId()));

    }

    public static void populateTmpRecIdFromSet(Set  _recIdSet, AVTmpRecId    _tmpRecId)
    {
        if (!_recIdSet)
        {
            throw error(strFmt("Argument %1 cannot be null.", identifierStr(_recIdSet)));
        }

        SetEnumerator enum = _recIdSet.getEnumerator();

        while (enum.moveNext())
        {
            _tmpRecId.RefRecId = enum.current();
            _tmpRecId.insert();
        }
    }

}