Extend the point of sale (POS) Dual display view

Important

Dynamics 365 Retail is now Dynamics 365 Commerce - offering comprehensive omnichannel commerce across e-Commerce, in-store, and call center. For more information about these changes, see Microsoft Dynamics 365 Commerce.

This topic explains how to extend the point of sale (POS) Dual display view so that it shows custom information. This topic is applicable to Microsoft Dynamics 365 for Finance and Operations 7.2 or Microsoft Dynamics 365 Retail 7.2 with KB 4091080, and later versions.

You can extend the POS Dual display view by adding a custom control. In the custom control, you can add images, POS data lists, labels, and so on, to show custom information.

Note

You can extend the POS Dual display view only by adding a custom control. The custom control will override the standard content that is shown in the POS Dual display view.

Required steps

Here is an overview of the steps that are required in order to customize the POS Dual display view.

  1. Configure the hardware profile to enable dual display.
  2. Create a folder in the POS.Extensions project for extension of the POS Dual display view.
  3. Add a new custom control that includes custom information.
  4. Update the manifest.json and extensions.json files with the extension of the POS Dual display view.
  5. Deploy the changes, and validate the customization.

Scenario or business problem

You will add a custom control column in the POS Dual display view to show the cart details and information about the customer and the store employee.

Configure the hardware profile to enable dual display

  1. Sign in to the client.
  2. Go to Retail and Commerce > Channel setup > POS setup > POS profiles > Hardware profiles.
  3. Select the hardware profile that is linked to your register.
  4. On the Dual display tab, set the Dual display in use option to Yes.
  5. Go to Retail and Commerce > Retail and Commerce IT > Distribution schedule.
  6. Select the Registers (1090) job, and then select Run now.

Note

You can find the end-to-end (E2E) sample in …\RetailSDK\POS\Extensions\DualDisplaySample.

Add a new custom control for extension of the POS Dual display view

  1. Start Microsoft Visual Studio 2015 in Administrator mode.

  2. Open the ModernPOS solution from …\RetailSDK\POS.

  3. Under the POS.Extensions project, create a folder that is named DualDisplayExtension.

  4. Under the DualDisplayExtension folder, create a folder that is named CustomControl.

  5. In the CustomControl folder, create a HTML file that is named DualDisplayCustomControl.html.

    In the HTML file, you will add a data list control to show the cart details, and text controls to show the total amount, customer name, employee name, and sign-in status.

  6. Copy the following code, and paste it into the DualDisplayCustomControl.html file.

    <!DOCTYPE html>
    <html lang="en" xmlns="http://www.w3.org/1999/xhtml">
    <head>
    <meta charset="utf-8" />
    <title></title>
    </head>
    <body>
    <!-- Note: The element ID is different from the ID generated by the POS extensibility framework. This 'template' ID is not used by the POS extensibility framework. -->
    <script id="Microsoft_Pos_Extensibility_Samples_DualDisplay" type="text/html">
    <div class="height100Percent width100Percent">
        <div class="height700 width100Percent no-shrink col marginBottom20">
            <div class="col grow marginBottom48">
                <div id="dualDisplayDataListSample" data-bind="msPosDataList: cartLinesDataList">
                </div>
            </div>
        </div>
        <div class="marginBottom20">
            <h2 class="marginLeft8" data-bind="text: cartTotalAmountLabel">Total Amount:</h2>
            <h2 class="marginLeft8" data-bind="text: cartTotalAmount"></h2>
            <h2 class="marginLeft8" data-bind="text: customerNameLabel">Customer Name:</h2>
            <h2 class="marginLeft8" data-bind="text: customerName"></h2>
            <h2 class="marginLeft8" data-bind="text: customerAccountNumberLabel">Customer Account Number:</h2>
            <h2 class="marginLeft8" data-bind="text: customerAccountNumber"></h2>
            <h2 class="marginLeft8" data-bind="text: isLoggedOn() ? 'logged in' : 'logged out'"></h2>
            <h2 class="marginLeft8" data-bind="text: employeeNameLabel">Employee Name:</h2>
            <h2 class="marginLeft8" data-bind="text: employeeName"></h2>
        </div>
    </div>
    </script>
    </body>
    </html>
    

    Next, you will add the resource file that is used to localize the field name.

  7. Under the DualDisplayExtension folder, create a folder that is named Resources.

  8. Under the Resources folder, create a folder that is named Strings.

  9. Under the Strings folder, create a folder that is named en-US.

  10. In the en-US folder add a new resources file and and change the file extension and name to resources.resjson and inside the resources.resjson file copy paste the below resource strings.

    //======================================================================================================
    //======================================= Sample comment. ==============================================
    //======================================================================================================
    
    {
        //======================== extensions strings. ========================
    
        "string_0" : "ID",
        "_string_0.comment" : "Item ID label text",
    
        "string_1" : "Name",
        "_string_1.comment" : "Item name label text",
    
        "string_2" : "Quantity",
        "_string_2.comment" : "Item cost label text",
    
        "string_3" : "Discount",
        "_string_3.comment" : "Total amount label text",
    
        "string_4" : "Cost",
        "_string_4.comment" : "Item cost label text",
    
        "string_5" : "Total Amount:",
        "_string_5.comment" : "Total amount label text",
    
        "string_6" : "Customer Name:",
        "_string_6.comment" : "Customer name label text",
    
        "string_7" : "Customer Account Number:",
        "_string_7.comment" : "Customer account number label text",
    
        "string_8" : "Employee Name:",
        "_string_8.comment" : "Employee name label text"
    }
    
  11. In the CustomControl folder, create a TypeScript file (.ts file) that is named DualDisplayCustomControl.ts.

  12. Copy the following code, and paste it into the DualDisplayCustomControl.ts file. This code imports the relevant entities and context.

    import {
        DualDisplayCustomControlBase,
        IDualDisplayCustomControlState,
        IDualDisplayCustomControlContext,
        CartChangedData,
        CustomerChangedData,
        LogOnStatusChangedData
    } from "PosApi/Extend/DualDisplay";
    import { DataList, IDataListState, SelectionMode } from "PosUISdk/Controls/DataList";
    import { ObjectExtensions, StringExtensions } from "PosApi/TypeExtensions";
    import { ProxyEntities } from "PosApi/Entities";
    
  13. In the DualDisplayCustomControl.ts file, create a class that is named DualDisplayCustomControl, and extend it from the DualDisplayCustomControlBase class. You extend from the DualDisplayCustomControlBase class to get all the events that are exposed for dual display.

    export default class DualDisplayCustomControl extends DualDisplayCustomControlBase { }
    
  14. Inside the DualDisplayCustomControl class, add the following variables to get the cart, customer, and employee details.

    private static readonly TEMPLATE_ID: string = "Microsoft_Pos_Extensibility_Samples_DualDisplay";
    
    // The data list to bind against and display with cart line information.
    
    public readonly cartLinesDataList: DataList<ProxyEntities.CartLine>;
    
    // Computed values for binding against.
    
    public readonly cartTotalAmount: Computed<number>;
    public readonly customerName: Computed<string>;
    public readonly customerAccountNumber: Computed<string>;
    public readonly isLoggedOn: Computed<boolean>;
    public readonly employeeName: Computed<string>;
    
    // Labels for binding against.
    
    public readonly cartTotalAmountLabel: string;
    public readonly customerNameLabel: string;
    public readonly customerAccountNumberLabel: string;
    public readonly employeeNameLabel: string;
    
    // Observable values used for keeping track of state.
    
    private readonly_cart: Observable<ProxyEntities.Cart>;
    private readonly_cartLinesObservable: ObservableArray<ProxyEntities.CartLine>;
    private readonly_customer: Observable<ProxyEntities.Customer>;
    private readonly_loggedOn: Observable<boolean>;
    private readonly_employee: Observable<ProxyEntities.Employee>; private_selectedTenderLines: ProxyEntities.TenderLine[];
    
  15. Create a class constructor method to initialize all the variables.

    constructor(id: string, context: IDualDisplayCustomControlContext) {
        super(id, context);
    
        // Initializes labels.
    
        this.cartTotalAmountLabel = this.context.resources.getString("string_5");
        this.customerNameLabel = this.context.resources.getString("string_6");
        this.customerAccountNumberLabel = this.context.resources.getString("string_7");
        this.employeeNameLabel = this.context.resources.getString("string_8");
    
        // Initializes observable and computed values.
    
        this._cart = ko.observable(null);
        this._cartLinesObservable = ko.observableArray([]);
        this._customer = ko.observable(null);
        this._loggedOn = ko.observable(false);
        this._employee = ko.observable(null);
        this.cartTotalAmount = ko.computed(() => {
            return ObjectExtensions.isNullOrUndefined(this._cart()) ? 0.00 : this._cart().TotalAmount;
        });
        this.customerName = ko.computed(() => {
            return ObjectExtensions.isNullOrUndefined(this._customer()) ? StringExtensions.EMPTY : this._customer().Name;
        });
        this.customerAccountNumber = ko.computed(() => {
            return ObjectExtensions.isNullOrUndefined(this._customer()) ? StringExtensions.EMPTY : this._customer().AccountNumber;
        });
        this.isLoggedOn = ko.computed(() => {
            return this._loggedOn();
        });
        this.employeeName = ko.computed(() => {
            return ObjectExtensions.isNullOrUndefined(this._employee()) ? StringExtensions.EMPTY : this._employee().Name;
        });
        this.cartChangedHandler = (data: CartChangedData) => {
            this._cart(data.cart);
            this._cartLinesObservable(ObjectExtensions.isNullOrUndefined(data.cart) ?[] : data.cart.CartLines)
        };
        this.customerChangedHandler = (data: CustomerChangedData) => {
            this._customer(data.customer);
        };
        this.logOnStatusChangedHandler = (data: LogOnStatusChangedData) => {
    
            // Displays the busy indicator here, even though it's not necessary, in order to showcase and test the scenario.
    
            this.isProcessing = true;
            window.setTimeout(() => {
                this.isProcessing = false;
            }, 1000);
            this._loggedOn(data.loggedOn);
            this._employee(data.employee);
        }
    
        // Initializes the cart lines data list
    
        let cartLinesDataListOptions: IDataListState<ProxyEntities.CartLine> = {
            selectionMode: SelectionMode.None,
            itemDataSource: this._cartLinesObservable,
            columns:[
                {
                    title: context.resources.getString("string_0"), // ID
                    ratio: 20,
                    collapseOrder: 2,
                    minWidth: 50,
                    computeValue: (cartLine: ProxyEntities.CartLine): string => {
                        return ObjectExtensions.isNullOrUndefined(cartLine.ItemId) ? StringExtensions.EMPTY : cartLine.ItemId;
                    }
                },
                {
                    title: context.resources.getString("string_1"), // Name
                    ratio: 50,
                    collapseOrder: 4,
                    minWidth: 100,
                    computeValue: (cartLine: ProxyEntities.CartLine): string => {
                        return ObjectExtensions.isNullOrUndefined(cartLine.Description) ? StringExtensions.EMPTY : cartLine.Description;
                    }
                },
                {
                    title: context.resources.getString("string_2"), // Quantity
                    ratio: 10,
                    collapseOrder: 3,
                    minWidth: 50,
                    computeValue: (cartLine: ProxyEntities.CartLine): string => {
                        return ObjectExtensions.isNullOrUndefined(cartLine.Quantity) ? StringExtensions.EMPTY : cartLine.Quantity.toString();
                    }
                },
                {
                    title: context.resources.getString("string_3"), // Discount
                    ratio: 10,
                    collapseOrder: 1,
                    minWidth: 50,
                    computeValue: (cartLine: ProxyEntities.CartLine): string => {
                        return ObjectExtensions.isNullOrUndefined(cartLine.DiscountAmount) ? StringExtensions.EMPTY : cartLine.DiscountAmount.toString();
                    }
                },
                {
                    title: context.resources.getString("string_4"), // Cost
                    ratio: 10,
                    collapseOrder: 5,
                    minWidth: 50,
                    computeValue: (cartLine: ProxyEntities.CartLine): string => {
                        return ObjectExtensions.isNullOrUndefined(cartLine.TotalAmount) ? StringExtensions.EMPTY : cartLine.TotalAmount.toString();
                    }
                }
            ]
        };
        this.cartLinesDataList = new DataList(cartLinesDataListOptions);
    
        // Logs the completion of constructing the DualDisplayCustomControl.
    
        this.context.logger.logInformational("DualDisplayCustomControl constructed", this.context.logger.getNewCorrelationId());
    }
    
  16. Add the onReady method to bind the control to the specified HTML element.

    /**
    * Binds the control to the specified element.
    * @param {HTMLElement} element The element to which the control should be bound.
    */
    
    public onReady(element: HTMLElement): void {
        ko.applyBindingsToNode(element, {
            template: {
                name: DualDisplayCustomControl.TEMPLATE_ID,
                data: this
            }
        });
    }
    
  17. Add the init method to initialize all the controls.

    /**
    * Initializes the control.
    * @param {IDualDisplayCustomControlState} state The initial state of the page used to initialize the control.
    */
    
    public init(state: IDualDisplayCustomControlState): void {
        this._cart(state.cart);
        this._customer(state.customer);
        this._loggedOn(state.loggedOn);
        this._employee(state.employee)
    }
    

    Here is what the overall class should look like.

    import {
        DualDisplayCustomControlBase,
        IDualDisplayCustomControlState,
        IDualDisplayCustomControlContext,
        CartChangedData,
        CustomerChangedData,
        LogOnStatusChangedData
    } from "PosApi/Extend/DualDisplay";
    import { DataList, IDataListState, SelectionMode } from "PosUISdk/Controls/DataList";
    import { ObjectExtensions, StringExtensions } from "PosApi/TypeExtensions";
    import { ProxyEntities } from "PosApi/Entities";
    export default class DualDisplayCustomControl extends DualDisplayCustomControlBase {
        private static readonly TEMPLATE_ID: string = "Microsoft_Pos_Extensibility_Samples_DualDisplay";
    
        // The data list to bind against and display with cart line information.
    
        public readonly cartLinesDataList: DataList<ProxyEntities.CartLine>;
    
        // Computed values for binding against.
    
        public readonly cartTotalAmount: Computed<number>;
        public readonly customerName: Computed<string>;
        public readonly customerAccountNumber: Computed<string>;
        public readonly isLoggedOn: Computed<boolean>;
        public readonly employeeName: Computed<string>;
    
        // Labels for binding against.
    
        public readonly cartTotalAmountLabel: string;
        public readonly customerNameLabel: string;
        public readonly customerAccountNumberLabel: string;
        public readonly employeeNameLabel: string;
    
        // Observable values used for keeping track of state.
    
        private readonly_cart: Observable<ProxyEntities.Cart>;
        private readonly_cartLinesObservable: ObservableArray<ProxyEntities.CartLine>;
        private readonly_customer: Observable<ProxyEntities.Customer>;
        private readonly_loggedOn: Observable<boolean>;
        private readonly_employee: Observable<ProxyEntities.Employee>;
        constructor(id: string, context: IDualDisplayCustomControlContext) {
            super(id, context);
    
            // Initializes labels.
    
            this.cartTotalAmountLabel = this.context.resources.getString("string_5");
            this.customerNameLabel = this.context.resources.getString("string_6");
            this.customerAccountNumberLabel = this.context.resources.getString("string_7");
            this.employeeNameLabel = this.context.resources.getString("string_8");
    
            // Initializes observable and computed values.
    
            this._cart = ko.observable(null);
            this._cartLinesObservable = ko.observableArray([]);
            this._customer = ko.observable(null);
            this._loggedOn = ko.observable(false);
            this._employee = ko.observable(null);
            this.cartTotalAmount = ko.computed(() => {
                return ObjectExtensions.isNullOrUndefined(this._cart()) ? 0.00 : this._cart().TotalAmount;
            });
            this.customerName = ko.computed(() => {
                return ObjectExtensions.isNullOrUndefined(this._customer()) ? StringExtensions.EMPTY : this._customer().Name;
            });
            this.customerAccountNumber = ko.computed(() => {
                return ObjectExtensions.isNullOrUndefined(this._customer()) ? StringExtensions.EMPTY : this._customer().AccountNumber;
            });
            this.isLoggedOn = ko.computed(() => {
                return this._loggedOn();
            });
            this.employeeName = ko.computed(() => {
                return ObjectExtensions.isNullOrUndefined(this._employee()) ? StringExtensions.EMPTY : this._employee().Name;
            });
            this.cartChangedHandler = (data: CartChangedData) => {
                this._cart(data.cart);
                this._cartLinesObservable(ObjectExtensions.isNullOrUndefined(data.cart) ?[] : data.cart.CartLines)
            };
            this.customerChangedHandler = (data: CustomerChangedData) => {
                this._customer(data.customer);
            };
            this.logOnStatusChangedHandler = (data: LogOnStatusChangedData) => {
    
                // Displays the busy indicator here, even though it's not necessary, in order to showcase and test the scenario.
    
                this.isProcessing = true;
                window.setTimeout(() => {
                    this.isProcessing = false;
                }, 1000);
                this._loggedOn(data.loggedOn);
                this._employee(data.employee);
            }
    
            // Initializes the cart lines data list
    
            let cartLinesDataListOptions: IDataListState<ProxyEntities.CartLine> = {
                selectionMode: SelectionMode.None,
                itemDataSource: this._cartLinesObservable,
                columns:[
                    {
                        title: context.resources.getString("string_0"), // ID
                        ratio: 20,
                        collapseOrder: 2,
                        minWidth: 50,
                        computeValue: (cartLine: ProxyEntities.CartLine): string => {
                            return ObjectExtensions.isNullOrUndefined(cartLine.ItemId) ? StringExtensions.EMPTY : cartLine.ItemId;
                        }
                    },
                    {
                        title: context.resources.getString("string_1"), // Name
                        ratio: 50,
                        collapseOrder: 4,
                        minWidth: 100,
                        computeValue: (cartLine: ProxyEntities.CartLine): string => {
                            return ObjectExtensions.isNullOrUndefined(cartLine.Description) ? StringExtensions.EMPTY : cartLine.Description;
                        }
                    },
                    {
                        title: context.resources.getString("string_2"), // Quantity
                        ratio: 10,
                        collapseOrder: 3,
                        minWidth: 50,
                        computeValue: (cartLine: ProxyEntities.CartLine): string => {
                            return ObjectExtensions.isNullOrUndefined(cartLine.Quantity) ? StringExtensions.EMPTY : cartLine.Quantity.toString();
                        }
                    },
                    {
                        title: context.resources.getString("string_3"), // Discount
                        ratio: 10,
                        collapseOrder: 1,
                        minWidth: 50,
                        computeValue: (cartLine: ProxyEntities.CartLine): string => {
                            return ObjectExtensions.isNullOrUndefined(cartLine.DiscountAmount) ? StringExtensions.EMPTY : cartLine.DiscountAmount.toString();
                        }
                    },
                    {
                        title: context.resources.getString("string_4"), // Cost
                        ratio: 10,
                        collapseOrder: 5,
                        minWidth: 50,
                        computeValue: (cartLine: ProxyEntities.CartLine): string => {
                            return ObjectExtensions.isNullOrUndefined(cartLine.TotalAmount) ? StringExtensions.EMPTY : cartLine.TotalAmount.toString();
                        }
                    }
                ]
            };
            this.cartLinesDataList = new DataList(cartLinesDataListOptions);
    
            // Logs the completion of constructing the DualDisplayCustomControl.
    
            this.context.logger.logInformational("DualDisplayCustomControl constructed", this.context.logger.getNewCorrelationId());
        }
    
        /**
        * Binds the control to the specified element.
        * @param {HTMLElement} element The element to which the control should be bound.
        */
    
        public onReady(element: HTMLElement): void {
            ko.applyBindingsToNode(element, {
                template: {
                    name: DualDisplayCustomControl.TEMPLATE_ID,
                    data: this
                }
            });
        }
    
        /**
        * Initializes the control.
        * @param {IDualDisplayCustomControlState} state The initial state of the page used to initialize the control.
        */
    
        public init(state: IDualDisplayCustomControlState): void {
            this._cart(state.cart);
            this._customer(state.customer);
            this._loggedOn(state.loggedOn);
            this._employee(state.employee)
        }
    }
    
  18. In the DualDisplayExtension folder, create a JavaScript Object Notation (JSON) file (.json file) that is named manifest.json.

  19. Copy the following code, and paste it into the manifest.json file. Delete the default generated code before you copy this code.

    {
        "$schema": "../manifestSchema.json",
        "name": "Pos_Extensibility_DualDisplaySample",
        "publisher": "Microsoft",
        "version": "7.2.0",
        "minimumPosVersion": "7.2.0.0",
        "components": {
            "resources": {
                "supportedUICultures": [ "en-US" ],
                "fallbackUICulture": "en-US",
                "culturesDirectoryPath": "Resources/Strings",
                "stringResourcesFileName": "resources.resjson"
            },
            "dualDisplay": {
                "customControl": {
                    "controlName": "DualDisplayCustomControl",
                    "htmlPath": "CustomControl/DualDisplayCustomControl.html",
                    "modulePath": "CustomControl/DualDisplayCustomControl"
                }
            }
        }
    }
    
  20. Open the extensions.json file under the POS.Extensions project, and update it with the DualDisplayExtension samples. In that way, the POS will include this extension during runtime.

    {
        "extensionPackages": [
            {
                "baseUrl": "SampleExtensions"
            },
            {
                "baseUrl": " SampleExtensions2"
            },
            {
                "baseUrl": " DualDisplayExtension"
            }
        ]
    }
    
  21. Open the tsconfig.json file, and comment out the extension package folders in the exclude list. The POS will use this file to include or exclude the extension. By default, the list contains all the excluded extensions. If you want to include an extension as part of the POS, add the name of the extension folder, and comment out the extension in the extension list, as shown here.

    "exclude": [
        "AuditEventExtensionSample",
        "B2BSample",
        "CustomerSearchWithAttributesSample",
        "FiscalRegisterSample",
        "PaymentSample",
        "PromotionsSample",
        "SalesTransactionSignatureSample",
        //"SampleExtensions2",
        "SampleExtensions",
        "StoreHoursSample",
        "SuspendTransactionReceiptSample"
        //"SampleExtensions",
        //"DualDisplayExtension"
    ],
    
  22. Compile and rebuild the project.

Validate the customization

  1. Sign in to Retail Modern POS by using 000160 as the operator ID and 123 as the password.
  2. On the welcome screen, select the Current transaction button.
  3. Add any item to the transaction. For example, add item number 0005.
  4. Add any customer to transaction. For example, add Karen Berg.
  5. The dual display should show the cart, total, employee, and customer details.