Creating a custom control using DataPager with other controls

I am trying to create a custom control that consists of a bp:DataPager and other controls. I need the custom control to use the same data source as an associated bp:GridView that shares the same parent. Nothing I have tried or what type of control I start with it never uses the data source correctly.

Ultimately, what I need is something like this:

<div ID="GridviewContainer" DataContext="MyCustomViewModelWithWhatIsNeededForThisGridView">
    <bp:GridView DataSource="{value: Customers}" ShowTableWhenNoData="true">
        <Columns>
            <bp:GridViewTextColumn Value="{value: Id}" HeaderText="Customer ID" />
            <bp:GridViewTextColumn Value="{value: Name}" HeaderText="Name" />
            <bp:GridViewTextColumn Value="{value: Orders}" HeaderText="Orders" />
        </Columns>
    </bp:GridView>
    <cc:CustomDataPagerWithOtherControls DataSet="{value: Customers}" />
</div>

Note the following:

  • There is a bp:GridView followed by a custom control.
  • The custom control contains a bp:DataPager and other controls.
  • All the elements are contained within a div with a DataContext. That datacontext should house everything needed for these controls.
  • Both controls in the GridviewContainer must use the same DataSource/DataSet object so they work together.
  • There could be more than one instance of the full div with different data on a page. (In other words, I need the ability to have multiple grids on the page showing different data, each with their own grid, datapager, and other related controls.)

The code above is how I need to use it. Following is a working example using regular controls but that do not share a DataContext or use any custom controls.

The dothtml file:

<div ID="GridviewContainer">
    <bp:GridView DataSource="{value: Customers}" ShowTableWhenNoData="true">
        <Columns>
            <bp:GridViewTextColumn Value="{value: Id}" HeaderText="Customer ID" />
            <bp:GridViewTextColumn Value="{value: Name}" HeaderText="Name" />
            <bp:GridViewTextColumn Value="{value: Orders}" HeaderText="Orders" />
        </Columns>
    </bp:GridView>
    <bp:DataPager DataSet="{value: Customers}" />
    <span>
        Page size:
        <bp:DropDownList DataSource="{value: PageSizeOptions}"
                         SelectedValue="{value: PageSizeSelectedValue}"
                         AllowUnselect="false"
                         Changed="{command: ChangePageSize()}"
                         class="SelectPageSize">
            <ToggleIcon>
                <bp:FAIcon Icon="CaretDownSolid" />
            </ToggleIcon>
        </bp:DropDownList>
    </span>
    <span class="GridItemsAndPagesCounts">
        <b>{{value: Customers.PagingOptions.TotalItemsCount}}</b>
        <span> items in </span>
        <b>{{value: Customers.PagingOptions.PagesCount}}</b>
        <span> pages</span>
    </span>
</div>

The ViewModel:

public BusinessPackDataSet<Customer> Customers { get; set; } = new BusinessPackDataSet<Customer> { PagingOptions = { PageSize = 10 } };
public List<int> PageSizeOptions { get; set; } = new List<int> { 5, 10, 15, 20 };
public int PageSizeSelectedValue { get; set; } = 10;

public override Task PreRender()
{
    if (Customers.IsRefreshRequired)
        Customers.LoadFromQueryable(GetQueryable(62));
    return base.PreRender();
}

public void ChangePageSize()
{
    Customers.IsRefreshRequired = true;
    Customers.PagingOptions.PageSize = PageSizeSelectedValue;
    Customers.PagingOptions.PageIndex = 0;
}

private static IQueryable<Customer> GetQueryable(int size)
{
    var numbers = new List<Customer>();
    for (var i = 0; i < size; i++)
    {
        numbers.Add(new Customer { Id = i + 1, Name = $"Customer {i + 1}", Orders = i });
    }
    return numbers.AsQueryable();
}

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Orders { get; set; }
}

How do I create a control that contains the DataPager and other controls and will use the specified DataSet correctly?

Hopefully I provided enough detail.

Apologies for the late response. I hope my answer helps you.

Let me try to describe one of the possible solutions. The first thing to do is to make a custom control. For example CustomDataPagerWithOtherControls as you have in the code. We also have a tutorial on the web how to do this. In short you need to make a .dotcontrol file and a .cs file. The Dotvvm extension can help with that. It might look something like this:
.cs

public class CustomDataPagerWithOtherControls : DotvvmMarkupControl
{
}

.dotcontrol

<bp:DataPager DataSet="{value: Customers}" />
<span>
    Page size:
    <bp:DropDownList DataSource="{value: PageSizeOptions}"
                     SelectedValue="{value: PageSizeSelectedValue}"
                     AllowUnselect="false"
                     Changed="{command: ChangePageSize()}"
                     class="SelectPageSize">
        <ToggleIcon>
            <bp:FAIcon Icon="CaretDownSolid" />
        </ToggleIcon>
    </bp:DropDownList>
</span>
<br />
<span class="GridItemsAndPagesCounts">
    <b>{{value: Customers.PagingOptions.TotalItemsCount}}</b>
    <span> items in </span>
    <b>{{value: Customers.PagingOptions.PagesCount}}</b>
    <span> pages</span>
    <br />
    <span>Page size: </span>
    <b>{{value: PageSizeSelectedValue}}</b>
</span>

The code I mentioned uses different variables. We need to store the states of each variable, so we will use ViewModel. At the same time, we move the function to update the grid size into it.

public class CustomDataPagerWithOtherControlsViewModel : DotvvmViewModelBase
{
    public BusinessPackDataSet<Customer> Customers { get; set; }
    public List<int> PageSizeOptions { get; set; }
    public int PageSizeSelectedValue { get; set; }

    public void ChangePageSize()
    {
        Customers.IsRefreshRequired = true;
        Customers.PagingOptions.PageSize = PageSizeSelectedValue;
        Customers.PagingOptions.PageIndex = 0;
    }
}

Now you just need to use the controll. It must be used with its ViewModel.

public CustomDataPagerWithOtherControlsViewModel CustomData1 { get; set; }

Now it is necessary to initialize the ViewModel and fill it with data. Grid with custom controll can look like this:

<div ID="GridviewContainer" DataContext="{value: CustomData1}">
    <bp:GridView DataSource="{value: _this.Customers}" ShowTableWhenNoData="true">
        <Columns>
            <bp:GridViewTextColumn Value="{value: Id}" HeaderText="Customer ID" />
            <bp:GridViewTextColumn Value="{value: Name}" HeaderText="Name" />
            <bp:GridViewTextColumn Value="{value: Orders}" HeaderText="Orders" />
        </Columns>
    </bp:GridView>
    <cc:CustomDataPagerWithOtherControls DataContext="{value: _this}" />
</div>

This grid with custom control can be used as many times as you want.

Thank you for the response.

I followed your provided instructions and have a working model.

My problem is that I do not know what datatype the DataSet for the GridView and associated DataPager will be. Really what I need is BusinessPackDataSet<T> where T will be a type specified by the page using the controls but I cannot find a way to make that work.

Customers is obviously not the type that would be used, it is just a copy of one of the samples in the documentation.

Is it possible to have the control contain BusinessPackDataSet<T> in place of the Customers property, if so, how is that done?

Thanks!

Unfortunately, it is currently not possible to set the generic type to ViewModel. So a global solution cannot be created. But we see this as a problem because it is very practical. We want to focus on solving this problem in the near future.

While it’s not possible to declare generic markup controls, I think you don’t need it in this case. You only use the PagingOptions property of the dataset, so it should be enough to the define control @viewModel as IGridViewDataSet (without the generic argument). I didn’t test it, so it’s still possible that the DataPager fails on this

Otherwise, it would be necessary to rewrite the control as a CompositeControl which works differently and does not have this limitation.

I tried creating a new dotcontrol with just the following in it:

@viewModel DotVVM.Framework.Controls.IGridViewDataSet, DotVVM.Framework

<div>
    <h2>test</h2>
</div>

I assume I did it wrong. I get the following error when I load it on a page:

DotVVM.Framework.Compilation.DotvvmCompilationException: Could not resolve type ‘DotVVM.Framework.Controls.IGridViewDataSet, DotVVM.Framework’.

On the Controls tab of the DotVVM Compilation Status Page is says the control compilation failed.

I do have a completely working CompositeControl version. The usage is not as simple as I would like it to be but it does work.

It is not the best solution but if I can’t come up with a better one I’ll have to move forward with it.

[ControlMarkupOptions(AllowContent = false)]
public class DataPagerAsComposite : CompositeControl
{
    private readonly BindingCompilationService _bindingCompilationService;
    private readonly DotVVM.Framework.Controls.DataPager.CommonBindings _dataPagerCommonBindings;

    /// <summary>
    /// Returns the "Page size" string.
    /// </summary>
    public string PageSizeLabel => "Page size";

    public DataPagerAsComposite(BindingCompilationService bindingCompilationService, DotVVM.Framework.Controls.DataPager.CommonBindings dataPagerCommonBindings)
    {
        _bindingCompilationService = bindingCompilationService;
        _dataPagerCommonBindings = dataPagerCommonBindings;
    }

    /// <summary>
    /// The entry point into a CompositeControl.
    /// </summary>
    /// <param name="dataSet">The DataSet to use for the DataPager. This must be the same object as the DataSource on the associated GridView on the page.</param>
    /// <param name="pageSizeSelectionChanged">The command to call when the page size is changed.</param>
    /// <param name="pageSizeOptions">The int items to use as the available page size options.</param>
    /// <param name="pageSizeSelectedValue">The value of the item selected by the user. </param>
    /// <param name="totalItems">The total number of items in the GridView DataSet.</param>
    /// <param name="totalPages">The number of pages the items are spread across.</param>
    public DotvvmControl GetContents
    (
        [MarkupOptions(Required = true)]
        ValueOrBinding<IBusinessPackDataSet> dataSet,
        ICommandBinding pageSizeSelectionChanged,
        ValueOrBinding<List<int>> pageSizeOptions,
        ValueOrBinding<int> pageSizeSelectedValue,
        ValueOrBinding<int> totalItems,
        ValueOrBinding<int> totalPages
    )
    {
        var control = new HtmlGenericControl("div")
            // Add the DataPager
            .AppendChildren
            (
                new BPControls.DataPager(_dataPagerCommonBindings, _bindingCompilationService)
                    .SetProperty(d => d.DataSet, dataSet.BindingOrDefault)
            )
            // Add the Page Size selection control
            .AppendChildren(GetPageSizeControl(pageSizeOptions, pageSizeSelectedValue, pageSizeSelectionChanged))
            // Add the control showing the total number of items and how many pages they are spread across
            .AppendChildren(GetItemsInPagesControl(totalItems, totalPages))
            ;

        return control;
    }

    /// <summary>
    /// Returns a span containing the "## items in ## pages" string.
    /// </summary>
    /// <param name="totalItems">The total number of items in the GridView DataSet.</param>
    /// <param name="totalPages">The number of pages the items are spread across.</param>
    private static HtmlGenericControl GetItemsInPagesControl(ValueOrBinding<int> totalItems, ValueOrBinding<int> totalPages)
    {
        var span = new HtmlGenericControl("span") { ID = "PageCountSpan" };
        span.AddCssClass("mt-2 float-right");

        var totalItemsLiteral = new Literal(totalItems, true);
        totalItemsLiteral.AddCssClass("font-weight-bold");
        var totalItemsInText = new HtmlGenericControl("span")
        {
            InnerText = $" {WebResourceHelper.GetResource(PageResourcing.GridViewItemsInText).Text} "
        };

        var totalPagesLiteral = new Literal(totalPages, true);
        totalPagesLiteral.AddCssClass("font-weight-bold");
        var totalPagesText = new HtmlGenericControl("span")
        {
            InnerText = $" {WebResourceHelper.GetResource(PageResourcing.GridViewPagesText).Text}"
        };

        span.Children.Add(totalItemsLiteral);
        span.Children.Add(totalItemsInText);
        span.Children.Add(totalPagesLiteral);
        span.Children.Add(totalPagesText);

        return span;
    }

    /// <summary>
    /// Returns a span containing the "Page size: " string and the drop down list for selecting the items per page.
    /// </summary>
    /// <param name="pageSizeSelectionChanged">The command to call when the page size is changed.</param>
    /// <param name="pageSizeOptions">The int items to use as the available page size options.</param>
    /// <param name="pageSizeSelectedValue">The value of the item selected by the user. </param>
    private HtmlGenericControl GetPageSizeControl(ValueOrBinding<List<int>> pageSizeOptions, ValueOrBinding<int> pageSizeSelectedValue, ICommandBinding pageSizeSelectionChanged)
    {
        var outerControl = new HtmlGenericControl("span");
        outerControl.AddCssClass("text-nowrap mx-1");

        var pageSizeLabel = new HtmlGenericControl("span");
        pageSizeLabel.SetValueRaw(HtmlGenericControl.InnerTextProperty, $"{PageSizeLabel}: ");
        
        outerControl.Children.Add(pageSizeLabel);

        var pageSizeSelect = new DropDownList(_bindingCompilationService)
        {
            AllowUnselect = false,
        };
        pageSizeSelect.SetProperty(DropDownList.DataSourceProperty, pageSizeOptions.BindingOrDefault);
        pageSizeSelect.SetProperty(DropDownList.SelectedValueProperty, pageSizeSelectedValue.BindingOrDefault);
        pageSizeSelect.AddCssClass("SelectPageSize");

        pageSizeSelect.SetProperty(DropDownList.ChangedProperty, pageSizeSelectionChanged);

        outerControl.Children.Add(pageSizeSelect);

        return outerControl;
    }
}

The usage looks like this on the page:

<cc:DataPagerAsComposite DataSet="{value: DataSet}"
                          PageSizeSelectionChanged="{command: _root.Grid_ChangePageSize()}"
                          PageSizeOptions="{value: _parent.GridPerPageOptions}"
                          PageSizeSelectedValue="{value: _parent.GridPerPageSelectedValue}"
                          TotalItems="{value: DataSet.PagingOptions.TotalItemsCount}"
                          TotalPages="{value: DataSet.PagingOptions.PagesCount}"
                          />

The DataSet property is a member of the DataContext for the containing div wrapping the GridView and DataPagerAsComposite controls. _root and _parent both point to the dothtml page.

Again, what I would really like to be able to do is this:

<div id="GridContainer" DataContext="HolderOfTheData">
  <bp:GridView DataSource="{value: DataSourceObjectInsideHolderOfTheData}">
    . . .
  </bp:GridView>
  <cc:CustomDataPagerControl DataSource="{value: DataSourceObjectInsideHolderOfTheData}" />
</div>

Ideally, the drop down list contents and event handling is all inside the control. The values it uses are not needed by anything else.

I appreciate the responses and ideas present so far.

I managed to create a simple demo using markup control.

The @viewModel of markup control can be an interface or a base class. The pager control does not need to know the concrete type of the item in the dataset. It cares only about the PagingOptions you can create an interface that exposes the data set as IPageableGridViewDataSet.

    public interface IGridPageViewModel
    {
        public IPageableGridViewDataSet DataSet { get; }
        public int[] GridPerPageOptions { get; }
        void OnPageSizeChanged();
    }

The implementation should contain the concrete type with the generic argument:

public class GridPageViewModel<T> : DotvvmViewModelBase, IGridPageViewModel where T : class, new()
{
    public BusinessPackDataSet<T> DataSet { get; set; } = new BusinessPackDataSet<T>()
    {
        PagingOptions =
        {
            PageSize = 10
        }
    };
    IPageableGridViewDataSet IGridPageViewModel.DataSet => DataSet;

    [Bind(Direction.ServerToClientFirstRequest)]
    public int[] GridPerPageOptions => new [] { 10, 20, 50, 100 };

    public void OnPageSizeChanged()
    {
        DataSet.RequestRefresh();
    }
}

The markup control can use the interface as its ViewModel.

@viewModel DotvvmApplication1.ViewModels.Generic.IGridPageViewModel, DotvvmApplication1

<bp:DataPager DataSet="{value: DataSet}" />

<span class="text-nowrap mx-1">
    <bp:DropDownList DataSource="{value: GridPerPageOptions}"
                     SelectedValue="{value: DataSet.PagingOptions.PageSize}"
                     AllowUnselect="false"
                     Changed="{command: OnPageSizeChanged()}">
    </bp:DropDownList>
</span>
<span>{{value: DataSet.PagingOptions.TotalItemsCount}} / {{value: DataSet.PagingOptions.PagesCount}}</span>

A couple of tips here:

  • The list of possible page sizes can be in the base class or in the master page ViewModel. Right now, it needs to be somewhere in the ViewModel. I used [Bind(Direction.ServerToClientFirstRequest)] to make sure it is not transferred on each postback. We plan to support this “static collections” in some future versions of DotVVM.

  • It is good to avoid declaring properties for markup controls if you can. They have some performance overhead because of added serialization and deserialization, so if you can, pass the data for the control using the DataContext. I just passed the object via the data context without the need to declare own properties.

Here is a complete demo:
MarkupControlDemo.zip (28.3 KB)

1 Like

Thank you for your help. With your example I was able to build almost exactly what I had in mind.