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
What's Next?
- Introducing Tuxboard
- Layout of a Tuxboard dashboard
- Dashboard Modularity using Tuxboard
- Moving Widgets in Tuxboard
- Creating a Tuxbar for Tuxboard
- Managing Layouts in Tuxboard: Simple Layout Dialog
- Managing Layouts in Tuxboard: Advanced Layout Dialog
- Adding Widgets with a Tuxboard Dialog
- Using Widget Toolbars (or Deleting Widgets)
- Creating User-Specific Dashboards
- Creating Default Dashboards using Roles
- Creating Default Widgets using Roles
- Creating Custom Tuxboard Widgets
Full examples located at Tuxboard.Examples.
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.
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.
What's Next?
- Introducing Tuxboard
- Layout of a Tuxboard dashboard
- Dashboard Modularity using Tuxboard
- Moving Widgets in Tuxboard
- Creating a Tuxbar for Tuxboard
- Managing Layouts in Tuxboard: Simple Layout Dialog
- Managing Layouts in Tuxboard: Advanced Layout Dialog
- Adding Widgets with a Tuxboard Dialog
- Using Widget Toolbars (or Deleting Widgets)
- Creating User-Specific Dashboards
- Creating Default Dashboards using Roles
- Creating Default Widgets using Roles
- Creating Custom Tuxboard Widgets
Full examples located at Tuxboard.Examples.