Managing Layouts in Tuxboard: Simple Layout Dialog

June 27th, 2024

Layouts are key when it comes to dashboards. In this post, we'll create a dialog to select a single layout type for a LayoutRow.

What's Next?

Full examples located at Tuxboard.Examples.

With our new Tuxbar, we can create buttons to allow interactivity on the dashboard.

Users like a bit of diversity so they want an ability to change their layout. Let's give them the ability to change the type of their LayoutRow.

The approach used here is similar to a previous post titled creating dynamic dialogs with Bootstrap 5.

Overview of Layout Rows and Layout Types

If we remember back to the Layout post, a LayoutRow is where all widgets reside. A LayoutType defines the columns of each LayoutRow. Layout types are defined in the LayoutType SQL Server table.

If we look at the LayoutType table, we'll see the following records.

LayoutTypeId Title Layout
1 Three Columns, Equal col-4/col-4/col-4
2 Three Columns, 50% Middle col-3/col-6/col-3
3 Four Columns, 25% col-3/col-3/col-3/col-3
4 Two Columns, 50% col-6/col-6

If the Layout strings in the last column look familiar, it's because they're Bootstrap Layout CSS classes. This allows any type of CSS framework to be used on the dashboard. As mentioned through all of these posts, all of the examples use Bootstrap, but can easily be adapted to a new CSS framework.

If creating a new Layout Type, the CSS classes are delimited by forward slashes (/). For example, let's say you want a 1-column LayoutRow. The title would be "One Column, 100%" with a Layout string of "col-12" based on the Boostrap grid layouts.

Every layout row has a layout type associated to it. A LayoutRow should ALWAYS have a LayoutType. 

Creating a Boilerplate Dialog

Tuxboard dashboards can contain one or more Layout Rows for basic layouts. This example contains only one layout row and will be easy to update.

Since everything stems from a Tuxbar along with a Tuxboard and Services, this is the best place to start.

Let's add a Bootstrap dialog to the bottom of our Index.cshtml page.

<!-- Change Layout -->
<div class="modal fade" id="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">Change 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">
                <button type="button" class="btn btn-sm btn-primary save-layout">Save Layout</button>
                <button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
            </div>
        </div>
    </div>
</div>

The .modal-body provides a FontAwesome spinner while we're loading the content. When the content is available, we replace the .modal-body with content from the response.

Keep in mind the name of the DOM element. In this case, it's "#layout-dialog".

Reusing Code with a BaseDialog

One of the reasons for a base dialog class is for the reusability of the class. The BaseDialog provides common calls each example uses from this point on.

The code for the BaseDialog is listed below.

import * as bootstrap from "bootstrap";

export
 class BaseDialog {
    protected dialogBodySelector = ".modal-body";
    constructor(protected selector: string) { }
    public getDialogInstance = () => bootstrap.Modal.getOrCreateInstance(this.getDialog());     public getDom = () => this.getDialog();     public getDialog = () => document.querySelector(this.selector);
    showDialog = () => this.getDialogInstance().show();     hideDialog = () => this.getDialogInstance().hide(); }

A couple of notes for the above code:

The BaseDialog class will be used through every example from this point.

Building the SimpleLayoutDialog with TypeScript

The SimpleLayoutDialog is what hooks everything up to the C# ViewComponent DOM elements. When loaded and rendered, the events are connected and ready to display the body of the dialog.

The SimpleLayoutDialog TypeScript class is only meant to attach the events to DOM elements returned by the SimpleLayoutDialogViewComponent (in the next section).

The code for the SimpleLayoutDialog class is shown below.

export class SimpleLayoutDialog extends BaseDialog {

    dashboardData: string;
    constructor(         selector: string,         private tuxboard: Tuxboard)     {         super(selector);         this.initialize();     }
    initialize = () => {         this.getDom().addEventListener('shown.bs.modal',             () => this.loadDialog());     }
    getService = () => this.tuxboard.getService();
    public getSaveLayoutButton = () => this.getDom().querySelector(defaultSaveLayoutButtonSelector) as HTMLButtonElement;    public getLayoutList = () => this.getDom().querySelector(defaultLayoutListSelector);     public getLayoutItems = () => this.getLayoutList().querySelectorAll(defaultLayoutItemSelector);
    private loadDialog = () => {         this.getService().getSimpleLayoutDialog()             .then((data:string) => {                 this.getDom().querySelector('.modal-body').innerHTML = data;                 this.attachEvents();             });     }
    public getSelected = () => this.getDom().querySelector("li.selected");     public getSelectedId = () => this.getSelected().getAttribute('data-id');
    public getLayoutRowId = () => {         const tab = this.tuxboard.getTab();         const layoutRows = this.tuxboard.getLayoutRowCollection(tab);         return layoutRows[0].getLayoutRowId();     }
    public clearSelected = () => {         Array.from(this.getLayoutItems()).forEach((item: HTMLLIElement) => {             item.classList.remove("selected");         })     }
    public attachEvents = () => {         const items = this.getLayoutItems();         Array.from(items).forEach((item: HTMLLIElement) => {             item?.removeEventListener('click', () => { this.listItemOnClick(item); });             item?.addEventListener('click', () => { this.listItemOnClick(item); });         })
        const saveButton = this.getSaveLayoutButton();         saveButton?.removeEventListener("click", this.saveLayoutClick);         saveButton?.addEventListener("click", this.saveLayoutClick);     }
    public listItemOnClick = (item: HTMLLIElement) => {         this.clearSelected();         item.classList.add("selected");     }
    public saveLayoutClick = (ev: Event) => {         ev.preventDefault();         this.saveLayout();     }
    private saveLayout = () => {         const layoutRowId = this.getLayoutRowId();         this.getService().saveSimpleLayout(layoutRowId, this.getSelectedId())             .then((data:string) => {                 this.dashboardData = data;                 this.hideDialog();             })     } }

To display the layout types correctly, a ViewComponent is used which we'll get to in the next section.

On initial creation, we pass in the selector of the dialog box and an instance of a Tuxboard for later use into the constructor.

In the initialize() method, the modal is should load the body of the dialog by calling getSimpleLayoutDialog() in the tuxboardService.ts.

After successfully loading and setting the body of the dialog, events are connected to provide functionality for the user (through the attachEvents() method).

For now, the list of types are represented using an unordered list. The current layout type will be selected and identified with a bold title. When clicking on a list item, the click event (listItemOnClick) will clear all list items with a CSS class called "selected" (through the clearSelected() method) and add the CSS "selected" class to the selected list item.

Pressing the Save Layout button will perform a post, update the dashboard's layout, and return fully-rendered tuxboardtemplate ViewComponent.

Let's move forward by creating the body of the simple layout dialog.

Creating the Simple Layout View Component

The SimpleLayoutDialog ViewComponent is similar to the other ViewComponents in the project: View Components are located in the Pages/Shared/Components directory and the body of the dialog is located in the SimpleLayoutDialog folder containing a Default.cshtml and SimpleLayoutDialogViewComponent.cs ViewComponent.

/Pages/Shared/Components/SimpleLayoutDialog/Default.cshtml

@model SimpleLayoutModel
 
<p condition="Model?.Layouts == null" class="text-center">Layouts not available.</p>
<ul condition="Model?.Layouts != null" class="layout-list">
    @foreach (var layoutType in Model!.Layouts)
    {
        <li data-id="@layoutType.Id" class="text-center @(layoutType.Selected ? "selected": "")">
            <div condition="layoutType.Selected" class="layout-header"><strong>@layoutType.Title</strong></div>
            <div condition="!layoutType.Selected" class="layout-header">@layoutType.Title</div>
            <div class="preview-table">
                <div class="preview-row">
                    @foreach (var column in layoutType.Layout.Split('/'))
                    {
                        <div class="@column">&nbsp;</div>
                    }
                </div>
            </div>
        </li>
    }
</ul>

/Pages/Shared/Components/SimpleLayoutDialog/SimpleLayoutDialogViewComponent.cs

[ViewComponent(Name = "simplelayoutdialog")]
public class SimpleLayoutDialogViewComponent : ViewComponent
{
    public IViewComponentResult Invoke(List<LayoutTypeDto> layouts)
    {
        return View(new SimpleLayoutModel{Layouts = layouts});
    }
}

At the top of the ViewComponent HTML, there's a condition attribute which is new. This attribute was introduced through the Author Tag Helpers and called the ConditionTagHelper. When the condition is true, it will render the HTML. If not, it won't render it.

In the .preview-row DIV element, the columns are created by splitting them using the forward slash (/). It automatically applies the CSS class by column and places a non-breaking space in the row to expand each column.

In the SimpleLayoutDialogViewComponent, there are two new types we use: LayoutTypeDto and SimpleLayoutModel. The LayoutTypeDto is created by an extension method and the SimpleLayoutModel is created for ease-of-use in the HTML.

LayoutTypeDto.cs

public record struct LayoutTypeDto
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Layout { get; set; }
    public bool Selected { get; set; }
}

LayoutTypeExtension.cs

public static class LayoutTypeExtension
{
    public static LayoutTypeDto ToDto(this LayoutType type, int defaultValue) =>
        new()
        {
            Id = type.LayoutTypeId,
            Title = type.Title,
            Layout = type.Layout,
            Selected = type.LayoutTypeId.Equals(defaultValue)
        };
}

SimpleLayoutModel.cs

public class SimpleLayoutModel
{
    public List<LayoutTypeDto> Layouts { get; set; } = new();
}

All of these are simple classes/records for the display of each layout in the dialog. 

Retrieving the SimpleLayoutDialog

Similar to the Refresh button, a post is required to return back the body of the dialog box.

In the Index.cshtml.cs file, the OnPostSimpleLayoutDialog method is created.

public async Task<IActionResult> OnPostSimpleLayoutDialog()
{
    var dashboard = await _service.GetDashboardAsync(_config);
    var layouts = dashboard.GetCurrentTab().GetLayouts().FirstOrDefault();
    var currentLayout = layouts?.LayoutRows.FirstOrDefault();

    var layoutTypes = await _service.GetLayoutTypesAsync();     var result = layoutTypes.Select(e => e.ToDto(currentLayout.LayoutTypeId)).ToList();
    return ViewComponent("simplelayoutdialog", result); }

Since this example is only using one LayoutRow, it'll be easy to find once the dashboard is loaded.

As mentioned above, each layout row has a type attached to it. Once the current layout row is identified, the type (LayoutTypeId) is passed into the DTO (Data Transfer Object) to determine if it's the current one and "Selected" through the extension method.

Once the layout types are converted into DTOs, they're passed to the ViewComponent.

Adding a Button to the Tuxbar

With everything built with C#, we can now focus on creating a button to display the dialog.

First, the button is added to the Tuxbar HTML (located in the /Pages/Shared/Components/Tuxbar/Default.cshtml folder). Changes made to the HTML are in bold.

<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>
            <button type="button" id="layout-button" title="Change Layout (simple)" class="btn btn-outline-secondary">
                <i class="fa fa-table-columns"></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>

The name of the button is called "#layout-button" and is using a FontAwesome icon of fa-table-columns class to identify the icon as a simple column layout.

Next is adding the button to the Tuxbar through the initialize() method using TypeScript (found in /wwwroot/src/tuxboard/tuxbar/tuxbar.ts with changes in bold).

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

The defaultSimpleLayoutButton name is defined in common.ts and can be adjusted to whatever name you called the button in the HTML above.

As shown in the code, we now need a SimpleLayoutButton class for our dialog.

Building the SimpleLayoutButton

The final piece of this puzzle is the simple layout button code to tie everything together.

The button accomplishes two things: on click, display the simple layout dialog box and, after hiding the dialog, check to see if any updates need to be applied to the dashboard.

The code for the button is shown below.

export class SimpleLayoutButton 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 SimpleLayoutDialog(             defaultSimpleLayoutDialogSelector,             this.tuxBar.getTuxboard());
        if (dialog) {             dialog.getDom().removeEventListener("hide.bs.modal", () => this.hideSimpleLayout(dialog), false);             dialog.getDom().addEventListener("hide.bs.modal", () => this.hideSimpleLayout(dialog), false);
            dialog.showDialog();         }     }
   hideSimpleLayout = (dialog: SimpleLayoutDialog) => {         this.tuxBar.getTuxboard().updateDashboard(dialog.dashboardData);     }
    getDom = () => this.tuxBar.getDom().querySelector(this.selector); }

Since we're inheriting from a Tuxbar button, we need to pass in a Tuxbar instance and the selector of the button. In this case, we're passing in "#layout-button".

We also attach the click event which points to the onClick method in the class. If the user clicks on it, we create an instance of the SimpleLayoutDialog class, attach hide events, finally show the dialog.

The hideSimpleLayout() method passes the instance of the dialog to update the dashboard.

Conclusion

Dashboard users always have a preferred layout or design of how they want their dashboards to look and Tuxboard provides that flexibility with LayoutRows.

With the simple layout dialog, Tuxboard users can customize their own dashboard with whatever layout type they see fit. 

The next post takes LayoutRows a bit further by creating an AdvancedLayoutDialog to add even more flexibility to users.

What's Next?

Full examples located at Tuxboard.Examples.