Smoothing out a few wrinkles

Introduction

Before tackling the next major challenge of printing and emailing purchase orders, there are a few wrinkles that could do with smoothing out.  The ones that are nagging me are:

  • Position and sequence of buttons is inconsistent.
  • Data entry of numbers is awkward (quantity and unit price on purchase order lines, for example.)
  • Product drop-down list would benefit from showing the product description as well as the code.
  • Prevent an order being saved without a supplier being selected.
  • An order can be saved without order lines - ask the user for confirmation.
  • Add the ability to archive (delete) an order.

Button position and sequence

So far, on the maintenance forms I have positioned the buttons in the bottom right - or rather that seemed to be the default position - with the 'Cancel' on the left, whereas on the purchase order entry form the buttons are positioned in the bottom left with the 'Save' button on the left.

There is some debate as to the optimum position and sequence of buttons (Artem Syzonenko (2019) and Luke Wroblewski (2007)), the main conclusions being that buttons should be easily discoverable and consistent.  There was some consensus that buttons should be positioned in the bottom left of the form with the primary button also on the left. Having tried some forms with this arrangement I'm not completely convinced.  My preference is to be completely consistent by placing the buttons in the bottom right corner of the form, but with the primary button on the left.  By placing the primary button in the left it has the advantage that should the user be using the tab key to move through the fields reaches the 'Save' button first.

With this in mind, most of the maintenance forms will need to be modified!  Luckily this only involves swapping the order of the buttons.  Taking the ProductPage as an example, it is simply a matter of changing:

<div class="e-footer-content">
    <div class="button-container">
          <button type="button" class="e-btn e-normal" @onclick="@CloseDialog">Cancel</button>
          <button type="submit" class="e-btn e-normal e-primary">Save</button>
     </div>
</div>

to:

<div class="e-footer-content">
    <div class="button-container">
          <button type="submit" class="e-btn e-normal e-primary">Save</button>
          <button type="button" class="e-btn e-normal" @onclick="@CloseDialog">Cancel</button>
     </div>
</div>

This will need repeating for:

  • TaxPage.razor
  • SupplierPage.razor
  • The dialog called "DialogAddEditOrderLine" on PurchaseOrderPage.razor

This leaves the position of the buttons on PurchaseOrderPage.razor.  This needs moving from the left side of the page to the right.  This can be achieved by modifying the footer div. (Or the button-container div; it doesn't seem to matter which.)

<div class="e-footer-content" style="text-align: right; width: 100%;">

Number Data Entry

Tabbing through the Purchase Order Line dialog, entering Quantity and Unit Price can be very awkward.  There are a number of reasons for this, which combined can cause a lot of data entry difficulty.

EnabledRtl

Probably the main reason for difficulty entering data is that within the Purchase Order Line dialog I have an attribute on the numeric text box fields called 'EnableRtl' set to 'true'.  I alighted on this because I thought this set the alignment within the text box to right-aligned (which it does - Syncfusion forum answer), however, it also alters how data it entered.  It means 'Right to Left' and is intended for languages such as Persian, Arabic, Hebrew, Urdu, etc. where the flow is from right to left, not left to right as in English.  Step one is therefore to remove this attribute from all Numeric textboxes. But this will revert the text alignment to left aligned. How do we change the alignment to right-aligned?

text-align

After more investigation the answer was found in the Syncfusion documentation.  We need to add a CssClass attribute to the numeric textboxes we want right-aligned, with the following:

CssClass="e-style"

For example, for Quantity the SfNumericTextBox definition should look similar to this (at this stage):

            <SfNumericTextBox Enabled="true" Placeholder="Quantity"
                              FloatLabelType="@FloatLabelType.Always"
                              ShowSpinButton="false"
                              Format="n0"                              
                              @bind-Value="addeditOrderLine.POLineProductQuantity"
                              @onfocusout='@POLineCalc'
                              CssClass="e-style">
            </SfNumericTextBox>

To define 'e-style', add this to the <style> section .

    .e-numeric.e-style .e-control.e-numerictextbox {
        text-align:right;
        padding:0px 5px 0px 0px;
    }

I have added the padding so that there is a small gap between the number entered and the edge of the textbox.

Add the CssClass to every numeric textbox.

Number of Decimals

The next flaw relates to a mis-match between the numeric text field attributes and the table definition for the columns in SQL.  For Quantity I have the column defined in SQL as 'Decimal (9,3)'.  This means the total number of digits, (both sides of the decimal point) is 9, with 3 digits to the right of the decimal point.  For Unit Price I have the column definition in SQL as 'money'.

For the Quantity numeric textbox this means that although the display attribute is 'n0' (i.e. no decimal places), the user can, in fact, type in up to 3 decimal places.  Amongst other problems, this could cause the maths to appear to be wrong, as the Quantity displayed would be rounded to the nearest whole number.  However, although the underlying database column allows 3 decimal places, we can restrict the number that will be saved when the user moves away from the textbox by using the 'Decimal' attribute of the Syncfusion NumericTextBox.  To ensure that the user cannot save any decimal places we need to add 'Decimals="0"' to the attributes.  (If the user enters any decimal places the number will be rounded to the nearest whole number.)

Similarly, although the SQL money type allows 4 decimal places we can restrict the Unit Price to 2 decimal places by using the same attribute, setting it to 2.

But there is one last attribute that needs setting to prevent the user entering more decimal places than we want.  This is 'ValidateDecimalOnType'.  If this is set to 'true', the user will be prevented from entering more decimal places than we want.

In the case of Net Price and other fields that are not enabled, there is no need to use the Decimals or ValidateDecimalOnType attributes.

With that, data entry in numeric textboxes should behave in an expected manner - and I thought this was going to be simple!

Product Dropdown List

This was prompted by a suggestion in the Comments about one of the accompanying YouTube videos, and is one I wholeheartedly endorse.  Currently the Product Dropdown List shows the product codes; if the user doesn't have an intimate knowledge of which product codes apply to which products they are not going to be able to chose the correct product code.  To get round this problem we should include the product description as well as the code in the product dropdown list.

I decided to follow the Header Template example given in the Syncfusion documentation, although I had to make a few changes to get the exact effect I wanted.  The basic idea is to use DropDownListTemplates. The html code I used is shown below (but see the CSS class notes further on).

The DropDownListTemplate tag has a TItem of 'Product', the class providing the data for the dropdown list.  Within the DropDownListTemplates tags there are two further tags, one for the HeaderTemplate and one for ItemTemplate.  Taking the HeaderTemplate first, there is an overall <span> with a class called 'head', within which are another two <span> sections, one for the product code and the other for description, each surrounding the actual headings of 'Code' and 'Description'.  The purpose of the <span> classes is to use the associated css styling.

The 'ItemTemplate' follows a similar pattern with an overall tag of 'item', within which are 'productcode' and 'description' <span> sections.  I had to vary the Syncfusion example by specifically giving the ItemTemplate a context name - which I, imaginatively, called 'contextName' but could be anything. 

The remainder of the code is to control the appearance, and this took a bit of trial and error to get to work as I wanted.  I wanted two well-defined columns with both the Code and Description left-aligned within their respective columns.  The following code, which should be inserted within the <style> tags seems to achieve this.  In this case I have split the overall dropdown list 25% for the code and 75% for description. 

    .head, .item {
        display: table;
        width: 100%;
        margin: auto;
        text-align: left;
    }

    .head {
        height: 30px;
        font-size: 15px;
        font-weight: 600;
    }

    .productcode {
        display: table-cell;
        vertical-align: middle;
        text-align: left;
        width: 25%;
    }

    .description {
        display: table-cell;
        vertical-align: middle;
        text-align: left;
        width: 75%;
    }

    .head .productcode {
        text-indent: 17px;
    }

    .head .description {
        text-indent: 14px;
    }

Code for the DropDownListTemplates is below:

<DropDownListTemplates TItem="Product">
    <HeaderTemplate>
        <span class='head'><span class='productcode'>Code</span>
        <span class='description'>Description</span></span>
    </HeaderTemplate>
    <ItemTemplate Context="contextName">
        <span class='item'><span class='productcode'>@((contextName as Product).ProductCode)</span>
        <span class='description'>@((contextName as Product).ProductDescription)</span></span>
    </ItemTemplate>
</DropDownListTemplates>

Prevent order being saved with no supplier

I was alarmed to discover that, although I had DataAnnotationsValidator in place, it was possible to save a purchase order without first selecting a supplier.  I assume that the DataAnnotations was not operating because the supplier ID was 'hidden' behind the dropdown list, although I'm not really sure.

Rather than battle with this I decided the expedient approach was to test that a supplier had been selected at the start of the OrderSave method.  We can check for the existence of a supplier and then use the existing Warning component to inform the user and stop the save, as shown below:

        if (orderaddedit.POHeaderSupplierID == 0)
        {
            WarningHeaderMessage = "Warning!";
            WarningContentMessage = "Please Select a Supplier before saving the order.";
            Warning.OpenDialog();
            return;
        }

This displays the warning dialog with the appropriate wording and then uses 'return;' to stop the method.

No order lines have been entered?

I considered whether to prevent orders being saved if there were no order lines, but in the end decided that perhaps the best way of handling this situation is to ask the user to confirm that they want to save an order with no order lines.

To achieve this we are going to use a modal dialog with two options; one to save the order, the other to cancel the save.

We already have dialogs asking for confirmation for the deletion of products, tax rates, suppliers and order lines. In the case of products, tax rates and suppliers the record is displayed in the confirmation dialog, and since these are important records having specific dialogs is justified.  But for some circumstances it makes more sense to use a generic 'Confirm/Cancel' dialog that can be used in multiple situations, in a similar way to our generic 'Warning' dialog.

I am indebted for both inspiration and code to Pragim Technologies without which I doubt I would have got this working.

Start by selecting the 'Shared' folder and add a new Blazor component; call it 'ConfirmPage.razor'.  Replace the default core with the code shown below. 

<SfDialog @ref="DialogConfirm" @bind-Visible="@IsVisible"   
          AllowDragging="true" IsModal="true" 
          Width="500px" ShowCloseIcon="true">
    <DialogTemplates>
        <Header> @ConfirmHeaderMessage </Header>
        <Content>@ConfirmContentMessage</Content>
    </DialogTemplates>
    <DialogButtons>
        <DialogButton Content="Confirm" IsPrimary="true" OnClick="() => OnConfirmationChange(true)" />
        <DialogButton Content="Cancel" IsPrimary="false" OnClick="() => OnConfirmationChange(false)"/>
    </DialogButtons>
</SfDialog>

@code {
    SfDialog DialogConfirm;
    public bool IsVisible { get; set; } = false;

    [Parameter] public string ConfirmHeaderMessage { get; set; }
    [Parameter] public string ConfirmContentMessage { get; set; }

    [Parameter]
    public EventCallback<bool> ConfirmationChanged { get; set; }

    public void OpenDialog()
    {
        this.IsVisible = true;
        this.StateHasChanged();
    }

    protected async Task OnConfirmationChange(bool value)
    {
        this.IsVisible = false;
        await ConfirmationChanged.InvokeAsync(value);
    }

}

The html section has header and content templates, to which variables for the text to be displayed can be passed from the calling page.  There are two buttons both of which trigger the 'OnConfirmationChange' method; the confirm button with a value of true, the cancel with a value of false.  In turn, the OnConfirmationChange method fires an EventCallback with a boolean, true for confirm, false for cancel, and then makes the dialog invisible.

In the PurchaseOrderPage we need to insert the confirmation dialog.  To do this paste the following at the end of the html section.

<ConfirmPage @ref="ConfirmSaveOrder" ConfirmHeaderMessage="@ConfirmHeaderMessage" ConfirmContentMessage="@ConfirmContentMessage" ConfirmationChanged="OrderSaveProcess" />

We need to declare the component itself, the messages to be passed to the component and the ConfirmationChanged boolean being returned by the dialog.  Paste the following into the code section:

    ConfirmPage ConfirmSaveOrder;
    string ConfirmHeaderMessage = "";
    string ConfirmContentMessage = "";
    public bool ConfirmationChanged { get; set; } = false;

I suspect the next step could be improved, but after much trial and error this was the tactic I ended up using.

Create a new method called 'OrderSaveProcess' and cut the bulk of the code from 'OrderSave' and paste into the new method.  Note that a boolean variable called 'saveConfirmed' is passed to the method and the code only executed if 'saveConfirmed' is true.

    protected async Task OrderSaveProcess(bool saveConfirmed)
    {
        if (saveConfirmed) {

            if (POHeaderID == 0)
            {
                //Save the record - 1st - the POHeader

                int HeaderID = await POHeaderService.POHeaderInsert(
                               orderaddedit.POHeaderOrderDate,
                               orderaddedit.POHeaderSupplierID,
                               orderaddedit.POHeaderSupplierAddress1,
                               orderaddedit.POHeaderSupplierAddress2,
                               orderaddedit.POHeaderSupplierAddress3,
                               orderaddedit.POHeaderSupplierPostCode,
                               orderaddedit.POHeaderSupplierEmail,
                               orderaddedit.POHeaderRequestedBy
                               );

                //2nd - the POLines
                foreach (var individualPOLine in orderLines)
                {
                    individualPOLine.POLineHeaderID = HeaderID;
                    bool Success = await POLineService.POLineInsert(individualPOLine);
                }

                NavigationManager.NavigateTo("/");
            }
            else
            {
                //Order is being edited
                //POHeader
                bool Success = await POHeaderService.POHeaderUpdate(orderaddedit);

                //POLines
                foreach (var individualPOLine in orderLines)
                {
                    //If POLineHeaderID is positive it means it has been edited during the edit of this order                
                    if (individualPOLine.POLineID > 0)
                    {
                        Success = await POLineService.POLineUpdate(individualPOLine);
                    }
                    else
                    //If POLineID is negative it means it has been added during the edit of this order
                    {
                        individualPOLine.POLineHeaderID = POHeaderID;
                        Success = await POLineService.POLineInsert(individualPOLine);
                    }
                }

                foreach (var individualPOLine in OrderLinesToBeDeleted)
                {
                    Success = await POLineService.POLineDeleteOne(individualPOLine);
                }
                //Clear the list of POLines to be deleted
                OrderLinesToBeDeleted.Clear();

                NavigationManager.NavigateTo("/");
            }
        }
    }

Into what remains of 'OrderSave' add the following after the check to ensure that a supplier has been selected,

        if (orderLines.Count == 0)
        {
            ConfirmHeaderMessage = "Confirm Save!";
            ConfirmContentMessage = "There are no order lines. Please confirm order should be saved.";
            ConfirmSaveOrder.OpenDialog();
        }
        else
        {
            await OrderSaveProcess(true);
        }

The above code tests for the count of order lines and if 0 displays the confirmation dialog; if there are order lines it proceeds to call the 'OrderSaveProcess', setting the boolean to true.  If the confirmation dialog gets called it returns a boolean to indicate whether the user has confirmed deletion (true), or selected to cancel (false); this boolean is passed to the 'OrderSaveProcess', which in turn either executes or cancels the save.

Order Line Deletion

Now we have a generic confirmation dialog we ought to use it to handle confirmation that the user wishes to delete an order line, although it means back-tracking on some of our code!

The first step is to delete the existing html code for DialogDeleteOrderLine and the declaration for it.

Replace the html with:

<ConfirmPage @ref="ConfirmDeletion" ConfirmHeaderMessage="@ConfirmHeaderMessage" ConfirmContentMessage="@ConfirmContentMessage" ConfirmationChanged="ConfirmDelete" />

This looks like a duplication of the ConfirmPage for saving the order we have just added, but has a different @ref and action to handle the ConfirmationChanged callback.

Add the declaration for this.  (We don't need to re-declare header or content message or ConfirmationChanged.)

  ConfirmPage ConfirmDeletion;

In the ToolbarClickHandler method, for the Delete option replace the current code with:

        if (args.Item.Text == "Delete")
        {
            //Code for adding goes here
            if (selectedPOLineID == 0)
            {
                WarningHeaderMessage = "Warning!";
                WarningContentMessage = "Please select an Order Line from the grid.";
                Warning.OpenDialog();
            }
            else
            {
                ConfirmHeaderMessage = "Confirm Deletion";
                ConfirmContentMessage = "Please confirm that this order line should be deleted.";
                ConfirmDeletion.OpenDialog();
            }
        }

Instead of opening the previous delete dialog it provides text for the header and content messages and opens the new ConfirmPage component.

The previous delete dialog had two methods associated with it, 'ConfirmDeleteNo' and 'ConfirmDeleteYes' - both of these may now be deleted and replaced by a new method:

    protected void ConfirmDelete(bool deleteConfirmed)
    {
        if (deleteConfirmed)
        {
            OrderLineDelete();
            StateHasChanged();
            selectedPOLineID = 0;
        }
    }

I have included the line 'selectedPOLineID = 0;' in the branch where the user deletes the order line.  It could have been placed after the 'deleteConfirmed' section so it was always reset to 0, but I thought it looked better with the current order line still selected, but this is a personal choice.

Delete a Purchase Order

In fact we don't actually delete purchase orders, but mark them as 'archived'.  Archived orders are excluded from the purchase order grid on the index page.

We will again be using the generic confirmation page to ask the user to confirm that they really want to 'delete' the purchase order.

Open Index.razor and place the ConfirmPage in the html section as below:

<ConfirmPage @ref="ConfirmOrderDelete" ConfirmHeaderMessage="@ConfirmHeaderMessage" ConfirmContentMessage="@ConfirmContentMessage" ConfirmationChanged="ConfirmOrderArchive" />

This is basically the same as the others we used for order lines, but I have changed the '@ref' and 'ConfirmationChanged' to avoid possible confusion.  (Just a note: the '@ref' and 'ConfirmationChanged' must not have the 

Declare the page and other variables in the code section:

    ConfirmPage ConfirmOrderDelete;
    string ConfirmHeaderMessage = "";
    string ConfirmContentMessage = "";
    public bool ConfirmationChanged { get; set; } = false;

In the ToolbarClickHandler enter the following code for handling 'Delete'. This does three things

  • Checks that an order has been selected, and warns if not.
  • Populates an 'orderHeader' object with the data for the currently selected order. (We will declare 'orderHeader' later.)
  • Assigns the messages we want displayed in the header and content.
  • Opens the confirm dialog.
        if (args.Item.Text == "Delete")
        {
            //Code for deleting
            if (selectedPOHeaderID == 0)    //Check that an order has been selected
            {
                WarningHeaderMessage = "Warning!";
                WarningContentMessage = "Please select an Order from the grid.";
                Warning.OpenDialog();
            }
            else
            {
                //Populate orderHeader using selctedPOHeaderID
                orderHeader = await POHeaderService.POHeader_GetOne(selectedPOHeaderID);

                ConfirmHeaderMessage = "Confirm Deletion";
                ConfirmContentMessage = "Please confirm that this order should be deleted.";
                ConfirmOrderDelete.OpenDialog();
            }
        }

The ToolbarClickHandler will also need to be changed from 'void' to 'async Task', since the POHeaderService is awaited.

Declare the variable orderHeader by inserting the following code near the top of the code section.

POHeader orderHeader = new POHeader();

Lastly, we need to write the method triggered by 'ConfirmationChanged' when returned from the confirmation dialog.  Paste the following into the bottom of the code section:

    protected async Task ConfirmOrderArchive(bool archiveConfirmed)
    {
        if (archiveConfirmed)
        {
            orderHeader.POHeaderIsArchived = true;
            bool Success = await POHeaderService.POHeaderUpdate(orderHeader);
            poheader = await POHeaderService.POHeaderList();
            StateHasChanged();
        }
    }

This sets POHeaderIsArchived to true (for the selected order line) and then calls the POHeaderUpdate and then re=populates poheader so that the newly archived record is removed from the grid.  StateHasChanged() ensures that the page is re-rendered.

Project Code

The code for all the files changed in this post are shown here.

YouTube Video

Blazor Purchase Orders - Part 19