Adding Widgets with a Tuxboard Dialog

July 1st, 2024

Tuxboard requires a way to add multiple widgets to a dashboard. In this post, we'll create an AddWidgetDialog for just such a purpose

What's Next?

Full examples located at Tuxboard.Examples.

One of the best features of a dashboard is to personalize it by adding widgets.

In this post, we'll borrow techniques from past posts and implement a new Add Widget dialog similar to how we created the Simple Layout dialog and the Advanced Layout dialog.

Creating the Boilerplate Dialog

The add widget dialog isn't any different from the other dialogs. We simply create the standard boilerplate HTML in our Tuxboard main page (which in this case is the Index.cshtml). 

Pages/index.cshtml

.
.
<!--
 Add Widget --> <div class="modal fade" id="add-widget-dialog" tabindex="-1" role="dialog">     <div class="modal-dialog" role="document">         <div class="modal-content">             <div class="modal-header">                 <h5 class="modal-title">Add Widget</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 add-widget">Add Widget</button>                 <button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>             </div>         </div>     </div> </div>

For the add widget dialog, we'll aptly name it "add-widget-dialog". Don't forget to create the constant name in the common.ts so we can refer to it.

wwwroot/src/tuxboard/common.ts

export const defaultAddWidgetDialogSelector = "#add-widget-dialog";

Once we have a way to identify the dialog, we can begin building the main body of the dialog using a ViewComponent.

Building the AddWidgetDialog ViewComponent

To create the body of our Add Widget dialog, the widgets need to have some sort of organization; we can't just throw down a large amount of widgets and expect the user to wade through every single one of them.

One of the fields in the Widget table is GroupName. This field is used to segment the widgets into common themes.

To display the widgets, we'll use a basic JavaScript Bootstrap Tabs interface. We'll place the group names on the left with a list of widgets in that group on the right.

Pages/Shared/Components/AddWidgetDialog/Default.cshtml

@model Add_Widgets.Pages.Shared.Components.AdvancedLayoutDialog.AddWidgetModel
@{
    var groups = Model.Widgets.GroupBy(e => e.GroupName);
    var groupList = groups.Select(i => i.Key);
}
<div class="d-flex align-items-start">
    <div class="nav flex-column nav-pills me-3" id="v-pills-tab" role="tablist" aria-orientation="vertical">
        @foreach (var groupName in groupList)
        {
            var normalizedGroupName = groupName.Replace(" ", "_").ToLower();
            <button class="nav-link @(groupName == Model.Widgets.First().GroupName ? "active" : "")"
                    id="v-pills-@(normalizedGroupName)-tab" data-bs-toggle="pill"
                    data-bs-target="#v-pills-@(normalizedGroupName)" type="button" role="tab"
                    aria-controls="v-pills-@(normalizedGroupName)" aria-selected="true">
                @groupName
            </button>
        }
    </div>
    <div class="tab-content flex-fill" id="v-pills-tabContent">
        @foreach (var groupName in groupList)
        {
            var normalizedGroupName = groupName.Replace(" ", "_").ToLower();
            <div class="tab-pane fade@(groupName == Model.Widgets.First().GroupName ? " show active" : "")"
                 id="v-pills-@(normalizedGroupName)" role="tabpanel" aria-labelledby="v-pills-@(normalizedGroupName)-tab" tabindex="0">
 
                <ul class="list-group">
                    @foreach (var widget in Model.Widgets.Where(e => e.GroupName == groupName))
                    {
                        <li class="list-group-item list-group-item-action" data-id="@widget.WidgetId.ToString()">
                            <div class="d-flex w-100">
                                <h5 class="mb-1">@widget.Title</h5>
                            </div>
                            <p class="mb-1 fst-italic">@widget.Description</p>
                        </li>
                    }
                </ul>
 
            </div>
        }
    </div>
</div>

Pages/Shared/Components/AddWidgetDialog/AddWidgetModel.cs

public class AddWidgetModel
{
    public List<WidgetDto> Widgets { get; set; } = new();
}

Pages/Shared/Components/AddWidgetDialog/AddWidgetViewComponent.cs

[ViewComponent(Name = "addwidgetdialog")]
public class AddWidgetDialogViewComponent : ViewComponent
{
    public IViewComponentResult Invoke(AddWidgetModel model)
    {
        return View(model);
    }
}

With our ViewComponent created, we can go back to our Index.cshtml.cs and return the body of the dialog.

Pages/index.cshtml.cs

.
.

/* Add Widget Dialog */

public
 async Task<IActionResult> OnPostAddWidgetsDialog() {     var widgets = (await _service.GetWidgetsAsync())         .Select(r=> r.ToDto())         .ToList();
    return ViewComponent("addwidgetdialog", new AddWidgetModel { Widgets = widgets }); }

We retrieve all of the widgets available, build our DTO (Data Transfer Objects) to pass them on to the AddWidgetDialog ViewComponent, and pass the result back to our JavaScript.

Building the AddWidgetDialog class in TypeScript

Our AddWidgetDialog class is very similar to other dialogs and require only a couple events to implement the functionality.

As expected, the class is small, but definitely an easy dialog to implement.

wwwroot/src/tuxboard/dialog/addWidget/AddWidgetDialog.ts

export class AddWidgetDialog extends BaseDialog {

    allowRefresh: boolean = false;
    constructor(selector: string, private tuxboard: Tuxboard) {         super(selector);         this.initialize();     }
    initialize = () => {         this.getDom().addEventListener('shown.bs.modal',             () => this.loadDialog());     }
    getService = () => this.tuxboard.getService();
    public getAddWidgetButton = () => this.getDom().querySelector(defaultAddButtonSelector) as HTMLButtonElement;     public getWidgetItems = () => this.getDom().querySelectorAll(defaultWidgetListItemSelector);
    private loadDialog = () => {         this.getService().getAddWidgetDialog()             .then((data: string) => {                 this.getDom().querySelector('.modal-body').innerHTML = data;                 this.attachEvents();             });     }
    public getSelected = () => this.getDom().querySelector("li" + defaultWidgetSelectionSelector);     public getSelectedId = () => this.getSelected().getAttribute(dataIdAttribute);
    public clearSelected = () => {         Array.from(this.getWidgetItems()).forEach((item: HTMLLIElement) => {             item.classList.remove(noPeriod(defaultWidgetSelectionSelector));         })     }
    public attachEvents = () => {         const items = this.getWidgetItems();         Array.from(items).forEach((item: HTMLLIElement) => {             item.removeEventListener('click', () => { this.listItemOnClick(item); });             item.addEventListener('click', () => { this.listItemOnClick(item); });         })
       const addButton = this.getAddWidgetButton();         addButton?.removeEventListener("click", this.addWidgetToLayout, false);         addButton?.addEventListener("click", this.addWidgetToLayout, false);     }
    public listItemOnClick = (item: HTMLLIElement) => {         this.clearSelected();         item.classList.add(noPeriod(defaultWidgetSelectionSelector));     }
    private addWidgetToLayout = (ev: Event) => {         ev.preventDefault();         ev.stopImmediatePropagation();         this.getService().addWidget(this.getSelectedId())             .then( () => {                 this.allowRefresh = true;                 this.hideDialog();             })     } }

The loadDialog() method gets the service (TuxboardService.ts), performs a post to get the AddWidgetDialog body, and assigns the result of the ViewComponent to the ".modal-body" of our Bootstrap dialog.

Once we have the dialog loaded, we need to attach the events to our DOM elements, mainly all of the widgets and the Add Widget button (located in the attachEvents() method).

Our addWidgetToLayout() method occurs when clicking the "Add Widget" button. We stop any additional click events from happening and call the addWidget service. When it returns, we set the allowRefresh property to true and hide the dialog.

We'll continue discussing this flow below in the Tuxbar's AddWidget button.

Creating the Services

We now have an AddWidgetDialog class, but no services.

If we look at the class, we need two services: one to retrieve the body of the dialog and one to add our selected widget.

wwwroot/src/tuxboard/services/tuxboardService.ts

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

The getAddWidgetDialog() method doesn't need anything at this point. Make the call and return the code.

Since we're here, we might as well create the AddWidget() service for actually adding the widget to the dashboard.

wwwroot/src/tuxboard/services/tuxboardService.ts

.
.
private
 tuxAddWidgetUrl: string = "?handler=AddWidget"; .
.
public addWidget = (widgetId: string) => {
    var postData = {         WidgetId: widgetId     };
    const request = new Request(this.tuxAddWidgetUrl,         {             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 WidgetId we pass into the service is the data-id associated with the list item of the widget from the HTML. Refer to the TypeScript methods getSelected() and getSelectedId().

We simply pass in the widget id they selected and send it back to the server for processing.

Pages/index.cshtml.cs

.
.
public
 async Task<IActionResult> OnPostAddWidgetAsync([FromBody] AddWidgetRequest request) {     var dashboard = await _service.GetDashboardAsync(_config);
    var baseWidget = await _service.GetWidgetAsync(request.WidgetId);
    var layoutRow = dashboard.GetFirstLayoutRow();     if (layoutRow != null)     {         var placement = layoutRow.CreateFromWidget(baseWidget);         // placement object can be set to any other layout row chosen;         // default is first layout row, first column.         await _service.AddWidgetPlacementAsync(placement);     }
    return new OkResult(); }

Once we have our base widget, we get the first layout row, create a WidgetPlacement object for that layout row, and save it.

In this code example, when we add a widget, Tuxboard places it in the first column of the first layout row. For more flexibility, widgets can be added to any layout row using a LayoutRow instance.

Editor's Note: In this AddWidget post method, we return an OkResult() and not a rendered "tuxboardtemplate". Why not? It's a matter of preference and this was a quick example. On one hand, we're returning an OkResult() and then performing a refresh. On the other hand, we're returning a ViewComponent("tuxboardtemplate",model) and attaching events to DOM model results. The former technique requires an additional API request. While I thought about this and decided to go with the lazy (first) approach of calling the refresh() method, it may be a better approach to return the ViewComponent instead of the OkResult() for performance reasons.

Adding the Tuxbar Button

Our Tuxbar button will have a simple plus symbol on it for adding widgets (changes in bold).

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>
            <button type="button" id="add-widget-button" title="Add Widget" class="btn btn-outline-secondary">
                <i class="fa-regular fa-square-plus"></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>

Again, add a string constant in the common.ts to identify the add-widget-button.

export const defaultAddWidgetButton = "#add-widget-button";

The AddWidgetButton class ties everything together by attaching a click event to the button, creating the AddWidgetDialog instance, and refreshing the dashboard.

wwwroot/src/tuxboard/tuxbar/AddWidgetButton.ts

export class AddWidgetButton 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 AddWidgetDialog(             defaultAddWidgetDialogSelector,             this.tuxBar.getTuxboard());
        if (dialog) {             dialog.getDom().removeEventListener("hide.bs.modal", () => this.refresh(dialog), false);             dialog.getDom().addEventListener("hide.bs.modal", () => this.refresh(dialog), false);             dialog.showDialog();         }     };
    refresh = (dialog: AddWidgetDialog) => {         if (dialog.allowRefresh) {             this.tuxBar.getTuxboard().refresh();         }     };
    getDom = () => this.tuxBar.getDom().querySelector(this.selector); }

When we hide the dialog, we check the allowRefresh property. If true, we refresh the dashboard. As mentioned above in the Editor's Note, we could've returned a full tuxboardtemplate ViewComponent/HTML and set it through the .innerHTML property.

Finally, we add the button to the Tuxbar (changes in bold).

wwwroot/src/tuxboard/tuxbar/Tuxbar.ts

.
.
public
 initialize = () => {     this.controls.push(new RefreshButton(this, defaultTuxbarRefreshButton));     this.controls.push(new AddWidgetButton(this, defaultAddWidgetButton));     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)); }

Once we have our buttons on the toolbar, we can see how the dialog works.

Conclusion

In this post, we created a fundamental Add Widget dialog so users can add any widget to their dashboard.

This fundamental dialog will become extremely helpful in future posts while demonstrating a number of features to expand on this technique.

What's Next?

Full examples located at Tuxboard.Examples.