Managing Layouts in Tuxboard: Advanced Layout Dialog

Taking layouts in Tuxboard to the next level, we create an advanced layout dialog for building complex dashboard layouts

Written by Jonathan "JD" Danylko • Last Updated: • Tuxboard •

Three buildings with various windows

In the last post, we demonstrated how to change the layout by creating a simple layout dialog. But what if we have a number of layout rows with different types on the dashboard?

In this post, we'll create a more advanced layout dialog with the ability to add and remove layout rows. The concept behind creating this dialog will be the same as the Simple Layout Dialog we created in the previous post, but implement additional drag/drop features.

Creating the Advanced Layout Dialog

The Advanced Layout template will reside in the Index.cshtml right underneath the Simple Layout Dialog HTML. This contains the standard Bootstrap dialog we've been using throughout the examples.

<!-- Advanced Layout -->
<div class="modal fade" id="advanced-layout-dialog" tabindex="-1" role="dialog">
    <div class="modal-dialog modal-lg" role="document">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">Advanced Layout</h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
            </div>
            <div class="modal-body">
                <div class="h-100 justify-content-center text-center">
                    <i class="fas fa-spinner fa-spin fa-2x"></i>
                </div>
            </div>
            <div class="modal-footer justify-content-between">
                <div class="col">
                    <span class="error-container" hidden>
                        <small class="error-message d-inline-flex mb-0 px-2 py-1 fw-semibold text-danger-emphasis bg-danger-subtle border border-danger-subtle rounded-2"></small>
                    </span>
                </div>
                <div class="col text-end">
                    <button type="button" class="btn btn-sm btn-primary save-advanced-layout">Save Layout</button>
                    <button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
                </div>
            </div>
        </div>
    </div>
</div>

If you don't like the name of the ID's (modal id called advanced-layout-dialog or the save button called save-advanced-layout), change it to your liking, but make sure to update the consts in the common.ts file as well.

Creating the AdvancedLayoutDialog ViewComponent (C#)

The advanced layout dialog consists of two primary controls: a dropdown of the available layout types and a visual representation of the layout rows.

Pages/Shared/Components/AdvancedLayoutDialog/Default.cshtml

<div class="row">
    <form>
        <div class="offset-1 col-10 my-3">
            <select class="form-select form-select-sm row-layout-types" aria-label=".form-select-sm example">
                <option value="0" selected>Select a Row to Add</option>
                @foreach (var layoutType in Model.LayoutTypes)
                {
                    <option value="@layoutType.LayoutTypeId">
                        @Html.Raw(layoutType.Title)
                    </option>
                }
            </select>
        </div>
    </form>
</div>
<div class="row">     <div class="offset-1 col-10">         <div class="row-layout-columns">             <ul class="row-layout-list list-unstyled">                 @foreach (var row in Model.LayoutRows.OrderBy(t => t.RowIndex))                 {                     @await Component.InvokeAsync("advancedlayoutrow", row)                 }             </ul>         </div>     </div> </div>

Pages/Shared/Components/AdvancedLayoutDialog/AdvancedLayoutDialogViewComponent.cs

[ViewComponent(Name = "advancedlayoutdialog")]
public class AdvancedLayoutDialogViewComponent : ViewComponent
{
    public IViewComponentResult Invoke(AdvancedLayoutModel model)
    {
        return View(model);
    }
}

Pages/Shared/Components/AdvancedLayoutDialog/AdvancedLayoutModel.cs

public class AdvancedLayoutModel
{
    public List<LayoutRow> LayoutRows { get; set; } = new();
    public List<LayoutType> LayoutTypes { get; set; } = new();
}

The top control in the dialog is meant for adding new layout types to the display below. When selecting a layout type from the dropdown list, a service will call the getLayoutType() to return the row, and automatically add itself to the list of Layout Rows shown below it.

Why the additional advancedlayoutrow ViewComponent in the view? When we select a layout type, we need to add it's visual representation to the bottom of the layout row list. While we could create it using JavaScript, it's better to return rendered HTML.

Updating the Body of the AdvancedLayoutDialog (C#)

Similar to the SimpleLayoutDialog, the body of the dialog is retrieved through the AdvancedLayoutDialog() post method in the Index.cshtml.cs C# code.

public async Task<IActionResult> OnPostAdvancedLayoutDialog()
{
    var layoutRows = new List<LayoutRow>();

    var dashboard = await _service.GetDashboardAsync(_config);     var layouts = dashboard.GetCurrentTab().GetLayouts().FirstOrDefault();     if (layouts != null)     {         layoutRows.AddRange(layouts.LayoutRows.ToList());     }
    var layoutTypes = await _service.GetLayoutTypesAsync();
    return ViewComponent("advancedlayoutdialog", new AdvancedLayoutModel     {         LayoutRows = layoutRows,         LayoutTypes = layoutTypes     }); }

Once we have the dashboard and layout types, the AdvancedLayoutModel is built for the ViewComponent, rendered into HTML, and returned to the JavaScript.

Creating the AdvancedLayoutDialog (TypeScript)

The AdvancedLayoutDialog class is similar to the SimpleLayoutDialog class but calls to the getAdvancedLayoutDialog() in the TuxboardService. The simple class is listed below.

wwwroot/src/tuxboard/dialog/advancedLayout/advancedLayoutDialog.ts

export class AdvancedLayoutDialog extends BaseDialog {

    constructor(         selector: string,         private tuxboard: Tuxboard) {         super(selector);         this.initialize();     }
    initialize = () => {         this.getDom().addEventListener('shown.bs.modal',             () => this.loadDialog());     }
    private loadDialog = () => {         this.getService().getAdvancedLayoutDialog()             .then((data: string) => {                 this.getDom().querySelector('.modal-body').innerHTML = data;             });     }
    getService = () => this.tuxboard.getService(); }

This gives us the basics for our advanced layout.

As expected, the getAdvancedLayoutDialog() service is almost the same, but posting to a different URL.

wwwroot/src/tuxboard/services/tuxboardService.ts

private tuxAdvancedLayoutDialogUrl: string = "?handler=AdvancedLayoutDialog";
.
.
public getAdvancedLayoutDialog = () => {
    const request = new Request(this.tuxAdvancedLayoutDialogUrl,         {             method: "post",             headers: {                 'Content-Type': 'application/json',                 'RequestVerificationToken': this.getToken(),             }         });
    return fetch(request)         .then(this.validateResponse)         .then(this.readResponseAsText)         .catch(this.logError); }

Let's focus on activating the advanced layout dialog on our dashboard.

Adding the button to the Tuxbar

The Tuxbar is starting to get a little crowded. Let's move the buttons into sections as well as add the advanced layout button.

Pages/Shared/Components/Tuxbar/Default.cshtml

<div class="tuxbar btn-toolbar border border-1 bg-light p-1 mb-3 justify-content-between" role="toolbar" aria-label="Tuxbar for Tuxboard">
    <form>
        <div class="btn-group btn-group-sm">
            <button type="button" id="refresh-button" title="Refresh" class="btn btn-outline-secondary">
                <i class="fa fa-arrows-rotate"></i>
            </button>
        </div>
        <div class="btn-group btn-group-sm">
            <button type="button" id="layout-button" title="Change Layout (simple)" class="btn btn-outline-secondary">
                <i class="fa fa-table-columns"></i>
            </button>
            <button type="button" id="advanced-layout-button" title="Change Layout (advanced)" class="btn btn-outline-secondary">
                <i class="fa fa-table-list"></i>
            </button>
        </div>
        <div class="btn-group btn-group-sm mx-2">
            <span id="tuxbar-status"></span>
        </div>
    </form>
    <div class="input-group mx-3">
        <span id="tuxbar-spinner" hidden><i class="fa-solid fa-sync fa-spin"></i></span>
    </div>
</div>

Since we added a new button, we need to identify it with a string constant in the common.ts file. We'll also add a new constant to identify our advanced layout dialog as well.

export const defaultAdvancedLayoutButton = "#advanced-layout-button";

export
 const defaultAdvancedLayoutDialogSelector = "#advanced-layout-dialog";

With our constants defined, the AdvancedLayoutButton.ts can be defined in the tuxbar folder.

wwwroot/src/tuxboard/tuxbar/AdvancedLayoutButton.ts

export class AdvancedLayoutButton extends TuxbarButton {

    constructor(tb: Tuxbar, sel: string) {
        super(tb, sel);
        const element = this.getDom();         element?.removeEventListener("click", this.onClick, false);         element?.addEventListener("click", this.onClick, false);     }
    onClick = (ev: MouseEvent) => {
        const dialog = new AdvancedLayoutDialog(             defaultAdvancedLayoutDialogSelector,             this.tuxBar.getTuxboard());
        if (dialog) {             dialog.getDom().removeEventListener("hide.bs.modal", () => this.hideAdvancedLayout(dialog), false);             dialog.getDom().addEventListener("hide.bs.modal", () => this.hideAdvancedLayout(dialog), false);
            dialog.showDialog();         }     }
    hideAdvancedLayout = (dialog: AdvancedLayoutDialog) => {         this.tuxBar.getTuxboard().updateDashboard(dialog.dashboardData);     }
    getDom = () => this.tuxBar.getDom().querySelector(this.selector); }

The onClick event creates an instance of the AdvancedLayoutDialog class passing in the defaultAdvancedLayoutDialogSelector and an instance of a Tuxbar. As with the SimpleLayoutDialog, the instance of the Tuxbar is to gain access to the TuxboardService.

We also add an event to update the dashboard when the dialog is hidden (closed). The updateDashboard method will only update when data returns from the dialog.

With the AdvancedLayoutButton defined, we can add the button to the Tuxbar through the initialize() method in the Tuxbar.ts (changes in bold).

wwwroot/src/tuxboard/tuxbar/Tuxbar.ts

.
.
public
 initialize = () => {     this.controls.push(new RefreshButton(this, defaultTuxbarRefreshButton));
    this.controls.push(new SimpleLayoutButton(this, defaultSimpleLayoutButton));     this.controls.push(new AdvancedLayoutButton(this, defaultAdvancedLayoutButton));
    this.controls.push(new TuxbarMessage(this, defaultTuxbarMessageSelector));
    this.controls.push(new TuxbarSpinner(this, defaultTuxbarSpinnerSelector)); }

When built, the tuxbar should have a new look with sections along with a new Advanced Layout Dialog.

Screenshot of a tuxbar with various buttons for a tuxboard

Animation of an Advanced Layout Dialog

We can now move on to the events of the dialog.

Adding a new Layout Type

As mentioned before, the dropdown list contains all of the layout types. The first item in the dropdown is a message to the users when they select an item. Once selected, the layout type is added to the bottom of the display.

Once the dialog is loaded, events are attached to the new DOM elements.

public getDropdownTypes = () => this.getDialog().querySelector(defaultDropdownLayoutTypesSelector);
public getDropdownItems = () => this.getLayoutList().querySelectorAll(defaultDropdownLayoutRowTypeSelector);

public
 attachEvents = () => {
    const dropdown = this.getDropdownTypes() as HTMLSelectElement;     dropdown.onchange = () => {         const selected = +dropdown.selectedOptions[0].value;         dropdown.value = "0"; // reset dropdown         this.getService().getLayoutType(selected)             .then((data: string) => this.addNewRow(data))     }; };

JavaScript tip: If you have a number as a string and want to make it an integer/number, prefix the line with a '+'. This does the conversion automatically.

When selecting a layout type, we get the value they selected, reset the dropdown to the first item, and call the getLayoutType() in the tuxboardService.ts file to add the new type to the bottom of the list.

private tuxGetLayoutTypeUrl: string = "?handler=GetLayoutType";
.
.
public
 getLayoutType = (typeId: number) => {
    var postData = {         id: typeId     }
    const request = new Request(this.tuxGetLayoutTypeUrl,         {             method: "post",             body: JSON.stringify(postData),             headers: {                 'Content-Type': 'application/json',                 'RequestVerificationToken': this.getToken(),             }         });
    return fetch(request)         .then(this.validateResponse)         .then(this.readResponseAsText)         .catch(this.logError); }

The GetLayoutType() in the Index.cshtml.cs file is just as expected with ViewComponents. The advancedlayoutrow ViewComponent is called and HTML is returned with empty values except for the Layout Type.

public async Task<IActionResult> OnPostGetLayoutTypeAsync([FromBody] LayoutTypeRequest request)
{
    var layoutTypes = await _service.GetLayoutTypesAsync();
    var layoutType = layoutTypes.FirstOrDefault(e => e.LayoutTypeId == request.Id);

    return ViewComponent("advancedlayoutrow", new LayoutRow     {         LayoutRowId = Guid.Empty,         LayoutTypeId = request.Id,         RowIndex = 0,         LayoutType = layoutType     }); }

These empty values are on purpose to signify we have a new row which we'll discuss later.

Once the rendered result is returned, the HTML is added through the addNewRow() method and connects the related events to the new row.

Deleting Layout Rows

When removing layout rows, widgets may exist in the row. While one way to accomplish this is through JavaScript, a better approach would be to ask the server (C#) whether we can delete the rows or not.

The Tuxboard service was recently updated to include a new method called CanDeleteLayoutRow() which requires a Tab Id and a Layout Row Id. It returns a true meaning it can be deleted or false if it can't be deleted.

Pages/Index.cshtml.cs

.
.
public
 async Task<IActionResult> OnPostCanDeleteLayoutRowAsync([FromBody] CanDeleteRequest request) {     var result = await _service.CanDeleteLayoutRowAsync(request.TabId, request.LayoutRowId);
    return result         ? new OkObjectResult(new CanDeleteResponse(request.LayoutRowId, string.Empty))         : new ConflictObjectResult(new CanDeleteResponse(request.LayoutRowId,             "Cannot delete Layout Row. Row contains widgets.")); }

The result returns either an OK or a Conflict. The CanDeleteResponse returns the Layout Row Id and a message. If OK, a message isn't returned.

If we move to our AdvancedLayoutDialog.ts file, the attachEvents() method requires a way to delete the row. In this example, we add a trash can at the end of our layout type row and add a click event to the icon.

attachDeleteEvents = (buttons: NodeListOf<Element>) => {
    [].forEach.call(buttons,
        (item: HTMLElement) => {
            item.removeEventListener('click', this.deleteRowEvent, false);
            item.addEventListener('click', this.deleteRowEvent, false);
        }
    );
}

deleteRowEvent
 = (ev: Event) => {     const row = (ev.target as HTMLElement).closest('li');     const tabId = this.tuxboard.getTab().getCurrentTabId();
    this.clearErrorMessage();
    if (row && this.canDelete()) {         var rowId = row.getAttribute(dataIdAttribute);         this.getService().canDeleteLayoutRow(tabId, rowId)             .then(response => {                 if (response.ok) {                     this.clearErrorMessage();                     row.remove();                 }                 return response;             })             .then(response => response.json())             .then(data => {                 if (data && data.message !== "") {                     this.setErrorMessage(data.message);                 }             })     } }

setErrorMessage = (message: string) => {     const container = this.getErrorContainer();     if (container) {         const msg = this.getErrorMessage();         if (msg) {             msg.innerHTML = message;             showElement(container);         }     } }
clearErrorMessage
 = () => {     const container = this.getErrorContainer();     if (container) {         hideElement(container);     } }

Once the list of delete buttons are collected, we pass them into the attachDeleteEvents() method. When a trashcan is clicked, we check to see if there's more than one Layout Row. If there's only one, you cannot delete the last layout row. There should always be at least one layout row.

The deleteRowEvent is a bit confusing so let's break it down. Once we have the tab id and layout row id, we have to clear the error message. Next, we check to see if we can actually delete the row if there's more than one.

If we can delete the row (client-side ask), we make a call to then ask the server if we can delete the row (does it have any widgets in any of the layout rows?). If we receive an OK from the server, we clear the error message and remove the row. If we receive a conflict, we convert the result into a JSON object and display the error message.

Saving the Advanced Layout

With a user moving layout rows up and down, it can be a tedious task to track what changes the user made to update their dashboard.

The advanced layout dialog makes an effort to take a snapshot of the layout defined by the user and tries to reconcile this snapshot with the existing layout.

The snapshot consists of a list of LayoutItems and contained inside a LayoutModel.

wwwroot/src/tuxboard/advancedLayout/LayoutItem.ts

export class LayoutItem {
    constructor(
        public Index: number,
        public LayoutRowId: string,
        public TypeId: number
    ) { }
}

wwwroot/src/tuxboard/advancedLayout/LayoutModel.ts

export class LayoutModel {
    constructor(public LayoutList: LayoutItem[], public TabId: string) { }
}

In the AdvancedLayoutDialog.ts, the LayoutModel is created through the getLayoutModel() method as shown below.

private getLayoutModel = () => {
    const tabId = this.tuxboard.getTab().getCurrentTabId();
    const layoutData = new Array<LayoutItem>();
    [].forEach.call(this.getLayoutListItems(), (liItem: HTMLLIElement, index: number) => {
        const rowTypeId = +liItem.getAttribute('data-row-type');
        let id = liItem.getAttribute(dataIdAttribute);
        if (!id) id = '0';
        layoutData.push(new LayoutItem(index, id, rowTypeId));
    });

    return new LayoutModel(layoutData, tabId); }

The process starts by getting the current dashboard tab and creating a new array of type LayoutItem. Based on the layout of the rows in the dialog, we get the layout row type through HTML attributes, what is the id of the layout row, and the index in the list. Once we have our list of LayoutItems, we pass them into the LayoutModel constructor along with the tab id.

Once we have the layout model, the saveLayout() method in the AdvancedLayoutDialog.ts passes the layout model to the service.

private saveLayout = () => {
    const model = this.getLayoutModel();
    this.getService().saveAdvancedLayout(model)
        .then((data:string) => {
            this.tuxboard.updateDashboard(data);
            this.hideDialog();
        });
}

The saveAdvancedLayout() posts the data to the C# SaveAdvancedLayout method.

private tuxSaveAdvancedLayoutUrl: string = "?handler=SaveAdvancedLayout";
.
.
public
 saveAdvancedLayout = (model: LayoutModel) => {
    var postData =     {         TabId: model.TabId,         LayoutList: model.LayoutList     }
    const request = new Request(this.tuxSaveAdvancedLayoutUrl,         {             method: "post",             body: JSON.stringify(postData),             headers: {                 'Content-Type': 'application/json',                 'RequestVerificationToken': this.getToken(),             }         });
    return fetch(request)         .then(this.validateResponse)         .then(this.readResponseAsText)         .catch(this.logError); }

On the C# side, the JavaScript LayoutModel turns into an AdvancedLayoutRequest with similar types.

public async Task<IActionResult> OnPostSaveAdvancedLayoutAsync([FromBody] AdvancedLayoutRequest request)
{
    await _service.SaveLayoutAsync(request.TabId, request.LayoutList
        .Select(e =>
            new LayoutOrder
            {
                LayoutRowId = e.LayoutRowId.Equals(Guid.Empty)
                    ? new Guid()
                    : e.LayoutRowId,
                TypeId = e.TypeId,
                Index = e.Index
            })
        .ToList());

    var dashboard = await _service.GetDashboardAsync(_config);
    return ViewComponent("tuxboardtemplate", dashboard); }

The AdvancedLayoutRequest class consists of the JavaScript equivalents...only in C#.

public class AdvancedLayoutRequest
{
    public Guid TabId { get; set; }
    public List<AdvancedLayoutItem> LayoutList { get; set; } = new();
}

public
 class AdvancedLayoutItem {     public Guid LayoutRowId { get; set; }     public int Index { get; set; }     public int TypeId { get; set; } }

The SaveLayoutAsync and SaveLayout takes a Tab Id and a list of type LayoutOrder. Once these are sent back to the service, they are synced up with the existing layout rows.

  • Add a Layout Row - If a LayoutRowId is empty ("") or an empty Guid ("00000000-0000-0000-0000-000000000000"), a new Layout Row will be added.
  • Remove a Layout Row - If a Layout Row is saved in the database and not found in the LayoutOrder list and doesn't contain widgets, the Layout Row will be deleted.
  • Update an Existing Layout Row - If an existing Layout Row was found, the Layout Row is updated with the Index and the LayoutTypeId and saved.

Once everything is synced up, the dashboard is loaded, is rendered through the tuxboardtemplate, and returns the new dashboard layout.

Dragging and Dropping the Layout Rows

The advanced layout dialog implements a drag and drop way to move layout rows up and down while displaying a placeholder. The setup is a bit different from dragging widgets on the dashboard. The layout rows are meant to only move vertically and not horizontally. This makes it a bit more challenging.

The initLayoutDragAndDrop() adds the mousedown event to all ListItems.

public initLayoutDragAndDrop = () => {

    const liList = this.getLayoutListItems();
    [].forEach.call(liList,         (liItem: HTMLElement) => {             const handle = liItem.querySelector(defaultLayoutRowItemHandleSelector);             handle.addEventListener("mousedown", (me: MouseEvent) => this.handleMouseDown(me), false);         }     ); }

Once the mouseDown events are assigned, the handleMouseDown() method begins the process once a list item starts to move.

The dragging and placeholder elements are controlled through private variables at the top of the class.

private draggingElement;
private dragX: number = 0;
private dragY: number = 0;
private placeHolder: HTMLLIElement;
private isDraggingStarted: boolean = false;

The draggingElement is the list item we wanted to move where dragX and dragY is the location of where we're moving the list item. placeholder is a visual, empty list item to show where the draggingElement could go when dropped. Finally, the isDraggingStarted identifies whether we are dragging or not.

public handleMouseDown = (ev: MouseEvent) => {
    const dragHandle = ev.target as Element;
    this.draggingElement = dragHandle.closest('li') as HTMLLIElement;

    const rect = this.draggingElement.getBoundingClientRect();     this.dragX = ev.pageX - rect.left;     this.dragY = ev.pageY - rect.top;
    document.addEventListener('mousemove', this.handleMouseMove)     document.addEventListener('mouseup', this.handleMouseUp) }

Once the mouse down event is triggered, we get the draggingElement and the dragX and dragY coordinates and assign mousemove and mouseup. If the mouse button is up, we reset the dragX, dragY, and draggingElement while removing the mousemove and mouseup events (as shown in the handleMouseUp method).

The majority of activity occurs in the handleMouseMove event (as shown below).

public handleMouseMove = (ev: MouseEvent) => {

    const draggingRect = this.draggingElement.getBoundingClientRect();     const parentRect = this.getLayoutList().getBoundingClientRect();
    if (!this.isDraggingStarted) {         this.isDraggingStarted = true;
        if (!this.placeHolder) {             this.placeHolder = this.createPlaceholder();         }         this.draggingElement.parentNode.insertBefore(             this.placeHolder,             this.draggingElement.nextSibling         );
        this.placeHolder.style.height = `${draggingRect.height}px`;     }
    this.draggingElement.style.position = 'absolute';     this.draggingElement.style.top = `${(ev.pageY - this.dragY) / 2}px`;     this.draggingElement.style.left = parentRect.left;     this.draggingElement.style.width = "75%";
    const previousElement = this.draggingElement.previousElementSibling;     const nextElement = this.placeHolder.nextElementSibling;
    if (previousElement && this.isAbove(this.draggingElement, previousElement)) {         this.swap(this.placeHolder, this.draggingElement);         this.swap(this.placeHolder, previousElement);     }
    if (nextElement && this.isAbove(nextElement, this.draggingElement)) {         this.swap(nextElement, this.placeHolder);         this.swap(nextElement, this.draggingElement);     } }

When first dragging a list item, the initialization process is required to set the isDraggingElement to true and create and insert a placeholder. Once initialized, we compare the draggingElement with the previous and next element sibling to swap the placeholder with the draggingElement.

After moving the list items around, the saveLayout() method takes care of the order by saving everything.

Conclusion

In this post, we demonstrated how to add, remove, and organize layout rows for a Tuxboard dashboard by creating an Advanced Layout Dialog.

One note regarding these two dialogs: use one or the other based on your needs. If you want a simple layout for your users with one layout row, implement the SimpleLayoutDialog(). If you have a more complex layout with various layout types, use the AdvancedLayoutDialog().

There really wouldn't be a need for both dialogs and would confuse users making for a bad user experience.

Did you like this content? Show your support by buying me a coffee.

Buy me a coffee  Buy me a coffee
Picture of Jonathan "JD" Danylko

Jonathan "JD" Danylko is an author, web architect, and entrepreneur who's been programming for over 30 years. He's developed websites for small, medium, and Fortune 500 companies since 1996.

He currently works at Insight Enterprises as an Architect.

When asked what he likes to do in his spare time, he replies, "I like to write and I like to code. I also like to write about code."

comments powered by Disqus