Moving Widgets in Tuxboard

In this post, we'll focus on moving Tuxboard widgets on the dashboard

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

Man buried in moving boxes in a room

In the last Tuxboard post, we broke out each piece of the dashboard into a ViewComponent making it easier to access through JavaScript and APIs.

In this post, we'll focus on moving widgets on the screen to other parts of the dashboard so users can have their own customized dashboard.

With this type of interactivity, we'll focus on using TypeScript since it's one of the most popular languages. Since a dashboard is a simple one-pager, we'll only need one script to run Tuxboard.

Client Tasks Setup

Since we're using TypeScript and JavaScript is key in customizing our dashboard, we need to update our project to use a Task Runner to make our lives easier.

(Sidenote: I've mentioned Task Runners before where it takes the hard work of client-side tasks off your hands making your application more maintainable).

The Task Runner accomplishes the following tasks for us:

  • Transpiles TypeScript (*.ts) into JavaScript (*.js) files
  • Converts SASS styles into optimized CSS files
  • Bundles the JavaScript and CSS files
  • Copies them into their respective directories: /js and /css (or /styles if you like)
  • Copies used libraries from node_modules into a library folder (usually /lib)

As you can see, Task Runners perform a lot of fundamental tasks making our lives a bit easier.

The DragWidgets example shows the following directory structure as shown below:

Screenshot of a directory structure of the Visual Studio DragWidgets project

While this isn't a Task Runner post, most of these tasks are already defined and created in the DragWidgets example so we can focus on the dashboard functionality of moving widgets on the dashboard.

Requirements

On a dashboard, we need some kind of rules for moving widgets.

The requirements for this example when moving a widget are as follows:

  • Provide a dashed line for the widget's "dropzone" (where the widget can be dropped).
  • Allowable dropzones
    • Column - the widget is moved to the column either at the end of the column or between two existing widgets.
    • Widget Heading - When dropped on a widget heading, the dropped widget will push the dropzone widget down and insert it.

Now that we know our constraints, we can create our TypeScript files with the drag/drop process.

Updating Widget Template

Before we build our TypeScript files, each widget requires an HTML 5 attribute in a widget template. In the Pages\Shared\Components\WidgetTemplate\Default.cshtml file, we have to add draggable="true" is necessary for us to move the widget around our dashboard.

<div class="card @(Model.Collapsed ? "collapsed" : string.Empty)"
     data-id="@Model.WidgetPlacementId" draggable="true">

Once we add the attribute to our widget, we can begin looking at creating the Tuxboard TypeScript file.

Creating the Dashboard File

In the image above, there is a dashboard.ts file in the src directory. This is the entry point for our dashboard and it's quite simple.

import { ready } from "./tuxboard/common";
import { Tuxboard } from "./tuxboard/tuxboard";

ready
(() => {     const dashboard = new Tuxboard();     dashboard.initialize(); })

There's a reason why it's so simple which we'll cover in a later post.

As a sidenote, the ready() function is located in the common.js and gives us the ability to load all of the JavaScript before executing it. Once everything loads, we create the Tuxboard instance and initialize it.

Creating Drag and Drop Events

In the initialization process, we add the drag and drop events using the attachDragAndDropEvents() function.

The snippet below is from the tuxboard.ts file.

attachDragAndDropEvents = () => {
    const columns = this.getAllColumns(this.getTab());
    for (const column of columns) {
        column.getDom().addEventListener("dragstart", (ev: DragEvent) => {
            this.dragStart(ev, column)
        }, false);
        column.getDom().addEventListener("dragover", this.dragover, false);
        column.getDom().addEventListener("dragenter", this.dragenter, false);
        column.getDom().addEventListener("dragleave", this.dragLeave, false);
        column.getDom().addEventListener("drop", (ev: DragEvent) => { this.drop(ev) }, false);
        column.getDom().addEventListener("dragend", (ev: DragEvent) => { this.dragEnd(ev); }, false);
    }
}

dragStart = (ev: DragEvent, column: Column) => {

   if (ev.stopPropagation) ev.stopPropagation();

   ev.dataTransfer.effectAllowed = 'move';

   const elem = ev.target as HTMLElement;

   this.dragInfo = new DragWidgetInfo(
        elem.getAttribute(dataId),
        column.getIndex(),
        column.layoutRowId,
        column.getIndex(),
        column.layoutRowId);

   ev.dataTransfer.setData('text', JSON.stringify(this.dragInfo));
}

dragover
 = (ev: DragEvent) => {
    if (ev.preventDefault) ev.preventDefault();
    if (ev.stopPropagation) ev.stopPropagation();

   ev.dataTransfer.dropEffect = 'move';

   const target = ev.target as HTMLElement;

   return isWidgetOrColumn(target);
}

dragenter
 = (ev: DragEvent) => {
    if (ev.preventDefault) ev.preventDefault();
    if (ev.stopPropagation) ev.stopPropagation();

   const target = ev.target as HTMLElement;
    if (isWidgetOrColumn(target)) target.classList.add('over');
}

dragLeave
 = (ev: DragEvent) => {
    if (ev.preventDefault) ev.preventDefault();
    if (ev.stopPropagation) ev.stopPropagation();

   const target = ev.target as HTMLElement;
    if (isWidgetOrColumn(target)) target.classList.remove("over");
}

drop
 = (ev: DragEvent) => {
    if (ev.preventDefault) ev.preventDefault();
    if (ev.stopPropagation) ev.stopPropagation();

   const targetElement = ev.target as HTMLElement; // .column or .card-header

   this.dragInfo = JSON.parse(ev.dataTransfer.getData("text"));

   const draggedWidget = document.querySelector(`[${dataId}='${this.dragInfo.placementId}'`);

   if (isWidget(targetElement)) {
        const widget = getClosestByClass(targetElement, noPeriod(defaultWidgetSelector));
        const column = getClosestByClass(targetElement, noPeriod(defaultColumnSelector));
        if (column && widget) {
            column.insertBefore(draggedWidget, widget);
        }
    }
    else if (targetElement.classList.contains(defaultColumnSelector.substr(1))) {
        const closestWidget = getClosestByClass(targetElement,
            defaultWidgetSelector);
        if (closestWidget) {
            targetElement.insertBefore(draggedWidget, closestWidget);
        } else {
            targetElement.append(draggedWidget);
        }
    }
}

dragEnd
 = (ev: DragEvent) => {
    if (ev.preventDefault) ev.preventDefault();
    if (ev.stopPropagation) ev.stopPropagation();

   this.dashboard.querySelectorAll(defaultColumnSelector)
        .forEach((elem: HTMLElement) => elem.classList.remove("over"));
    this.dashboard.querySelectorAll(defaultWidgetHeaderSelector)
        .forEach((elem: HTMLElement) => elem.classList.remove("over"));

   const id = this.dragInfo.placementId;

   this.dragInfo.placementList = getWidgetSnapshot(this.dragInfo, this.getTab());

   const selected = this.dragInfo.placementList
        .filter((elem: PlacementItem) => elem.placementId === id);
    if (selected && selected.length > 0) {
        this.dragInfo.currentLayoutRowId = selected[0].layoutRowId;
        this.dragInfo.currentColumnIndex = selected[0].columnIndex;
    }

   ev.dataTransfer.clearData();
}

Once we have our drag events attached to columns, we can proceed with initially dragging a widget.

Initial Drag

The DragStart event begins when we grab a card's heading. In this case, we're using Bootstrap's Card component for widgets.

We need to define what kind of drageffect this is by assigning the effectAllowed property to "move", getting the widget element through ev.target, and creating the DragWidgetInfo instance.

What is the purpose of the DragWidgetInfo? To save the previous and current location of the widget on the dashboard so we can persist the widget's location in the database.

The DragWidgetInfo contains the following:

  • Widget Placement ID - What widget are we moving on the dashboard.
  • Previous Column Index & Layout Row ID - What is the initial position of the widget's location on the dashhboard?
  • Current Column Index & Layout Row ID - What is the ending position of the widget's location?
  • PlacementList - Contains a snapshot of widget placement objects 

Once we have this instance, we serialize the object and store the text in the dataTransfer object so we can pass around the data throughout the drag/drop process.

Dragging and Identifying Targets

The DragOver event is fired when elements are dragged over a valid drop element. In this case, it's a widget or a column.

If we identify the element as a widget or a column, we return true. If not, false.

The DragEnter event fires when the element dragged enters a valid element. After identifying whether the element is a widget or column, we add a CSS class called hover to the element to give the user a visual representation of a drop zone.

The DragLeave does the opposite. The event fires when they leave a valid element. As shown in the code, we remove the hover CSS class when the user leaves the valid element.

Dropping the Widget

When the user drops the widget, two events fire: Drop event and the DragEnd event.

In the Drop event, we need the data from our event. This includes the element from the ev.target and our dragWidgetInfo instance from the dataTransfer. The dragWidgetInfo is then assigned to the dragInfo property of the class.

Strange thing about this: I placed this assignment in multiple places and it seems  

Once we have our data, we identify whether the user dropped the widget on a column or another widget.

If it's a widget, we insert the dragged widget before the target. If it's a column, we append the widget to the end of the column.

The second event, DragEnd, is the final piece of the puzzle. The DragEnd event fires when a drag operation ends by either releasing the mouse button or pressing the ESC key.

Consider the DragEnd event as a cleanup process. For the cleanup process, we remove the "hover" CSS class from all widgets and columns (just in case) and take a widget snapshot of the dashboard. The snapshot is meant to create a list of all widgets on the dashboard. Once we have the snapshot, we send it back to the server (which we'll cover in a later post).

Finally, we clean the data from dataTransfer.

Saving the Dashboard

Since we have a snapshot of our dashboard, we can create a service for saving our widget layout.

We require the following:

  • A TypeScript Service
  • Server-side post

Let's focus on the server-side post first.

Since most dashboards are one page, we can create a post method in our C# page called SaveWidgetPosition(). The method takes a PlacementParameter which is identical to a DragWidgetInfo on the client-side.

public async Task<IActionResult> OnPostSaveWidgetPosition([FromBody] PlacementParameter model)
{
    var placement = await _service.SaveWidgetPlacementAsync(model);
    if (placement == null)
    {
        return StatusCode((int)HttpStatusCode.InternalServerError,
            $"Widget Placement (id:{model.PlacementId}) was NOT saved.");
    }

   return new OkObjectResult("Widget Placement was saved.");
}

We could've added an API for this particular call, but for our purposes, we'll use this method on the page.

One other important note regarding this method: the SaveWidgetPosition(0 method simply saves a position to the table without associating it to a user. We haven't added anything pertaining to users...yet. We'll dig into user-specific dashboards in a later post, but for now, the example is only meant for a single dashboard and not a user-specific dashboard.

On the client-side, we'll create a new TypeScript called TuxboardService. For now, this will make the call to the server-side method called "SaveWidgetPosition()".

export class TuxboardService extends BaseService {

   private tuxWidgetPlacementUrl: string = "?handler=SaveWidgetPosition";

   constructor(debugParam: boolean = false) {
        super(debugParam);
    }

   public saveWidgetPlacement(ev: Event, dragInfo: DragWidgetInfo) {

       const postData = {
            Column: dragInfo.currentColumnIndex,
            LayoutRowId: dragInfo.currentLayoutRowId,
            PreviousColumn: dragInfo.previousColumnIndex,
            PreviousLayout: dragInfo.previousLayoutRowId,
            PlacementId: dragInfo.placementId,
            PlacementList: dragInfo.placementList,
        };

       const request = new Request(this.tuxWidgetPlacementUrl,
            {
                method: "post",
                body: JSON.stringify(postData),
                headers: {
                    'Content-Type': 'application/json',
                    'RequestVerificationToken': this.getToken(),
                }
            });

       return fetch(request)
            .then(this.validateResponse)
            .catch(this.logError);
    }
}

With Razor Pages, a postback always injects a RequestValidationToken into the form. The getToken() method (used in the BaseService.ts) reads the value and uses it for posting data back to the server.

With everything in place, we can add the final touch to the dragEnd event (changes in bold):

dragEnd = (ev: DragEvent) => {

    if (ev.preventDefault) ev.preventDefault();
    if (ev.stopPropagation) ev.stopPropagation();

   this.dashboard.querySelectorAll(defaultColumnSelector)
        .forEach((elem: HTMLElement) => elem.classList.remove("over"));
    this.dashboard.querySelectorAll(defaultWidgetHeaderSelector)
        .forEach((elem: HTMLElement) => elem.classList.remove("over"));

   const id = this.dragInfo.placementId;

   this.dragInfo.placementList = getWidgetSnapshot(this.dragInfo, this.getTab());

   const selected = this.dragInfo.placementList
        .filter((elem: PlacementItem) => elem.placementId === id);
    if (selected && selected.length > 0) {
        this.dragInfo.currentLayoutRowId = selected[0].layoutRowId;
        this.dragInfo.currentColumnIndex = selected[0].columnIndex;
    }

   this.service.saveWidgetPlacement(ev, this.dragInfo)
        .then((result) => console.log("Saved."));

   ev.dataTransfer.clearData();
}

As mentioned above, the this.dragInfo type is the same as a PlacementParameter type in C#.

Conclusion

In this post, we created a way to move widgets on the dashboard and save their positions to the database.

To create a successful dashboard, customization is essential to users. Moving widgets is considered one of those customizable features. This allows users to create a familiar and useful dashboard for system notifications in order to make critical business decisions.

In the next post, we'll look at implementing a Tuxbar.

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