diff --git a/.github/uitest/action.yml b/.github/uitest/action.yml index bbd0d05023..4cc0217567 100644 --- a/.github/uitest/action.yml +++ b/.github/uitest/action.yml @@ -37,6 +37,7 @@ runs: shell: bash env: DOTVVM_SAMPLES_CONFIG_PROFILE: ${{ inputs.samples-config }} + GITHUB_TOKEN: ${{ inputs.github-token }} - if: ${{ runner.os == 'Windows' }} run: choco install dotnet-aspnetcoremodule-v2 -y @@ -53,6 +54,7 @@ runs: shell: pwsh env: DOTVVM_SAMPLES_CONFIG_PROFILE: ${{ inputs.samples-config }} + GITHUB_TOKEN: ${{ inputs.github-token }} # publish the result to github - uses: ./.github/test-report diff --git a/.github/uitest/uitest.ps1 b/.github/uitest/uitest.ps1 index 3a1b990223..579c4b8e62 100644 --- a/.github/uitest/uitest.ps1 +++ b/.github/uitest/uitest.ps1 @@ -110,6 +110,8 @@ function Start-Sample { [int][parameter(Position = 2)]$port ) Invoke-RequiredCmds "Start sample '$sampleName'" { + Reset-IISServerManager -Confirm:$false + Remove-IISSite -Confirm:$false -Name $sampleName -ErrorAction SilentlyContinue icacls "$root\artifacts\" /grant "IIS_IUSRS:(OI)(CI)F" @@ -117,7 +119,7 @@ function Start-Sample { New-IISSite -Name "$sampleName" ` -PhysicalPath "$path" ` -BindingInformation "*:${port}:" - + # ensure IIS created the site while ($true) { $state = (Get-IISSite -Name $sampleName).State @@ -131,6 +133,15 @@ function Start-Sample { throw "Site '${sampleName}' could not be started. State: '${state}'." } } + + # add or update environment variable to the application pool + $existingEnvVar = c:\windows\system32\inetsrv\appcmd.exe list config -section:system.applicationHost/applicationPools ` + | out-string ` + | select-xml -XPath "//add[@name='DefaultAppPool']/environmentVariables/add[@name='GITHUB_TOKEN']" + if ($existingEnvVar -ne $null) { + c:\windows\system32\inetsrv\appcmd.exe set config -section:system.applicationHost/applicationPools /-"[name='DefaultAppPool'].environmentVariables.[name='GITHUB_TOKEN']" /commit:apphost + } + c:\windows\system32\inetsrv\appcmd.exe set config -section:system.applicationHost/applicationPools /+"[name='DefaultAppPool'].environmentVariables.[name='GITHUB_TOKEN',value='$env:GITHUB_TOKEN']" /commit:apphost } } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 56b7b42208..cc0bba6d99 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -20,7 +20,7 @@ - 11.0 + 12.0 $(NoWarn);CS1591;CS1573 true diff --git a/src/Framework/Core/Controls/GenericGridViewDataSet.cs b/src/Framework/Core/Controls/GenericGridViewDataSet.cs new file mode 100644 index 0000000000..bb5867b845 --- /dev/null +++ b/src/Framework/Core/Controls/GenericGridViewDataSet.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace DotVVM.Framework.Controls +{ + public class GenericGridViewDataSet< + T, + TFilteringOptions, + TSortingOptions, + TPagingOptions, + TRowInsertOptions, + TRowEditOptions> + : IGridViewDataSet, + IFilterableGridViewDataSet, + ISortableGridViewDataSet, + IPageableGridViewDataSet, + IRowInsertGridViewDataSet, + IRowEditGridViewDataSet + where TFilteringOptions : IFilteringOptions + where TSortingOptions : ISortingOptions + where TPagingOptions : IPagingOptions + where TRowInsertOptions : IRowInsertOptions + where TRowEditOptions : IRowEditOptions + { + + /// + /// Gets or sets the items for the current page. + /// + public IList Items { get; set; } = new List(); + + /// + /// Gets or sets whether the data should be refreshed. This property is set to true automatically + /// when paging, sorting or other options change. + /// + public bool IsRefreshRequired { get; set; } = true; + + /// + /// Gets or sets the settings for filtering. + /// + public TFilteringOptions FilteringOptions { get; set; } + + /// + /// Gets or sets the settings for sorting. + /// + public TSortingOptions SortingOptions { get; set; } + + /// + /// Gets or sets the settings for paging. + /// + public TPagingOptions PagingOptions { get; set; } + + /// + /// Gets or sets the settings for row (item) insert feature. + /// + public TRowInsertOptions RowInsertOptions { get; set; } + + /// + /// Gets or sets the settings for row (item) edit feature. + /// + public TRowEditOptions RowEditOptions { get; set; } + + + IList IBaseGridViewDataSet.Items => Items is IList list ? list : new ReadOnlyCollection(Items); + + IFilteringOptions IFilterableGridViewDataSet.FilteringOptions => this.FilteringOptions; + + ISortingOptions ISortableGridViewDataSet.SortingOptions => this.SortingOptions; + + IPagingOptions IPageableGridViewDataSet.PagingOptions => this.PagingOptions; + + IRowInsertOptions IRowInsertGridViewDataSet.RowInsertOptions => this.RowInsertOptions; + + IRowEditOptions IRowEditGridViewDataSet.RowEditOptions => this.RowEditOptions; + + + + public GenericGridViewDataSet(TFilteringOptions filteringOptions, TSortingOptions sortingOptions, TPagingOptions pagingOptions, TRowInsertOptions rowInsertOptions, TRowEditOptions rowEditOptions) + { + FilteringOptions = filteringOptions; + SortingOptions = sortingOptions; + PagingOptions = pagingOptions; + RowInsertOptions = rowInsertOptions; + RowEditOptions = rowEditOptions; + } + + + /// + /// Requests to reload data into the . + /// + public void RequestRefresh() + { + IsRefreshRequired = true; + } + + /// + /// Applies the options from the specified to this instance. + /// + public void ApplyOptions(GridViewDataSetOptions options) + { + if (options.FilteringOptions != null) + { + FilteringOptions = options.FilteringOptions; + } + + if (options.SortingOptions != null) + { + SortingOptions = options.SortingOptions; + } + + if (options.PagingOptions != null) + { + PagingOptions = options.PagingOptions; + } + } + + public GridViewDataSetOptions GetOptions() + { + return new() + { + FilteringOptions = FilteringOptions, + SortingOptions = SortingOptions, + PagingOptions = PagingOptions + }; + } + + /// Sets new items + filtering, sorting, paging options. + public void ApplyResult(GridViewDataSetResult result) + { + Items = result.Items.ToList(); + if (result.FilteringOptions is {}) + FilteringOptions = result.FilteringOptions; + if (result.SortingOptions is {}) + SortingOptions = result.SortingOptions; + if (result.PagingOptions is {}) + PagingOptions = result.PagingOptions; + } + } +} diff --git a/src/Framework/Core/Controls/GridViewDataSet.cs b/src/Framework/Core/Controls/GridViewDataSet.cs index 3492a23e68..fc730b6de0 100644 --- a/src/Framework/Core/Controls/GridViewDataSet.cs +++ b/src/Framework/Core/Controls/GridViewDataSet.cs @@ -1,6 +1,4 @@ using System; -using System.Collections; -using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -11,167 +9,18 @@ namespace DotVVM.Framework.Controls /// /// Represents a collection of items with paging, sorting and row edit capabilities. /// - /// The type of the elements. - public class GridViewDataSet : IGridViewDataSet + /// The type of the elements in the collection. + public class GridViewDataSet() + : GenericGridViewDataSet(new(), new(), new(), new(), new()) { - /// - /// Gets or sets whether the data should be refreshed. This property is set to true automatically - /// when paging, sorting or other options change. - /// - public bool IsRefreshRequired { get; set; } = true; - - /// - /// Gets or sets the items for the current page. - /// - public IList Items { get; set; } = new List(); - - IList IBaseGridViewDataSet.Items => Items is List list ? list : Items.ToList(); - - /// - /// Gets or sets the settings for paging. - /// - public IPagingOptions PagingOptions { get; set; } = new PagingOptions(); - - /// - /// Gets or sets the settings for row (item) edit feature. - /// - public IRowEditOptions RowEditOptions { get; set; } = new RowEditOptions(); - - /// - /// Gets or sets the settings for sorting. - /// - public ISortingOptions SortingOptions { get; set; } = new SortingOptions(); - - /// - /// Navigates to the specific page. - /// - /// The zero-based index of the page to navigate to. - public void GoToPage(int index) - { - PagingOptions.PageIndex = index; - RequestRefresh(); - } - - /// - /// Loads data into the from the given source. - /// - /// The source to load data from. - public void LoadFromQueryable(IQueryable source) - { - source = ApplyFilteringToQueryable(source); - Items = ApplyOptionsToQueryable(source).ToList(); - PagingOptions.TotalItemsCount = source.Count(); - IsRefreshRequired = false; - } - - /// - /// Requests to reload data into the . - /// - public virtual void RequestRefresh() - { - IsRefreshRequired = true; - } - - /// - /// Sets the sort expression. If the specified expression is already set, switches the sort direction. - /// - [Obsolete("This method has strange side-effects, assign the expression yourself and reload the DataSet.")] - public virtual void SetSortExpression(string expression) - { - if (SortingOptions.SortExpression == expression) - { - SortingOptions.SortDescending = !SortingOptions.SortDescending; - } - else - { - SortingOptions.SortExpression = expression; - SortingOptions.SortDescending = false; - } - - GoToPage(0); - } - - /// - /// Applies filtering to the before the total number - /// of items is retrieved. - /// - /// The to modify. - public virtual IQueryable ApplyFilteringToQueryable(IQueryable queryable) - { - return queryable; - } - - /// - /// Applies options to the after the total number - /// of items is retrieved. - /// - /// The to modify. - public virtual IQueryable ApplyOptionsToQueryable(IQueryable queryable) - { - queryable = ApplySortingToQueryable(queryable); - queryable = ApplyPagingToQueryable(queryable); - return queryable; - } - - /// - /// Applies sorting to the after the total number - /// of items is retrieved. - /// - /// The to modify. - public virtual IQueryable ApplySortingToQueryable(IQueryable queryable) - { - if (SortingOptions?.SortExpression == null) - { - return queryable; - } - - var parameterExpression = Expression.Parameter(typeof(T), "p"); - Expression sortByExpression = parameterExpression; - foreach (var prop in (SortingOptions.SortExpression ?? "").Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries)) - { - var property = sortByExpression.Type.GetTypeInfo().GetProperty(prop); - if (property == null) - { - throw new Exception($"Could not sort by property '{prop}', since it does not exists."); - } - if (property.GetCustomAttribute() is BindAttribute bind && bind.Direction == Direction.None) - { - throw new Exception($"Cannot sort by an property '{prop}' that has [Bind(Direction.None)]."); - } - if (property.GetCustomAttribute() is ProtectAttribute protect && protect.Settings == ProtectMode.EncryptData) - { - throw new Exception($"Cannot sort by an property '{prop}' that is encrypted."); - } - - sortByExpression = Expression.Property(sortByExpression, property); - } - if (sortByExpression == parameterExpression) // no sorting - { - return queryable; - } - var lambdaExpression = Expression.Lambda(sortByExpression, parameterExpression); - var methodCallExpression = Expression.Call(typeof(Queryable), GetSortingMethodName(), - new[] { parameterExpression.Type, sortByExpression.Type }, - queryable.Expression, - Expression.Quote(lambdaExpression)); - return queryable.Provider.CreateQuery(methodCallExpression); - } - - /// - /// Applies paging to the after the total number - /// of items is retrieved. - /// - /// The to modify. - public virtual IQueryable ApplyPagingToQueryable(IQueryable queryable) - { - return PagingOptions != null && PagingOptions.PageSize > 0 ? - queryable.Skip(PagingOptions.PageSize * PagingOptions.PageIndex).Take(PagingOptions.PageSize) : - queryable; - } - - private string GetSortingMethodName() - { - return SortingOptions.SortDescending ? "OrderByDescending" : "OrderBy"; + // return specialized dataset options + public new GridViewDataSetOptions GetOptions() + { + return new GridViewDataSetOptions { + FilteringOptions = FilteringOptions, + SortingOptions = SortingOptions, + PagingOptions = PagingOptions + }; } } } diff --git a/src/Framework/Core/Controls/GridViewDataSetExtensions.cs b/src/Framework/Core/Controls/GridViewDataSetExtensions.cs index f5c08448fc..18cd22cdc5 100644 --- a/src/Framework/Core/Controls/GridViewDataSetExtensions.cs +++ b/src/Framework/Core/Controls/GridViewDataSetExtensions.cs @@ -1,43 +1,84 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + namespace DotVVM.Framework.Controls { public static class GridViewDataSetExtensions { - /// - /// Navigates to the first page. - /// - public static void GoToFirstPage(this IPageableGridViewDataSet dataSet) - { - dataSet.GoToPage(0); - } - /// - /// Navigates to the previous page if possible. - /// - public static void GoToPreviousPage(this IPageableGridViewDataSet dataSet) + public static void LoadFromQueryable(this IGridViewDataSet dataSet, IQueryable queryable) { - if (!dataSet.PagingOptions.IsFirstPage) + if (dataSet.FilteringOptions is not IApplyToQueryable filteringOptions) { - dataSet.GoToPage(dataSet.PagingOptions.PageIndex - 1); + throw new ArgumentException($"The FilteringOptions of {dataSet.GetType()} must implement IApplyToQueryable!"); + } + if (dataSet.SortingOptions is not IApplyToQueryable sortingOptions) + { + throw new ArgumentException($"The SortingOptions of {dataSet.GetType()} must implement IApplyToQueryable!"); + } + if (dataSet.PagingOptions is not IApplyToQueryable pagingOptions) + { + throw new ArgumentException($"The PagingOptions of {dataSet.GetType()} must implement IApplyToQueryable!"); } - } - /// - /// Navigates to the next page if possible. - /// - public static void GoToNextPage(this IPageableGridViewDataSet dataSet) - { - if (!dataSet.PagingOptions.IsLastPage) + var filtered = filteringOptions.ApplyToQueryable(queryable); + var sorted = sortingOptions.ApplyToQueryable(filtered); + var paged = pagingOptions.ApplyToQueryable(sorted); + dataSet.Items = paged.ToList(); + + if (pagingOptions is IPagingOptionsLoadingPostProcessor pagingOptionsLoadingPostProcessor) { - dataSet.GoToPage(dataSet.PagingOptions.PageIndex + 1); + pagingOptionsLoadingPostProcessor.ProcessLoadedItems(filtered, dataSet.Items); } + + dataSet.IsRefreshRequired = false; + } + + public static void GoToFirstPageAndRefresh(this IPageableGridViewDataSet dataSet) + { + dataSet.PagingOptions.GoToFirstPage(); + (dataSet as IRefreshableGridViewDataSet)?.RequestRefresh(); } - /// - /// Navigates to the last page. - /// - public static void GoToLastPage(this IPageableGridViewDataSet dataSet) + public static void GoToLastPageAndRefresh(this IPageableGridViewDataSet dataSet) { - dataSet.GoToPage(dataSet.PagingOptions.PagesCount - 1); + dataSet.PagingOptions.GoToLastPage(); + (dataSet as IRefreshableGridViewDataSet)?.RequestRefresh(); } + + public static void GoToPreviousPageAndRefresh(this IPageableGridViewDataSet dataSet) + { + dataSet.PagingOptions.GoToPreviousPage(); + (dataSet as IRefreshableGridViewDataSet)?.RequestRefresh(); + } + + public static void GoToNextPageAndRefresh(this IPageableGridViewDataSet dataSet) + { + dataSet.PagingOptions.GoToNextPage(); + (dataSet as IRefreshableGridViewDataSet)?.RequestRefresh(); + } + + public static void GoToPageAndRefresh(this IPageableGridViewDataSet dataSet, int pageIndex) + { + dataSet.PagingOptions.GoToPage(pageIndex); + (dataSet as IRefreshableGridViewDataSet)?.RequestRefresh(); + } + + + public static async Task LoadAsync( + this GenericGridViewDataSet dataSet, + Func, Task>> loadMethod) + where TFilteringOptions : IFilteringOptions + where TSortingOptions : ISortingOptions + where TPagingOptions : IPagingOptions + where TRowInsertOptions : IRowInsertOptions + where TRowEditOptions : IRowEditOptions + { + var options = dataSet.GetOptions(); + var result = await loadMethod(options); + dataSet.ApplyResult(result); + } + } } diff --git a/src/Framework/Core/Controls/GridViewDataSetResult.cs b/src/Framework/Core/Controls/GridViewDataSetResult.cs new file mode 100644 index 0000000000..c5a3b9487f --- /dev/null +++ b/src/Framework/Core/Controls/GridViewDataSetResult.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace DotVVM.Framework.Controls +{ + public class GridViewDataSetResult + where TFilteringOptions : IFilteringOptions + where TSortingOptions : ISortingOptions + where TPagingOptions : IPagingOptions + { + public GridViewDataSetResult(IReadOnlyList items, TFilteringOptions? filteringOptions = default, TSortingOptions? sortingOptions = default, TPagingOptions? pagingOptions = default) + { + Items = items; + FilteringOptions = filteringOptions; + SortingOptions = sortingOptions; + PagingOptions = pagingOptions; + } + + public GridViewDataSetResult(IReadOnlyList items, GridViewDataSetOptions options) + { + Items = items; + FilteringOptions = options.FilteringOptions; + SortingOptions = options.SortingOptions; + PagingOptions = options.PagingOptions; + } + + public IReadOnlyList Items { get; } + + public TFilteringOptions? FilteringOptions { get; } + + public TSortingOptions? SortingOptions { get; } + + public TPagingOptions? PagingOptions { get; } + } +} diff --git a/src/Framework/Core/Controls/IBaseGridViewDataSet.cs b/src/Framework/Core/Controls/IBaseGridViewDataSet.cs index 05da21466c..63c48c70de 100644 --- a/src/Framework/Core/Controls/IBaseGridViewDataSet.cs +++ b/src/Framework/Core/Controls/IBaseGridViewDataSet.cs @@ -12,7 +12,7 @@ public interface IBaseGridViewDataSet : IBaseGridViewDataSet /// /// Gets the items for the current page. /// - new IList Items { get; } + new IList Items { get; set; } } /// @@ -25,4 +25,4 @@ public interface IBaseGridViewDataSet /// IList Items { get; } } -} \ No newline at end of file +} diff --git a/src/Framework/Core/Controls/IFilterableGridViewDataSet.cs b/src/Framework/Core/Controls/IFilterableGridViewDataSet.cs new file mode 100644 index 0000000000..22bf4fcdaf --- /dev/null +++ b/src/Framework/Core/Controls/IFilterableGridViewDataSet.cs @@ -0,0 +1,22 @@ +namespace DotVVM.Framework.Controls +{ + /// + /// Extends the base GridViewDataSet with filtering options. + /// + public interface IFilterableGridViewDataSet : IFilterableGridViewDataSet + where TFilteringOptions : IFilteringOptions + { + /// + /// Gets the settings for filtering. + /// + new TFilteringOptions FilteringOptions { get; } + } + + public interface IFilterableGridViewDataSet : IBaseGridViewDataSet + { + /// + /// Gets the settings for filtering. + /// + IFilteringOptions FilteringOptions { get; } + } +} diff --git a/src/Framework/Core/Controls/IGridViewDataSet.cs b/src/Framework/Core/Controls/IGridViewDataSet.cs index cde8a61fa5..202c5fb184 100644 --- a/src/Framework/Core/Controls/IGridViewDataSet.cs +++ b/src/Framework/Core/Controls/IGridViewDataSet.cs @@ -11,7 +11,7 @@ public interface IGridViewDataSet : IGridViewDataSet, IBaseGridViewDataSet /// /// Represents a collection of items with paging, sorting and row edit capabilities. /// - public interface IGridViewDataSet : IPageableGridViewDataSet, ISortableGridViewDataSet, IRowEditGridViewDataSet, IRefreshableGridViewDataSet + public interface IGridViewDataSet : IFilterableGridViewDataSet, ISortableGridViewDataSet, IPageableGridViewDataSet, IRowInsertGridViewDataSet, IRowEditGridViewDataSet, IRefreshableGridViewDataSet { } -} \ No newline at end of file +} diff --git a/src/Framework/Core/Controls/IPageableGridViewDataSet.cs b/src/Framework/Core/Controls/IPageableGridViewDataSet.cs index b8b303d5af..f0dfd82a19 100644 --- a/src/Framework/Core/Controls/IPageableGridViewDataSet.cs +++ b/src/Framework/Core/Controls/IPageableGridViewDataSet.cs @@ -3,17 +3,20 @@ namespace DotVVM.Framework.Controls /// /// Extends the with paging functionality. /// - public interface IPageableGridViewDataSet : IBaseGridViewDataSet + public interface IPageableGridViewDataSet : IPageableGridViewDataSet + where TPagingOptions : IPagingOptions { /// /// Gets the settings for paging. /// - IPagingOptions PagingOptions { get; } + new TPagingOptions PagingOptions { get; } + } + public interface IPageableGridViewDataSet : IBaseGridViewDataSet + { /// - /// Navigates to the specific page. + /// Gets the settings for paging. /// - /// The zero-based index of the page to navigate to. - void GoToPage(int index); + IPagingOptions PagingOptions { get; } } -} \ No newline at end of file +} diff --git a/src/Framework/Core/Controls/IRefreshableGridViewDataSet.cs b/src/Framework/Core/Controls/IRefreshableGridViewDataSet.cs index 1f5c6390d4..346185ceab 100644 --- a/src/Framework/Core/Controls/IRefreshableGridViewDataSet.cs +++ b/src/Framework/Core/Controls/IRefreshableGridViewDataSet.cs @@ -1,6 +1,4 @@ -using System.Threading.Tasks; - -namespace DotVVM.Framework.Controls +namespace DotVVM.Framework.Controls { /// /// Extends the with refresh functionality. @@ -11,11 +9,11 @@ public interface IRefreshableGridViewDataSet : IBaseGridViewDataSet /// Gets whether the data should be refreshed. This property is set to true automatically /// when paging, sorting or other options change. /// - bool IsRefreshRequired { get; } + bool IsRefreshRequired { get; set; } /// - /// Requests to reload data into the . + /// Sets the flag to true. /// void RequestRefresh(); } -} \ No newline at end of file +} diff --git a/src/Framework/Core/Controls/IRowEditGridViewDataSet.cs b/src/Framework/Core/Controls/IRowEditGridViewDataSet.cs index 342ac19ad7..c5c95d0085 100644 --- a/src/Framework/Core/Controls/IRowEditGridViewDataSet.cs +++ b/src/Framework/Core/Controls/IRowEditGridViewDataSet.cs @@ -1,8 +1,14 @@ namespace DotVVM.Framework.Controls { - /// - /// Extends the with row edit functionality. - /// + public interface IRowEditGridViewDataSet : IRowEditGridViewDataSet + where TRowEditOptions : IRowEditOptions + { + /// + /// Gets the settings for row (item) edit feature. + /// + new TRowEditOptions RowEditOptions { get; } + } + public interface IRowEditGridViewDataSet : IBaseGridViewDataSet { /// @@ -10,4 +16,4 @@ public interface IRowEditGridViewDataSet : IBaseGridViewDataSet /// IRowEditOptions RowEditOptions { get; } } -} \ No newline at end of file +} diff --git a/src/Framework/Core/Controls/IRowInsertGridViewDataSet.cs b/src/Framework/Core/Controls/IRowInsertGridViewDataSet.cs index f8e6fc3ec0..a4e26bd8fd 100644 --- a/src/Framework/Core/Controls/IRowInsertGridViewDataSet.cs +++ b/src/Framework/Core/Controls/IRowInsertGridViewDataSet.cs @@ -1,21 +1,19 @@ namespace DotVVM.Framework.Controls { + /// /// Extends the with row insert functionality. /// - /// The type of the elements. - public interface IRowInsertGridViewDataSet : IRowInsertGridViewDataSet, IBaseGridViewDataSet - where T : class, new() + public interface IRowInsertGridViewDataSet : IRowInsertGridViewDataSet + where TRowInsertOptions : IRowInsertOptions { + /// /// Gets the settings for row (item) insert feature. /// - new IRowInsertOptions RowInsertOptions { get; } + new TRowInsertOptions RowInsertOptions { get; } } - /// - /// Extends the with row insert functionality. - /// public interface IRowInsertGridViewDataSet : IBaseGridViewDataSet { /// diff --git a/src/Framework/Core/Controls/ISortableGridViewDataSet.cs b/src/Framework/Core/Controls/ISortableGridViewDataSet.cs index 93f062fd3b..fa6b2fcedd 100644 --- a/src/Framework/Core/Controls/ISortableGridViewDataSet.cs +++ b/src/Framework/Core/Controls/ISortableGridViewDataSet.cs @@ -3,16 +3,17 @@ namespace DotVVM.Framework.Controls /// /// Extends the base GridViewDataSet with sorting functionality. /// - public interface ISortableGridViewDataSet : IBaseGridViewDataSet + public interface ISortableGridViewDataSet : ISortableGridViewDataSet + where TSortingOptions : ISortingOptions { /// /// Gets the settings for sorting. /// - ISortingOptions SortingOptions { get; } + new TSortingOptions SortingOptions { get; } + } - /// - /// Sets the sort expression. If the specified expression is already set, switches the sort direction. - /// - void SetSortExpression(string expression); + public interface ISortableGridViewDataSet : IBaseGridViewDataSet + { + ISortingOptions SortingOptions { get; } } -} \ No newline at end of file +} diff --git a/src/Framework/Core/Controls/Options/DistanceNearPageIndexesProvider.cs b/src/Framework/Core/Controls/Options/DistanceNearPageIndexesProvider.cs deleted file mode 100644 index f40ac13687..0000000000 --- a/src/Framework/Core/Controls/Options/DistanceNearPageIndexesProvider.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace DotVVM.Framework.Controls -{ - /// - /// Provides a list of page indexes near current paged based on distance. - /// - public class DistanceNearPageIndexesProvider : INearPageIndexesProvider - { - private readonly int distance; - - /// - /// Initializes a new instance of the class. - /// - /// The distance specifying the range of page indexes to return. - public DistanceNearPageIndexesProvider(int distance) - { - this.distance = distance; - } - - /// - /// Gets a list of page indexes near current page. - /// - /// The settings for paging. - public IList GetIndexes(IPagingOptions pagingOptions) - { - var firstIndex = Math.Max(pagingOptions.PageIndex - distance, 0); - var lastIndex = Math.Min(pagingOptions.PageIndex + distance + 1, pagingOptions.PagesCount); - - return Enumerable - .Range(firstIndex, lastIndex - firstIndex) - .ToList(); - } - } -} diff --git a/src/Framework/Core/Controls/Options/GridViewDataSetOptions.cs b/src/Framework/Core/Controls/Options/GridViewDataSetOptions.cs new file mode 100644 index 0000000000..57a4c20c99 --- /dev/null +++ b/src/Framework/Core/Controls/Options/GridViewDataSetOptions.cs @@ -0,0 +1,20 @@ +namespace DotVVM.Framework.Controls +{ + /// Contains filtering, sorting, and paging options of a . + public class GridViewDataSetOptions + where TFilteringOptions : IFilteringOptions + where TSortingOptions : ISortingOptions + where TPagingOptions : IPagingOptions + { + public TFilteringOptions FilteringOptions { get; set; } = default!; + + public TSortingOptions SortingOptions { get; set; } = default!; + + public TPagingOptions PagingOptions { get; set; } = default!; + } + + /// Contains filtering, sorting, and paging options of a + public class GridViewDataSetOptions : GridViewDataSetOptions + { + } +} diff --git a/src/Framework/Core/Controls/Options/IApplyToQueryable.cs b/src/Framework/Core/Controls/Options/IApplyToQueryable.cs new file mode 100644 index 0000000000..d230c1108d --- /dev/null +++ b/src/Framework/Core/Controls/Options/IApplyToQueryable.cs @@ -0,0 +1,9 @@ +using System.Linq; + +namespace DotVVM.Framework.Controls +{ + public interface IApplyToQueryable + { + IQueryable ApplyToQueryable(IQueryable queryable); + } +} diff --git a/src/Framework/Core/Controls/Options/IFilteringOptions.cs b/src/Framework/Core/Controls/Options/IFilteringOptions.cs new file mode 100644 index 0000000000..e4fea3849c --- /dev/null +++ b/src/Framework/Core/Controls/Options/IFilteringOptions.cs @@ -0,0 +1,6 @@ +namespace DotVVM.Framework.Controls +{ + public interface IFilteringOptions + { + } +} \ No newline at end of file diff --git a/src/Framework/Core/Controls/Options/INearPageIndexesProvider.cs b/src/Framework/Core/Controls/Options/INearPageIndexesProvider.cs index 4f87677195..c457333d58 100644 --- a/src/Framework/Core/Controls/Options/INearPageIndexesProvider.cs +++ b/src/Framework/Core/Controls/Options/INearPageIndexesProvider.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace DotVVM.Framework.Controls @@ -5,6 +6,7 @@ namespace DotVVM.Framework.Controls /// /// Provides a list of page indexes near current page. It can be used to build data pagers. /// + [Obsolete("This interface was removed. If you want to provide your custom page numbers, inherit the PagingOptions class and override the GetNearPageIndexes method.", true)] public interface INearPageIndexesProvider { /// @@ -13,4 +15,4 @@ public interface INearPageIndexesProvider /// The settings for paging. IList GetIndexes(IPagingOptions pagingOptions); } -} \ No newline at end of file +} diff --git a/src/Framework/Core/Controls/Options/IPagingOptions.cs b/src/Framework/Core/Controls/Options/IPagingOptions.cs index ea5fabfc9f..bb836e29cf 100644 --- a/src/Framework/Core/Controls/Options/IPagingOptions.cs +++ b/src/Framework/Core/Controls/Options/IPagingOptions.cs @@ -3,43 +3,121 @@ namespace DotVVM.Framework.Controls { /// - /// Represents settings for paging. + /// Represents a marker interface for GridViewDataSet paging options. /// + /// + /// + /// public interface IPagingOptions + { + } + + /// + /// Represents a paging options which support navigating to the next page. + /// + public interface IPagingNextPageCapability : IPagingOptions { /// - /// Gets or sets a zero-based index of the current page. + /// Modifies the options to load the next page. /// - int PageIndex { get; set; } + void GoToNextPage(); /// - /// Gets or sets the maximum number of items on a page. + /// Gets whether the current page is the last page. /// - int PageSize { get; set; } + bool IsLastPage { get; } + } + /// + /// Represents a paging options which support navigating to the previous page. + /// + public interface IPagingPreviousPageCapability : IPagingOptions + { /// - /// Gets or sets the total number of items in the data store without respect to paging. + /// Modifies the options to load the previous page. /// - int TotalItemsCount { get; set; } + void GoToPreviousPage(); /// - /// Gets whether the represents the first page. + /// Gets whether the current page is the first page. /// bool IsFirstPage { get; } + } + + /// + /// Represents a paging options which support navigating to the first page. + /// + public interface IPagingFirstPageCapability : IPagingOptions + { + /// + /// Modifies the options to load the first page. + /// + void GoToFirstPage(); + + /// + /// Gets whether the current page is the first page. + /// + bool IsFirstPage { get; } + } + + /// + /// Represents a paging options which support navigating to the last page. + /// + public interface IPagingLastPageCapability : IPagingOptions + { + /// + /// Modifies the options to load the last page. + /// + void GoToLastPage(); /// - /// Gets whether the represents the last page. + /// Gets whether the current page is the last page. /// bool IsLastPage { get; } + } + /// + /// Represents a paging options which support navigating to the a specific page by its index. + /// + public interface IPagingPageIndexCapability : IPagingOptions + { /// - /// Gets the total number of pages. + /// Gets or sets a zero-based index of the current page. /// - int PagesCount { get; } + int PageIndex { get; } /// - /// Gets a list of page indexes near the current page. It can be used to build data pagers. + /// Modifies the options to load the specific page. + /// + /// A zero-based index of the new page + void GoToPage(int pageIndex); + + /// + /// Gets a list of page indexes near the current page. It can be used to tell the DataPager control which page numbers should be shown to the user. /// IList NearPageIndexes { get; } } -} \ No newline at end of file + + /// + /// Represents a paging options which are aware of the page size. + /// + public interface IPagingPageSizeCapability : IPagingOptions + { + /// + /// Gets or sets the maximum number of items on a page. + /// + int PageSize { get; set; } + + } + + /// + /// Represents a paging options which are aware of the total number of items in the data set. + /// + public interface IPagingTotalItemsCountCapability : IPagingOptions + { + /// + /// Gets or sets the total number of items in the data store without respect to paging. + /// + int TotalItemsCount { get; set; } + } +} diff --git a/src/Framework/Core/Controls/Options/IPagingOptionsLoadingPostProcessor.cs b/src/Framework/Core/Controls/Options/IPagingOptionsLoadingPostProcessor.cs new file mode 100644 index 0000000000..6356489788 --- /dev/null +++ b/src/Framework/Core/Controls/Options/IPagingOptionsLoadingPostProcessor.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Linq; + +namespace DotVVM.Framework.Controls; + +/// Provides an extension point to the method, which is invoked after the items are loaded from database. +public interface IPagingOptionsLoadingPostProcessor +{ + void ProcessLoadedItems(IQueryable filteredQueryable, IList items); +} diff --git a/src/Framework/Core/Controls/Options/IRowEditOptions.cs b/src/Framework/Core/Controls/Options/IRowEditOptions.cs index 9bd2ce83d6..9ae00d337a 100644 --- a/src/Framework/Core/Controls/Options/IRowEditOptions.cs +++ b/src/Framework/Core/Controls/Options/IRowEditOptions.cs @@ -8,12 +8,11 @@ public interface IRowEditOptions /// /// Gets or sets the name of a property that uniquely identifies a row. (row ID, primary key, etc.). The value may be left out if inline editing is not enabled. /// - string? PrimaryKeyPropertyName { get; set; } + string? PrimaryKeyPropertyName { get; } /// /// Gets or sets the value of a property for the row that is being edited. Null if nothing is edited. /// - object? EditRowId { get; set; } - + object? EditRowId { get; } } } diff --git a/src/Framework/Core/Controls/Options/IRowInsertOptions.cs b/src/Framework/Core/Controls/Options/IRowInsertOptions.cs index afc7eb6225..15159aa48e 100644 --- a/src/Framework/Core/Controls/Options/IRowInsertOptions.cs +++ b/src/Framework/Core/Controls/Options/IRowInsertOptions.cs @@ -3,24 +3,17 @@ /// /// Represents settings for row (item) insert feature. /// - /// The type of inserted row. - public interface IRowInsertOptions : IRowInsertOptions - where T : class, new() + public interface IRowInsertOptions { - /// - /// Gets or sets the row to be inserted to data source. Null means that row insertion is not activated. - /// - new T? InsertedRow { get; set; } } + /// - /// Represents settings for row (item) insert feature. + /// which may contain one inserted row. /// - public interface IRowInsertOptions + public interface ISingleRowInsertOptions: IRowInsertOptions + where T: class { - /// - /// Gets or sets the row to be inserted into data source. Null means that row insertion is not activated. - /// - object? InsertedRow { get; } + T? InsertedRow { get; } } } diff --git a/src/Framework/Core/Controls/Options/ISortingOptions.cs b/src/Framework/Core/Controls/Options/ISortingOptions.cs index 006fd8adf4..8b27b40161 100644 --- a/src/Framework/Core/Controls/Options/ISortingOptions.cs +++ b/src/Framework/Core/Controls/Options/ISortingOptions.cs @@ -1,18 +1,44 @@ namespace DotVVM.Framework.Controls { /// - /// Represents settings for sorting. + /// Represents a marker interface for sorting options. /// + /// + /// + /// public interface ISortingOptions + { + } + + /// + /// Represents sorting options that support changing the sort expression by the user. + /// + public interface ISortingSetSortExpressionCapability : ISortingOptions + { + /// + /// Determines whether the specified property can be used for sorting. + /// + bool IsSortingAllowed(string sortExpression); + + /// + /// Modifies the options to be sorted by the specified expression. + /// + void SetSortExpression(string? sortExpression); + } + + /// + /// Represents sorting options that can specify one sorting criterion. + /// + public interface ISortingStateCapability : ISortingOptions { /// - /// Gets or sets whether the sort order should be descending. + /// Determines whether the column with specified sort expression is sorted in ascending order. /// - bool SortDescending { get; set; } + bool IsColumnSortedAscending(string? sortExpression); /// - /// Gets or sets the name of the property that is used for sorting. Null means the grid should not be sorted. + /// Determines whether the column with specified sort expression is sorted in descending order. /// - string? SortExpression { get; set; } + bool IsColumnSortedDescending(string? sortExpression); } } diff --git a/src/Framework/Core/Controls/Options/MultiCriteriaSortingOptions.cs b/src/Framework/Core/Controls/Options/MultiCriteriaSortingOptions.cs new file mode 100644 index 0000000000..6d9c1e82e1 --- /dev/null +++ b/src/Framework/Core/Controls/Options/MultiCriteriaSortingOptions.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using DotVVM.Framework.Controls.Options; + +namespace DotVVM.Framework.Controls; + +/// +/// Sorting options which supports sorting by multiple columns. +/// In the default implementation, the sorting is applied in the reverse order of clicks on the columns - i.e. the last clicked column is the primary sort criterion, the previous one is the secondary criterion, etc. +/// It behaves similarly to the standard , except that sorting is "stable". +/// Maximum number of sort criteria can be set by , and is 3 by default. +/// +public class MultiCriteriaSortingOptions : ISortingOptions, ISortingStateCapability, ISortingSetSortExpressionCapability, IApplyToQueryable +{ + public IList Criteria { get; set; } = new List(); + + /// Maximum length of the list. When exceeded, the overhanging tail is discarded. + public int MaxSortCriteriaCount { get; set; } = 3; + + public virtual IQueryable ApplyToQueryable(IQueryable queryable) + { + foreach (var criterion in Criteria.Reverse()) + { + queryable = SortingImplementation.ApplySortingToQueryable(queryable, criterion.SortExpression, criterion.SortDescending); + } + return queryable; + } + + /// When overridden in derived class, it can disallow sorting by certain columns. + public virtual bool IsSortingAllowed(string sortExpression) => true; + + public virtual void SetSortExpression(string? sortExpression) + { + if (sortExpression is {} && !IsSortingAllowed(sortExpression)) + throw new ArgumentException($"Sorting by column '{sortExpression}' is not allowed."); + + if (sortExpression == null) + { + Criteria.Clear(); + return; + } + + var index = Criteria.ToList().FindIndex(c => c.SortExpression == sortExpression); + if (index == 0) + { + // toggle the sort direction if we clicked on the column on the front + Criteria[index].SortDescending = !Criteria[index].SortDescending; + } + else if (index > 0) + { + // if the column is already sorted, move it to the front + Criteria.RemoveAt(index); + Criteria.Insert(0, new SortCriterion() { SortExpression = sortExpression }); + } + else + { + // add the column to the front + Criteria.Insert(0, new SortCriterion() { SortExpression = sortExpression }); + } + + while (Criteria.Count > MaxSortCriteriaCount) + { + Criteria.RemoveAt(Criteria.Count - 1); + } + } + + /// Returns if the specified column is sorted in ascending order in any of the + public bool IsColumnSortedAscending(string? sortExpression) => Criteria.Any(c => c.SortExpression == sortExpression && !c.SortDescending); + + /// Returns if the specified column is sorted in descending order in any of the + public bool IsColumnSortedDescending(string? sortExpression) => Criteria.Any(c => c.SortExpression == sortExpression && c.SortDescending); +} + +/// +/// Represents a sort criterion. +/// +public sealed record SortCriterion +{ + /// + /// Gets or sets whether the sort order should be descending. + /// + public bool SortDescending { get; set; } + + /// + /// Gets or sets the name of the property that is used for sorting. Null means the grid should not be sorted. May contain chained properties separated by dots. + /// + public string? SortExpression { get; set; } +} diff --git a/src/Framework/Core/Controls/Options/NextTokenHistoryPagingOptions.cs b/src/Framework/Core/Controls/Options/NextTokenHistoryPagingOptions.cs new file mode 100644 index 0000000000..8e89ddd31c --- /dev/null +++ b/src/Framework/Core/Controls/Options/NextTokenHistoryPagingOptions.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace DotVVM.Framework.Controls +{ + /// + /// Token-based paging options that can navigate to next and all previous pages. + /// + public class NextTokenHistoryPagingOptions : IPagingFirstPageCapability, IPagingPreviousPageCapability, IPagingPageIndexCapability, IPagingNextPageCapability + { + /// List of all known pages, including the current page. First page has a null token. + public List TokenHistory { get; set; } = new(); + + /// Zero-based index of the current page in the + public int PageIndex { get; set; } = 0; + + /// Gets if we are on the first page (null token, zero page index). + public bool IsFirstPage => PageIndex == 0; + + /// Navigates to the first page, resets the to zero. + public void GoToFirstPage() => PageIndex = 0; + + /// Gets whether the represents the last page. + public bool IsLastPage => PageIndex == TokenHistory.Count - 1; + + /// Navigates to the next page, if possible + public void GoToNextPage() => + PageIndex = Math.Min(PageIndex + 1, TokenHistory.Count - 1); + + /// Navigates to the previous page, if possible + public void GoToPreviousPage() => + PageIndex = Math.Max(PageIndex - 1, 0); + + /// Navigates to the page specified using a zero-based index. + public void GoToPage(int pageIndex) + { + if (TokenHistory.Count <= pageIndex) + throw new ArgumentOutOfRangeException(nameof(pageIndex)); + PageIndex = pageIndex; + } + + /// + /// Gets the token for loading the current page. The first page token is always null. + /// + public virtual string? GetCurrentPageToken() + { + if (TokenHistory.Count == 0) + { + TokenHistory.Add(null); + } + + return PageIndex < TokenHistory.Count ? TokenHistory[PageIndex] : throw new InvalidOperationException($"Cannot get token for page {PageIndex}, because the token history contains only {TokenHistory.Count} items."); + } + + /// + /// Saves the token for loading the next page to the token history. + /// + public virtual void SaveNextPageToken(string? nextPageToken) + { + if (TokenHistory.Count == 0) + { + TokenHistory.Add(null); + } + + if (IsLastPage && nextPageToken != null) + { + TokenHistory.Add(nextPageToken); + } + else if (PageIndex > TokenHistory.Count) + { + throw new InvalidOperationException($"Cannot save token for page {PageIndex}, because the token history contains only {TokenHistory.Count} items.."); + } + } + + public IList NearPageIndexes => GetNearPageIndexes(); + + /// + /// Gets a list of page indexes near the current page. Override this method to provide your own strategy. + /// + protected virtual IList GetNearPageIndexes() + { + return GetDefaultNearPageIndexes(5); + } + + /// + /// Returns the near page indexes in the maximum specified distance from the current page index. + /// + protected IList GetDefaultNearPageIndexes(int distance) + { + var firstIndex = Math.Max(PageIndex - distance, 0); + var lastIndex = Math.Min(PageIndex + distance + 1, TokenHistory.Count); + return Enumerable + .Range(firstIndex, lastIndex - firstIndex) + .ToList(); + } + + } +} diff --git a/src/Framework/Core/Controls/Options/NextTokenPagingOptions.cs b/src/Framework/Core/Controls/Options/NextTokenPagingOptions.cs new file mode 100644 index 0000000000..0a894d3fd4 --- /dev/null +++ b/src/Framework/Core/Controls/Options/NextTokenPagingOptions.cs @@ -0,0 +1,38 @@ +namespace DotVVM.Framework.Controls +{ + /// + /// Simple token-based paging options that can navigate to the next page. + /// Paging back is not supported by this class, you can use + /// + public class NextTokenPagingOptions : IPagingFirstPageCapability, IPagingNextPageCapability + { + /// The paging token of the next page. If this property is null, then this is the last known page. + public string? NextPageToken { get; set; } + + /// The paging token of the currently displayed page. Equal to null, if this is the first page. + public string? CurrentToken { get; set; } + + /// Gets if we are on the first page — if the is null. + public bool IsFirstPage => CurrentToken == null; + + /// Navigates to the first page, resets both the current and next token to null + public void GoToFirstPage() + { + CurrentToken = null; + NextPageToken = null; + } + + /// Gets if it is on the last known page — if the is null. + public bool IsLastPage => NextPageToken == null; + + /// Navigates to the next page, sets the to the and resets the next token. + public void GoToNextPage() + { + if (NextPageToken != null) + { + CurrentToken = NextPageToken; + NextPageToken = null; + } + } + } +} diff --git a/src/Framework/Core/Controls/Options/NoFilteringOptions.cs b/src/Framework/Core/Controls/Options/NoFilteringOptions.cs new file mode 100644 index 0000000000..50a72732d3 --- /dev/null +++ b/src/Framework/Core/Controls/Options/NoFilteringOptions.cs @@ -0,0 +1,10 @@ +using System.Linq; + +namespace DotVVM.Framework.Controls +{ + /// Dataset with NoFilteringOptions does not support filtering. + public sealed class NoFilteringOptions : IFilteringOptions, IApplyToQueryable + { + public IQueryable ApplyToQueryable(IQueryable queryable) => queryable; + } +} diff --git a/src/Framework/Core/Controls/Options/NoPagingOptions.cs b/src/Framework/Core/Controls/Options/NoPagingOptions.cs new file mode 100644 index 0000000000..658c2e7a34 --- /dev/null +++ b/src/Framework/Core/Controls/Options/NoPagingOptions.cs @@ -0,0 +1,10 @@ +using System.Linq; + +namespace DotVVM.Framework.Controls +{ + /// Dataset with NoPagingOptions does not support paging. + public sealed class NoPagingOptions : IPagingOptions, IApplyToQueryable + { + public IQueryable ApplyToQueryable(IQueryable queryable) => queryable; + } +} diff --git a/src/Framework/Core/Controls/Options/NoRowEditOptions.cs b/src/Framework/Core/Controls/Options/NoRowEditOptions.cs new file mode 100644 index 0000000000..77d705385b --- /dev/null +++ b/src/Framework/Core/Controls/Options/NoRowEditOptions.cs @@ -0,0 +1,11 @@ +namespace DotVVM.Framework.Controls +{ + + /// Dataset with NoRowEditOptions does not support the row edit feature. + public sealed class NoRowEditOptions : IRowEditOptions + { + public string? PrimaryKeyPropertyName => null; + + public object? EditRowId => null; + } +} diff --git a/src/Framework/Core/Controls/Options/NoRowInsertOptions.cs b/src/Framework/Core/Controls/Options/NoRowInsertOptions.cs new file mode 100644 index 0000000000..b86da74231 --- /dev/null +++ b/src/Framework/Core/Controls/Options/NoRowInsertOptions.cs @@ -0,0 +1,7 @@ +namespace DotVVM.Framework.Controls +{ + /// Dataset with NoRowInsertOptions does not support the row insertion feature. + public sealed class NoRowInsertOptions : IRowInsertOptions + { + } +} diff --git a/src/Framework/Core/Controls/Options/NoSortingOptions.cs b/src/Framework/Core/Controls/Options/NoSortingOptions.cs new file mode 100644 index 0000000000..0ba3864090 --- /dev/null +++ b/src/Framework/Core/Controls/Options/NoSortingOptions.cs @@ -0,0 +1,10 @@ +using System.Linq; + +namespace DotVVM.Framework.Controls +{ + /// Dataset with NoSortingOptions does not support sorting. + public sealed class NoSortingOptions : ISortingOptions, IApplyToQueryable + { + public IQueryable ApplyToQueryable(IQueryable queryable) => queryable; + } +} diff --git a/src/Framework/Core/Controls/Options/PagingImplementation.cs b/src/Framework/Core/Controls/Options/PagingImplementation.cs new file mode 100644 index 0000000000..60a9d77851 --- /dev/null +++ b/src/Framework/Core/Controls/Options/PagingImplementation.cs @@ -0,0 +1,22 @@ +using System.Linq; + +namespace DotVVM.Framework.Controls +{ + public static class PagingImplementation + { + + /// + /// Applies paging to the after the total number + /// of items is retrieved. + /// + /// The to modify. + public static IQueryable ApplyPagingToQueryable(IQueryable queryable, TPagingOptions options) + where TPagingOptions : IPagingPageSizeCapability, IPagingPageIndexCapability + { + return options.PageSize > 0 + ? queryable.Skip(options.PageSize * options.PageIndex).Take(options.PageSize) + : queryable; + } + } + +} diff --git a/src/Framework/Core/Controls/Options/PagingOptions.cs b/src/Framework/Core/Controls/Options/PagingOptions.cs index 47b089cea8..9a626f53fe 100644 --- a/src/Framework/Core/Controls/Options/PagingOptions.cs +++ b/src/Framework/Core/Controls/Options/PagingOptions.cs @@ -1,19 +1,21 @@ using System; using System.Collections.Generic; +using System.Linq; using DotVVM.Framework.ViewModel; namespace DotVVM.Framework.Controls { /// - /// Represents settings for paging. + /// Represents settings for offset-based paging using and . /// - public class PagingOptions : IPagingOptions + public class PagingOptions : IPagingOptions, IPagingFirstPageCapability, IPagingLastPageCapability, IPagingPreviousPageCapability, IPagingNextPageCapability, IPagingPageIndexCapability, IPagingPageSizeCapability, IPagingTotalItemsCountCapability, IApplyToQueryable, IPagingOptionsLoadingPostProcessor { /// /// Gets or sets the object that provides a list of page indexes near the current page. /// [Bind(Direction.None)] - public INearPageIndexesProvider NearPageIndexesProvider { get; set; } = new DistanceNearPageIndexesProvider(5); + [Obsolete("This interface was removed. If you want to provide your custom page numbers, inherit the PagingOptions class and override the GetNearPageIndexes method.", true)] + public INearPageIndexesProvider NearPageIndexesProvider { get; set; } = null!; /// /// Gets whether the represents the first page. @@ -55,9 +57,71 @@ public int PagesCount /// public int TotalItemsCount { get; set; } + /// Sets PageIndex to zero. + public void GoToFirstPage() => PageIndex = 0; + + /// Sets PageIndex to the last page (PagesCount - 1). + public void GoToLastPage() => PageIndex = PagesCount - 1; + + /// Increments the page counter, if the next page exists. + public void GoToNextPage() + { + if (PageIndex < PagesCount - 1) + { + PageIndex++; + } + } + /// Decrements the page counter, unless PageIndex is already zero. + public void GoToPreviousPage() + { + if (PageIndex > 0) + { + PageIndex--; + } + } + /// Sets page index to the . If the index overflows, PageIndex is set to the first/last page. + public void GoToPage(int pageIndex) + { + if (PageIndex >= 0 && PageIndex < PagesCount) + { + PageIndex = pageIndex; + } + } + + /// /// Gets a list of page indexes near the current page. It can be used to build data pagers. /// - public IList NearPageIndexes => NearPageIndexesProvider.GetIndexes(this); + public IList NearPageIndexes => GetNearPageIndexes(); + + /// + /// Gets a list of page indexes near the current page. Override this method to provide your own strategy. + /// + public virtual IList GetNearPageIndexes() + { + return GetDefaultNearPageIndexes(5); + } + + /// + /// Returns the near page indexes in the maximum specified distance from the current page index. + /// + protected virtual IList GetDefaultNearPageIndexes(int distance) + { + var firstIndex = Math.Max(PageIndex - distance, 0); + var lastIndex = Math.Min(PageIndex + distance + 1, PagesCount); + return Enumerable + .Range(firstIndex, lastIndex - firstIndex) + .ToList(); + } + + public virtual IQueryable ApplyToQueryable(IQueryable queryable) + { + return PagingImplementation.ApplyPagingToQueryable(queryable, this); + } + + public virtual void ProcessLoadedItems(IQueryable filteredQueryable, IList items) + { + TotalItemsCount = filteredQueryable.Count(); + } } } diff --git a/src/Framework/Core/Controls/Options/RowInsertOptions.cs b/src/Framework/Core/Controls/Options/RowInsertOptions.cs index 4f376d6162..f245f06db1 100644 --- a/src/Framework/Core/Controls/Options/RowInsertOptions.cs +++ b/src/Framework/Core/Controls/Options/RowInsertOptions.cs @@ -4,13 +4,12 @@ /// Represents settings for row (item) insert feature. /// /// The type of inserted row. - public class RowInsertOptions : IRowInsertOptions where T : class, new() + public class RowInsertOptions : IRowInsertOptions, ISingleRowInsertOptions + where T : class, new() { /// /// Gets or sets the row to be inserted to data source. /// public T? InsertedRow { get; set; } - - object? IRowInsertOptions.InsertedRow => InsertedRow; } } diff --git a/src/Framework/Core/Controls/Options/SortingImplementation.cs b/src/Framework/Core/Controls/Options/SortingImplementation.cs new file mode 100644 index 0000000000..17fdbaaf26 --- /dev/null +++ b/src/Framework/Core/Controls/Options/SortingImplementation.cs @@ -0,0 +1,62 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using DotVVM.Framework.ViewModel; + +namespace DotVVM.Framework.Controls.Options +{ + public static class SortingImplementation + { + + /// + /// Applies sorting to the after the total number + /// of items is retrieved. + /// + /// The to modify. + public static IQueryable ApplySortingToQueryable(IQueryable queryable, string? sortExpression, bool sortDescending) + { + if (sortExpression == null) + { + return queryable; + } + + var parameterExpression = Expression.Parameter(typeof(T), "p"); + Expression sortByExpression = parameterExpression; + foreach (var prop in sortExpression.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries)) + { + var property = sortByExpression.Type.GetProperty(prop); + if (property == null) + { + throw new Exception($"Could not sort by property '{prop}', since it does not exists."); + } + if (property.GetCustomAttribute() is BindAttribute bind && bind.Direction == Direction.None) + { + throw new Exception($"Cannot sort by an property '{prop}' that has [Bind(Direction.None)]."); + } + if (property.GetCustomAttribute() is ProtectAttribute protect && protect.Settings == ProtectMode.EncryptData) + { + throw new Exception($"Cannot sort by an property '{prop}' that is encrypted."); + } + + sortByExpression = Expression.Property(sortByExpression, property); + } + if (sortByExpression == parameterExpression) // no sorting + { + return queryable; + } + var lambdaExpression = Expression.Lambda(sortByExpression, parameterExpression); + var methodCallExpression = Expression.Call(typeof(Queryable), GetSortingMethodName(sortDescending), + new[] { parameterExpression.Type, sortByExpression.Type }, + queryable.Expression, + Expression.Quote(lambdaExpression)); + return queryable.Provider.CreateQuery(methodCallExpression); + } + + private static string GetSortingMethodName(bool sortDescending) + { + return sortDescending ? "OrderByDescending" : "OrderBy"; + } + + } +} diff --git a/src/Framework/Core/Controls/Options/SortingOptions.cs b/src/Framework/Core/Controls/Options/SortingOptions.cs index e7fe67fc1a..af52efdae0 100644 --- a/src/Framework/Core/Controls/Options/SortingOptions.cs +++ b/src/Framework/Core/Controls/Options/SortingOptions.cs @@ -1,9 +1,16 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using DotVVM.Framework.Controls.Options; +using DotVVM.Framework.ViewModel; + namespace DotVVM.Framework.Controls { /// - /// Represents settings for sorting. + /// Represents a default implementation of the sorting options. /// - public class SortingOptions : ISortingOptions + public class SortingOptions : ISortingOptions, ISortingStateCapability, ISortingSetSortExpressionCapability, IApplyToQueryable { /// /// Gets or sets whether the sort order should be descending. @@ -14,5 +21,48 @@ public class SortingOptions : ISortingOptions /// Gets or sets the name of the property that is used for sorting. Null means the grid should not be sorted. /// public string? SortExpression { get; set; } + + /// + /// Determines whether the specified property can be used for sorting. + /// + public virtual bool IsSortingAllowed(string sortExpression) => true; + + /// + /// Modifies the options to be sorted by the specified expression. + /// + public virtual void SetSortExpression(string? sortExpression) + { + if (sortExpression is {} && !IsSortingAllowed(sortExpression)) + throw new ArgumentException($"Sorting by column '{sortExpression}' is not allowed."); + + if (sortExpression == null) + { + SortExpression = null; + SortDescending = false; + } + else if (sortExpression == SortExpression) + { + SortDescending = !SortDescending; + } + else + { + SortExpression = sortExpression; + SortDescending = false; + } + } + + /// + /// Applies the sorting options to the specified IQueryable expression. + /// + public virtual IQueryable ApplyToQueryable(IQueryable queryable) + { + return SortingImplementation.ApplySortingToQueryable(queryable, SortExpression, SortDescending); + } + + /// + public bool IsColumnSortedAscending(string? sortExpression) => SortExpression == sortExpression && !SortDescending; + + /// + public bool IsColumnSortedDescending(string? sortExpression) => SortExpression == sortExpression && SortDescending; } } diff --git a/src/Framework/Framework/Binding/BindingHelper.cs b/src/Framework/Framework/Binding/BindingHelper.cs index eb1a4719b0..f319609f2b 100644 --- a/src/Framework/Framework/Binding/BindingHelper.cs +++ b/src/Framework/Framework/Binding/BindingHelper.cs @@ -20,6 +20,7 @@ using DotVVM.Framework.Runtime; using FastExpressionCompiler; using System.Diagnostics; +using System.Reflection; namespace DotVVM.Framework.Binding { @@ -242,6 +243,8 @@ public static T GetCommandDelegate(this ICommandBinding binding, DotvvmBin if (action is Func command2) return command2(); var parameters = action.GetType().GetMethod("Invoke")!.GetParameters(); + if (parameters.Length != args.Length) + throw new TargetParameterCountException($"Parameter count mismatch: received {args.Length}, but expected {parameters.Length} ({parameters.Select(p => p.ParameterType.ToCode()).StringJoin(", ")})"); var evaluatedArgs = args.Zip(parameters, (a, p) => a(p.ParameterType)).ToArray(); return action.DynamicInvoke(evaluatedArgs); } @@ -253,6 +256,33 @@ public static ParametrizedCode GetParametrizedCommandJavascript(this ICommandBin JavascriptTranslator.AdjustKnockoutScriptContext(binding.CommandJavascript, dataContextLevel: FindDataContextTarget(binding, control).stepsUp); + /// + /// Gets command arguments parametrized code from the arguments collection. + /// + public static CodeParameterAssignment? GetParametrizedCommandArgs(DotvvmControl control, IEnumerable? argumentsCollection) + { + if (argumentsCollection is null) return null; + var builder = new ParametrizedCode.Builder(); + var isFirst = true; + + builder.Add("["); + foreach (var arg in argumentsCollection) + { + if (!isFirst) + { + builder.Add(","); + } + + isFirst = false; + + builder.Add(ValueOrBinding.FromBoxedValue(arg).GetParametrizedJsExpression(control)); + } + + builder.Add("]"); + + return builder.Build(OperatorPrecedence.Max); + } + public static object? GetBindingValue(this IBinding binding, DotvvmBindableObject control) { if (binding is IStaticValueBinding valueBinding) @@ -373,7 +403,13 @@ public static TControl SetDataContextTypeFromDataSource(this TControl return dataContextType; } - var (childType, extensionParameters) = ApplyDataContextChange(dataContextType, property.DataContextChangeAttributes, obj, property); + var (childType, extensionParameters, addLayer) = ApplyDataContextChange(dataContextType, property.DataContextChangeAttributes, obj, property); + + if (!addLayer) + { + Debug.Assert(childType == dataContextType.DataContextType); + return DataContextStack.Create(dataContextType.DataContextType, dataContextType.Parent, dataContextType.NamespaceImports, extensionParameters.Concat(dataContextType.ExtensionParameters).ToArray(), dataContextType.BindingPropertyResolvers); + } if (childType is null) return null; // childType is null in case there is some error in processing (e.g. enumerable was expected). else return DataContextStack.Create(childType, dataContextType, extensionParameters: extensionParameters.ToArray()); @@ -396,7 +432,13 @@ public static DataContextStack GetDataContextType(this DotvvmProperty property, return dataContextType; } - var (childType, extensionParameters) = ApplyDataContextChange(dataContextType, property.DataContextChangeAttributes, obj, property); + var (childType, extensionParameters, addLayer) = ApplyDataContextChange(dataContextType, property.DataContextChangeAttributes, obj, property); + + if (!addLayer) + { + Debug.Assert(childType == dataContextType.DataContextType); + return DataContextStack.Create(dataContextType.DataContextType, dataContextType.Parent, dataContextType.NamespaceImports, extensionParameters.Concat(dataContextType.ExtensionParameters).ToArray(), dataContextType.BindingPropertyResolvers); + } if (childType is null) childType = typeof(UnknownTypeSentinel); @@ -404,33 +446,43 @@ public static DataContextStack GetDataContextType(this DotvvmProperty property, return DataContextStack.Create(childType, dataContextType, extensionParameters: extensionParameters.ToArray()); } - public static (Type? type, List extensionParameters) ApplyDataContextChange(DataContextStack dataContext, DataContextChangeAttribute[] attributes, ResolvedControl control, DotvvmProperty? property) + public static (Type? type, List extensionParameters, bool addLayer) ApplyDataContextChange(DataContextStack dataContext, DataContextChangeAttribute[] attributes, ResolvedControl control, DotvvmProperty? property) { var type = ResolvedTypeDescriptor.Create(dataContext.DataContextType); var extensionParameters = new List(); + var addLayer = false; foreach (var attribute in attributes.OrderBy(a => a.Order)) { if (type == null) break; extensionParameters.AddRange(attribute.GetExtensionParameters(type)); - type = attribute.GetChildDataContextType(type, dataContext, control, property); + if (attribute.NestDataContext) + { + addLayer = true; + type = attribute.GetChildDataContextType(type, dataContext, control, property); + } } - return (ResolvedTypeDescriptor.ToSystemType(type), extensionParameters); + return (ResolvedTypeDescriptor.ToSystemType(type), extensionParameters, addLayer); } - private static (Type? childType, List extensionParameters) ApplyDataContextChange(DataContextStack dataContextType, DataContextChangeAttribute[] attributes, DotvvmBindableObject obj, DotvvmProperty property) + private static (Type? childType, List extensionParameters, bool addLayer) ApplyDataContextChange(DataContextStack dataContextType, DataContextChangeAttribute[] attributes, DotvvmBindableObject obj, DotvvmProperty property) { Type? type = dataContextType.DataContextType; var extensionParameters = new List(); + var addLayer = false; foreach (var attribute in attributes.OrderBy(a => a.Order)) { if (type == null) break; extensionParameters.AddRange(attribute.GetExtensionParameters(new ResolvedTypeDescriptor(type))); - type = attribute.GetChildDataContextType(type, dataContextType, obj, property); + if (attribute.NestDataContext) + { + addLayer = true; + type = attribute.GetChildDataContextType(type, dataContextType, obj, property); + } } - return (type, extensionParameters); + return (type, extensionParameters, addLayer); } /// diff --git a/src/Framework/Framework/Binding/CommonBindings.cs b/src/Framework/Framework/Binding/CommonBindings.cs deleted file mode 100644 index ae5e3c9fe4..0000000000 --- a/src/Framework/Framework/Binding/CommonBindings.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using DotVVM.Framework.Binding.Expressions; - -namespace DotVVM.Framework.Binding -{ - public class CommonBindings - { - public CommonBindings(BindingCompilationService service) - { - this.BindingCompilationService = service; - } - - public BindingCompilationService BindingCompilationService { get; } - } -} diff --git a/src/Framework/Framework/Binding/DataContextChangeAttribute.cs b/src/Framework/Framework/Binding/DataContextChangeAttribute.cs index 582074f67b..3c07521828 100644 --- a/src/Framework/Framework/Binding/DataContextChangeAttribute.cs +++ b/src/Framework/Framework/Binding/DataContextChangeAttribute.cs @@ -8,6 +8,7 @@ using System.Linq.Expressions; using DotVVM.Framework.Compilation.ControlTree; using DotVVM.Framework.Compilation.ControlTree.Resolved; +using System.ComponentModel; namespace DotVVM.Framework.Binding { @@ -26,6 +27,10 @@ public abstract class DataContextChangeAttribute : Attribute /// Returning null means that the data context should not be changed. This overload is used at runtime, by `DotvvmProperty.GetDataContextType(DotvvmBindableObject)` helper method. public abstract Type? GetChildDataContextType(Type dataContext, DataContextStack controlContextStack, DotvvmBindableObject control, DotvvmProperty? property = null); + /// Whether new layer of DataContext should be created, or the current one should be adjusted (extension parameters will be added). + [DefaultValue(true)] + public virtual bool NestDataContext => true; + /// Gets the extension parameters that should be made available to the bindings inside. public virtual IEnumerable GetExtensionParameters(ITypeDescriptor dataContext) => Enumerable.Empty(); diff --git a/src/Framework/Framework/Binding/HelperNamespace/DataPagerApi.cs b/src/Framework/Framework/Binding/HelperNamespace/DataPagerApi.cs new file mode 100644 index 0000000000..73bab36afb --- /dev/null +++ b/src/Framework/Framework/Binding/HelperNamespace/DataPagerApi.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using DotVVM.Framework.Compilation.ControlTree; +using DotVVM.Framework.Compilation.ControlTree.Resolved; +using DotVVM.Framework.Compilation.Javascript; +using DotVVM.Framework.Compilation.Javascript.Ast; +using DotVVM.Framework.Controls; + +namespace DotVVM.Framework.Binding.HelperNamespace +{ + public class DataPagerApi + { + public void Load() => throw new NotSupportedException("The _dataPager.Load method is not supported on the server, please use a staticCommand to invoke it."); + + public bool IsLoading => false; + + public bool CanLoadNextPage => true; + + + public class DataPagerExtensionParameter : BindingExtensionParameter + { + public DataPagerExtensionParameter(string identifier, bool inherit = true) : base(identifier, ResolvedTypeDescriptor.Create(typeof(DataPagerApi)), inherit) + { + } + + internal static void Register(JavascriptTranslatableMethodCollection collection) + { + collection.AddMethodTranslator(() => default(DataPagerApi)!.Load(), new GenericMethodCompiler(args => + args[0].Member("$appendableDataPager").Member("loadNextPage").Invoke().WithAnnotation(new ResultIsPromiseAnnotation(e => e)))); + + collection.AddPropertyGetterTranslator(typeof(DataPagerApi), nameof(IsLoading), new GenericMethodCompiler(args => + args[0].Member("$appendableDataPager").Member("isLoading").WithAnnotation(ResultIsObservableAnnotation.Instance))); + + collection.AddPropertyGetterTranslator(typeof(DataPagerApi), nameof(CanLoadNextPage), new GenericMethodCompiler(args => + args[0].Member("$appendableDataPager").Member("canLoadNextPage").WithAnnotation(ResultIsObservableAnnotation.Instance))); + } + + public override JsExpression GetJsTranslation(JsExpression dataContext) => + dataContext; + public override Expression GetServerEquivalent(Expression controlParameter) => + Expression.New(typeof(DataPagerApi)); + } + + public class AddParameterDataContextChangeAttribute: DataContextChangeAttribute + { + public AddParameterDataContextChangeAttribute(string name = "_dataPager", int order = 0) + { + Name = name; + Order = order; + } + + public string Name { get; } + public override int Order { get; } + + public override ITypeDescriptor? GetChildDataContextType(ITypeDescriptor dataContext, IDataContextStack controlContextStack, IAbstractControl control, IPropertyDescriptor? property = null) => + dataContext; + public override Type? GetChildDataContextType(Type dataContext, DataContextStack controlContextStack, DotvvmBindableObject control, DotvvmProperty? property = null) => dataContext; + + public override bool NestDataContext => false; + public override IEnumerable GetExtensionParameters(ITypeDescriptor dataContext) + { + return new BindingExtensionParameter[] { + new DataPagerExtensionParameter(Name) + }; + } + } + } +} diff --git a/src/Framework/Framework/Compilation/Binding/GeneralBindingPropertyResolvers.cs b/src/Framework/Framework/Compilation/Binding/GeneralBindingPropertyResolvers.cs index e0ffccd615..e29eb171f0 100644 --- a/src/Framework/Framework/Compilation/Binding/GeneralBindingPropertyResolvers.cs +++ b/src/Framework/Framework/Compilation/Binding/GeneralBindingPropertyResolvers.cs @@ -427,10 +427,9 @@ public ThisBindingProperty GetThisBinding(IBinding binding, DataContextStack sta public CollectionElementDataContextBindingProperty GetCollectionElementDataContext(DataContextStack dataContext, ResultTypeBindingProperty resultType) { - return new CollectionElementDataContextBindingProperty(DataContextStack.Create( + return new CollectionElementDataContextBindingProperty(DataContextStack.CreateCollectionElement( ReflectionUtils.GetEnumerableType(resultType.Type).NotNull(), - parent: dataContext, - extensionParameters: new CollectionElementDataContextChangeAttribute(0).GetExtensionParameters(new ResolvedTypeDescriptor(dataContext.DataContextType)).ToArray() + dataContext )); } diff --git a/src/Framework/Framework/Compilation/ControlTree/ControlTreeResolverBase.cs b/src/Framework/Framework/Compilation/ControlTree/ControlTreeResolverBase.cs index ae1474660f..59a8a02cf2 100644 --- a/src/Framework/Framework/Compilation/ControlTree/ControlTreeResolverBase.cs +++ b/src/Framework/Framework/Compilation/ControlTree/ControlTreeResolverBase.cs @@ -745,9 +745,10 @@ protected virtual bool IsControlProperty(IPropertyDescriptor property) try { - var (type, extensionParameters) = ApplyContextChange(dataContext, attributes, control, property); + var (type, extensionParameters, addLayer) = ApplyContextChange(dataContext, attributes, control, property); if (type == null) return dataContext; + else if (!addLayer) return CreateDataContextTypeStack(dataContext.DataContextType, dataContext.Parent, dataContext.NamespaceImports, extensionParameters.Concat(dataContext.ExtensionParameters).ToArray()); else return CreateDataContextTypeStack(type, parentDataContextStack: dataContext, extensionParameters: extensionParameters.ToArray()); } catch (Exception exception) @@ -759,17 +760,23 @@ protected virtual bool IsControlProperty(IPropertyDescriptor property) } } - public static (ITypeDescriptor? type, List extensionParameters) ApplyContextChange(IDataContextStack dataContext, DataContextChangeAttribute[] attributes, IAbstractControl control, IPropertyDescriptor? property) + public static (ITypeDescriptor? type, List extensionParameters, bool addLayer) ApplyContextChange(IDataContextStack dataContext, DataContextChangeAttribute[] attributes, IAbstractControl control, IPropertyDescriptor? property) { var type = dataContext.DataContextType; var extensionParameters = new List(); + var addLayer = false; foreach (var attribute in attributes.OrderBy(a => a.Order)) { if (type == null) break; extensionParameters.AddRange(attribute.GetExtensionParameters(type)); - type = attribute.GetChildDataContextType(type, dataContext, control, property); + if (attribute.NestDataContext) + { + addLayer = true; + type = attribute.GetChildDataContextType(type, dataContext, control, property); + } + } - return (type, extensionParameters); + return (type, extensionParameters, addLayer); } diff --git a/src/Framework/Framework/Compilation/ControlTree/DataContextStack.cs b/src/Framework/Framework/Compilation/ControlTree/DataContextStack.cs index a655cadc35..f43a04f963 100644 --- a/src/Framework/Framework/Compilation/ControlTree/DataContextStack.cs +++ b/src/Framework/Framework/Compilation/ControlTree/DataContextStack.cs @@ -4,6 +4,7 @@ using System.Collections.Immutable; using System.Linq; using System.Runtime.CompilerServices; +using DotVVM.Framework.Binding; using DotVVM.Framework.Compilation.ControlTree.Resolved; using DotVVM.Framework.Utils; using FastExpressionCompiler; @@ -172,5 +173,23 @@ public static DataContextStack Create(Type type, var dcs = new DataContextStack(type, parent, imports, extensionParameters, bindingPropertyResolvers); return dcs;// internCache.GetValue(dcs, _ => dcs); } + + + /// Creates a new data context level with _index and _collection extension parameters. + public static DataContextStack CreateCollectionElement(Type elementType, + DataContextStack? parent = null, + IReadOnlyList? imports = null, + IReadOnlyList? extensionParameters = null, + IReadOnlyList? bindingPropertyResolvers = null) + { + var indexParameters = new CollectionElementDataContextChangeAttribute(0).GetExtensionParameters(new ResolvedTypeDescriptor(elementType.MakeArrayType())); + extensionParameters = extensionParameters is null ? indexParameters.ToArray() : extensionParameters.Concat(indexParameters).ToArray(); + return DataContextStack.Create( + elementType, parent, + imports: imports, + extensionParameters: extensionParameters, + bindingPropertyResolvers: bindingPropertyResolvers + ); + } } } diff --git a/src/Framework/Framework/Compilation/Javascript/Ast/JsAstHelpers.cs b/src/Framework/Framework/Compilation/Javascript/Ast/JsAstHelpers.cs index 53e710a8a8..b1f9593e3f 100644 --- a/src/Framework/Framework/Compilation/Javascript/Ast/JsAstHelpers.cs +++ b/src/Framework/Framework/Compilation/Javascript/Ast/JsAstHelpers.cs @@ -46,6 +46,11 @@ public static JsBlockStatement AsBlock(this JsStatement statement) => public static JsBlockStatement AsBlock(this IEnumerable statements) => new JsBlockStatement(statements); + public static JsExpression AsSequenceOperators(this IEnumerable expressions) => + expressions.OfType().Aggregate((a, b) => new JsBinaryExpression(a, BinaryOperatorType.Sequence, b)); + public static JsExpression AsSequenceOperators(params JsExpression?[] expressions) => + expressions.AsSequenceOperators(); + public static JsArrayExpression ArrayExpression(this IEnumerable items) => new JsArrayExpression(items); diff --git a/src/Framework/Framework/Compilation/Javascript/JavascriptTranslatableMethodCollection.cs b/src/Framework/Framework/Compilation/Javascript/JavascriptTranslatableMethodCollection.cs index fbbf532109..c9516a745f 100644 --- a/src/Framework/Framework/Compilation/Javascript/JavascriptTranslatableMethodCollection.cs +++ b/src/Framework/Framework/Compilation/Javascript/JavascriptTranslatableMethodCollection.cs @@ -80,6 +80,7 @@ public void AddMethodTranslator(Expression> methodCall, IJavascriptMe CheckNotAccidentalDefinition(method); AddMethodTranslator(method, translator); } + public void AddMethodTranslator(Expression methodCall, IJavascriptMethodTranslator translator) { var method = (MethodInfo)MethodFindingHelper.GetMethodFromExpression(methodCall); @@ -244,8 +245,9 @@ JsExpression dictionarySetIndexer(JsExpression[] args, MethodInfo method) => AddDefaultMathTranslations(); AddDefaultDateTimeTranslations(); AddDefaultConvertTranslations(); + AddDataSetOptionsTranslations(); } - + private void AddDefaultToStringTranslations() { AddMethodTranslator(typeof(object).GetMethod("ToString", Type.EmptyTypes), new PrimitiveToStringTranslator()); @@ -831,6 +833,95 @@ JsExpression wrapInRound(JsExpression a) => } } } + + private void AddDataSetOptionsTranslations() + { + // GridViewDataSetBindingProvider + AddMethodTranslator(typeof(GridViewDataSetBindingProvider), nameof(GridViewDataSetBindingProvider.DataSetClientSideLoad), new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("loadDataSet").Invoke( + args[1].WithAnnotation(ShouldBeObservableAnnotation.Instance), + args[2], + GridViewDataSetBindingProvider.LoadDataDelegate.ToExpression(), + GridViewDataSetBindingProvider.PostProcessorDelegate.ToExpression() + ).WithAnnotation(new ResultIsPromiseAnnotation(e => e)))); + + // _dataPager.Load() + DataPagerApi.DataPagerExtensionParameter.Register(this); + + // GridViewDataSet + var getOptions = new GenericMethodCompiler((JsExpression[] args, MethodInfo m) => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("getOptions") + .Invoke(args[1].WithAnnotation(ShouldBeObservableAnnotation.Instance)) + .WithAnnotation(new ViewModelInfoAnnotation(m.ReturnType, containsObservables: false)) + ); + AddMethodTranslator(typeof(GenericGridViewDataSet<,,,,,>).GetMethod("GetOptions"), getOptions); + AddMethodTranslator(() => default(GridViewDataSet)!.GetOptions(), getOptions); + + // PagingOptions + AddMethodTranslator(() => default(PagingOptions)!.GoToFirstPage(),new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("translations").Member("PagingOptions").Member("goToFirstPage") + .Invoke(args[0].WithAnnotation(ShouldBeObservableAnnotation.Instance)))); + AddMethodTranslator(() => default(PagingOptions)!.GoToLastPage(), new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("translations").Member("PagingOptions").Member("goToLastPage") + .Invoke(args[0].WithAnnotation(ShouldBeObservableAnnotation.Instance)))); + AddMethodTranslator(() => default(PagingOptions)!.GoToPreviousPage(), new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("translations").Member("PagingOptions").Member("goToPreviousPage") + .Invoke(args[0].WithAnnotation(ShouldBeObservableAnnotation.Instance)))); + AddMethodTranslator(() => default(PagingOptions)!.GoToNextPage(), new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("translations").Member("PagingOptions").Member("goToNextPage") + .Invoke(args[0].WithAnnotation(ShouldBeObservableAnnotation.Instance)))); + AddMethodTranslator(() => default(PagingOptions)!.GoToPage(default(int)), new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("translations").Member("PagingOptions").Member("goToPage") + .Invoke(args[0].WithAnnotation(ShouldBeObservableAnnotation.Instance), args[1]))); + + // NextTokenPagingOptions + AddMethodTranslator(() => default(NextTokenPagingOptions)!.GoToFirstPage(), new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("translations").Member("NextTokenPagingOptions").Member("goToFirstPage") + .Invoke(args[0].WithAnnotation(ShouldBeObservableAnnotation.Instance)))); + AddMethodTranslator(() => default(NextTokenPagingOptions)!.GoToNextPage(), new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("translations").Member("NextTokenPagingOptions").Member("goToNextPage") + .Invoke(args[0].WithAnnotation(ShouldBeObservableAnnotation.Instance)))); + + // NextTokenHistoryPagingOptions + AddMethodTranslator(() => default(NextTokenHistoryPagingOptions)!.GoToFirstPage(), new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("translations").Member("NextTokenHistoryPagingOptions").Member("goToFirstPage") + .Invoke(args[0].WithAnnotation(ShouldBeObservableAnnotation.Instance)))); + AddMethodTranslator(() => default(NextTokenHistoryPagingOptions)!.GoToPreviousPage(), new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("translations").Member("NextTokenHistoryPagingOptions").Member("goToPreviousPage") + .Invoke(args[0].WithAnnotation(ShouldBeObservableAnnotation.Instance)))); + AddMethodTranslator(() => default(NextTokenHistoryPagingOptions)!.GoToNextPage(), new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("translations").Member("NextTokenHistoryPagingOptions").Member("goToNextPage") + .Invoke(args[0].WithAnnotation(ShouldBeObservableAnnotation.Instance)))); + AddMethodTranslator(() => default(NextTokenHistoryPagingOptions)!.GoToPage(default(int)), new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("translations").Member("NextTokenHistoryPagingOptions").Member("goToPage") + .Invoke(args[0].WithAnnotation(ShouldBeObservableAnnotation.Instance), args[1]))); + + // SortingOptions + AddSetSortExpressionTranslation(); + AddSortingStateTranslation(); + AddSetSortExpressionTranslation(); + AddSortingStateTranslation(); + + void AddSetSortExpressionTranslation(string? clientTypeName = null) + where TSortingOptions : ISortingOptions, ISortingSetSortExpressionCapability + { + AddMethodTranslator(typeof(TSortingOptions), nameof(ISortingSetSortExpressionCapability.SetSortExpression), new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("translations").Member(clientTypeName ?? typeof(TSortingOptions).Name).Member("setSortExpression") + .Invoke(args[0].WithAnnotation(ShouldBeObservableAnnotation.Instance), args[1]))); + } + + void AddSortingStateTranslation(string? clientTypeName = null) + where TSortingOptions : ISortingOptions, ISortingStateCapability + { + AddMethodTranslator(typeof(TSortingOptions), nameof(ISortingStateCapability.IsColumnSortedAscending), new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("translations").Member(clientTypeName ?? typeof(TSortingOptions).Name).Member("isColumnSortedAscending") + .Invoke(new JsIdentifierExpression("ko").Member("toJS").Invoke(args[0].WithAnnotation(ShouldBeObservableAnnotation.Instance)), args[1]))); + AddMethodTranslator(typeof(TSortingOptions), nameof(ISortingStateCapability.IsColumnSortedDescending), new GenericMethodCompiler(args => + new JsIdentifierExpression("dotvvm").Member("dataSet").Member("translations").Member(clientTypeName ?? typeof(TSortingOptions).Name).Member("isColumnSortedDescending") + .Invoke(new JsIdentifierExpression("ko").Member("toJS").Invoke(args[0].WithAnnotation(ShouldBeObservableAnnotation.Instance)), args[1]))); + } + } + public JsExpression? TryTranslateCall(LazyTranslatedExpression? context, LazyTranslatedExpression[] args, MethodInfo method) { { diff --git a/src/Framework/Framework/Compilation/Javascript/JsParensFixingVisitor.cs b/src/Framework/Framework/Compilation/Javascript/JsParensFixingVisitor.cs index 02a1f0a16b..44432ef220 100644 --- a/src/Framework/Framework/Compilation/Javascript/JsParensFixingVisitor.cs +++ b/src/Framework/Framework/Compilation/Javascript/JsParensFixingVisitor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -56,7 +56,8 @@ public override string ToString() return Precedence + (IsPreferredSide ? "+" : "-") + " (" + name + ")"; } - public static readonly OperatorPrecedence Max = new OperatorPrecedence(20, true); + /// Assume that the expression is an atomic + public static OperatorPrecedence Max => new OperatorPrecedence(20, true); /// atomic expression, like `x`, `(x + y)`, `0`, `{"f": 123}`, `x[1]`, ... public const byte Atomic = 20; diff --git a/src/Framework/Framework/Compilation/Javascript/MemberInfoHelper.cs b/src/Framework/Framework/Compilation/Javascript/MemberInfoHelper.cs index 63442dde8a..3e658b317c 100644 --- a/src/Framework/Framework/Compilation/Javascript/MemberInfoHelper.cs +++ b/src/Framework/Framework/Compilation/Javascript/MemberInfoHelper.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; +using DotVVM.Framework.Controls; namespace DotVVM.Framework.Compilation.Javascript { @@ -31,6 +32,7 @@ static Expression UnwrapConversions(Expression e) e = unary.Operand; return e; } + static MethodBase GetMethodFromExpression(Expression expression) { var originalExpression = expression; @@ -153,6 +155,7 @@ public static class Generic { public record T { } public enum Enum { Something } public record struct Struct { } + public class DataSet: GridViewDataSet { } } } } diff --git a/src/Framework/Framework/Compilation/Styles/ResolvedControlHelper.cs b/src/Framework/Framework/Compilation/Styles/ResolvedControlHelper.cs index 51669e764b..24ac760cf6 100644 --- a/src/Framework/Framework/Compilation/Styles/ResolvedControlHelper.cs +++ b/src/Framework/Framework/Compilation/Styles/ResolvedControlHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq.Expressions; using DotVVM.Framework.Binding; @@ -134,20 +135,35 @@ public record PropertyTranslationException(DotvvmProperty property): } private static Type[] ImmutableContainers = new[] { - typeof(ImmutableArray<>), typeof(ImmutableList<>), typeof(ImmutableDictionary<,>), typeof(ImmutableHashSet<>), typeof(ImmutableQueue<>), typeof(ImmutableSortedDictionary<,>), typeof(ImmutableSortedSet<>), typeof(ImmutableStack<>) + typeof(ImmutableArray<>), typeof(ImmutableList<>), typeof(ImmutableHashSet<>), typeof(ImmutableQueue<>), typeof(ImmutableSortedSet<>), typeof(ImmutableStack<>) }; - internal static bool IsImmutableObject(Type type) => - typeof(IBinding).IsAssignableFrom(type ?? throw new ArgumentNullException(nameof(type))) - || type.GetCustomAttribute() is object - || type.IsGenericType && ImmutableContainers.Contains(type.GetGenericTypeDefinition()) && type.GenericTypeArguments.All(IsImmutableObject); - - public static bool IsAllowedPropertyValue([NotNullWhen(false)] object? value) => - value is ValueOrBinding vob && IsAllowedPropertyValue(vob.UnwrapToObject()) || - value is null || - ReflectionUtils.IsPrimitiveType(value.GetType()) || - value is HtmlGenericControl.AttributeList || - IsImmutableObject(value.GetType()) || - value is Array && ReflectionUtils.IsPrimitiveType(value.GetType().GetElementType()!); + + public static bool IsAllowedPropertyValue([NotNullWhen(false)] object? value) + { + if (value is ValueOrBinding vob && IsAllowedPropertyValue(vob.UnwrapToObject()) || + value is HtmlGenericControl.AttributeList || + value is IBinding || + value is null) + { + return true; + } + + var type = value.GetType(); + if (ReflectionUtils.IsPrimitiveType(type) || + type.GetCustomAttribute() is not null) + { + return true; + } + + // technically we should not allow Array as it can be modified, but we use it already on many places + if (value is Array || + (type.IsGenericType && ImmutableContainers.Contains(type.GetGenericTypeDefinition()))) + { + return ((IEnumerable)value).OfType().All(IsAllowedPropertyValue); + } + + return false; + } public static ResolvedPropertySetter TranslateProperty(DotvvmProperty property, object? value, DataContextStack dataContext, DotvvmConfiguration? config) { diff --git a/src/Framework/Framework/Controls/AppendableDataPager.cs b/src/Framework/Framework/Controls/AppendableDataPager.cs new file mode 100644 index 0000000000..a246dbed9f --- /dev/null +++ b/src/Framework/Framework/Controls/AppendableDataPager.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Text; +using DotVVM.Framework.Binding; +using DotVVM.Framework.Binding.Expressions; +using DotVVM.Framework.Binding.HelperNamespace; +using DotVVM.Framework.Compilation.Javascript; +using DotVVM.Framework.Hosting; +using DotVVM.Framework.Utils; + +namespace DotVVM.Framework.Controls +{ + /// + /// Renders a pager for that allows the user to append more items to the end of the list. + /// + [ControlMarkupOptions(AllowContent = false, DefaultContentProperty = nameof(LoadTemplate))] + public class AppendableDataPager : HtmlGenericControl + { + private readonly GridViewDataSetBindingProvider gridViewDataSetBindingProvider; + private readonly BindingCompilationService bindingService; + + /// + /// Template displayed when more pages exist (not ) + /// The template should contain a button triggering the loading of more data, it may use the {staticCommand: _dataPager.Load()} binding to invoke the function. + /// When this template isn't set, the pager will automatically load the next page when it becomes visible on screen. + /// + [MarkupOptions(AllowBinding = false, MappingMode = MappingMode.InnerElement)] + [DataPagerApi.AddParameterDataContextChange("_dataPager")] + public ITemplate? LoadTemplate + { + get { return (ITemplate?)GetValue(LoadTemplateProperty); } + set { SetValue(LoadTemplateProperty, value); } + } + public static readonly DotvvmProperty LoadTemplateProperty + = DotvvmProperty.Register(c => c.LoadTemplate, null); + + /// Template displayed when the next page is being loaded + [MarkupOptions(AllowBinding = false, MappingMode = MappingMode.InnerElement)] + [DataPagerApi.AddParameterDataContextChange("_dataPager")] + public ITemplate? LoadingTemplate + { + get { return (ITemplate?)GetValue(LoadingTemplateProperty); } + set { SetValue(LoadingTemplateProperty, value); } + } + public static readonly DotvvmProperty LoadingTemplateProperty + = DotvvmProperty.Register(c => c.LoadingTemplate, null); + + /// Template displayed when we are at the last page. + [MarkupOptions(AllowBinding = false, MappingMode = MappingMode.InnerElement)] + public ITemplate? EndTemplate + { + get { return (ITemplate?)GetValue(EndTemplateProperty); } + set { SetValue(EndTemplateProperty, value); } + } + public static readonly DotvvmProperty EndTemplateProperty + = DotvvmProperty.Register(c => c.EndTemplate, null); + + /// The data source GridViewDataSet (AppendableDataPager does not support plain collections) + [MarkupOptions(Required = true, AllowHardCodedValue = false)] + public IPageableGridViewDataSet DataSet + { + get { return (IPageableGridViewDataSet)GetValue(DataSetProperty)!; } + set { SetValue(DataSetProperty, value); } + } + public static readonly DotvvmProperty DataSetProperty + = DotvvmProperty.Register(c => c.DataSet, null); + + /// + /// Gets or sets the (static) command that will be used to load the next page. + /// It is recommended to use a staticCommand in AppendableDataPager. + /// The command accepts one argument of type and should return a new or . + /// + [MarkupOptions(Required = true)] + public ICommandBinding? LoadData + { + get => (ICommandBinding?)GetValue(LoadDataProperty); + set => SetValue(LoadDataProperty, value); + } + public static readonly DotvvmProperty LoadDataProperty = + DotvvmProperty.Register(nameof(LoadData)); + + private DataPagerBindings? dataPagerCommands = null; + + public AppendableDataPager(GridViewDataSetBindingProvider gridViewDataSetBindingProvider, BindingCompilationService bindingService) : base("div") + { + this.gridViewDataSetBindingProvider = gridViewDataSetBindingProvider; + this.bindingService = bindingService; + } + + protected internal override void OnLoad(IDotvvmRequestContext context) + { + var dataSetBinding = GetValueBinding(DataSetProperty)!; + var commandType = LoadData is { } ? GridViewDataSetCommandType.LoadDataDelegate : GridViewDataSetCommandType.Default; + dataPagerCommands = gridViewDataSetBindingProvider.GetDataPagerBindings(this.GetDataContextType()!, dataSetBinding, commandType); + + if (LoadTemplate != null) + { + var templateDataContext = LoadTemplateProperty.GetDataContextType(this)!; + var container = new PlaceHolder() + .SetProperty(p => p.IncludeInPage, bindingService.Cache.CreateValueBinding("_dataPager.CanLoadNextPage", templateDataContext)); + container.SetDataContextType(templateDataContext); + Children.Add(container); + + LoadTemplate.BuildContent(context, container); + } + + if (LoadingTemplate != null) + { + var templateDataContext = LoadingTemplateProperty.GetDataContextType(this)!; + var container = new PlaceHolder() + .SetProperty(p => p.IncludeInPage, bindingService.Cache.CreateValueBinding("_dataPager.IsLoading", templateDataContext)); + container.SetDataContextType(templateDataContext); + Children.Add(container); + + LoadingTemplate.BuildContent(context, container); + } + + if (EndTemplate != null) + { + var container = new PlaceHolder() + .SetProperty(p => p.IncludeInPage, dataPagerCommands.IsLastPage); + Children.Add(container); + + EndTemplate.BuildContent(context, container); + } + } + + protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequestContext context) + { + var dataSetBinding = GetDataSetBinding().GetKnockoutBindingExpression(this, unwrapped: true); + + var loadData = this.LoadData.NotNull("AppendableDataPager.LoadData is currently required."); + var loadDataCore = KnockoutHelper.GenerateClientPostbackLambda("LoadDataCore", loadData, this, PostbackScriptOptions.KnockoutBinding with { AllowPostbackHandlers = false }); + var loadNextPage = KnockoutHelper.GenerateClientPostbackLambda("LoadData", dataPagerCommands!.GoToNextPage!, this, PostbackScriptOptions.KnockoutBinding with { + ParameterAssignment = p => + p == GridViewDataSetBindingProvider.LoadDataDelegate ? new CodeParameterAssignment(loadDataCore, default) : + p == GridViewDataSetBindingProvider.PostProcessorDelegate ? new CodeParameterAssignment("dotvvm.dataSet.postProcessors.append", OperatorPrecedence.Max) : + default + }); + + var binding = new KnockoutBindingGroup(); + binding.Add("dataSet", dataSetBinding); + binding.Add("loadNextPage", loadNextPage); + binding.Add("autoLoadWhenInViewport", LoadTemplate is null ? "true" : "false"); + writer.AddKnockoutDataBind("dotvvm-appendable-data-pager", binding); + + base.AddAttributesToRender(writer, context); + } + + private IValueBinding GetDataSetBinding() + => GetValueBinding(DataSetProperty) ?? throw new DotvvmControlException(this, "The DataSet property of the dot:AppendableDataPager control must be set!"); + } +} diff --git a/src/Framework/Framework/Controls/Button.cs b/src/Framework/Framework/Controls/Button.cs index 7f03ad0a79..7f1c7d15ad 100644 --- a/src/Framework/Framework/Controls/Button.cs +++ b/src/Framework/Framework/Controls/Button.cs @@ -128,10 +128,9 @@ protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequest base.AddAttributesToRender(writer, context); - var clickBinding = GetCommandBinding(ClickProperty); - if (clickBinding != null) + if (CreateClickScript() is {} clickScript) { - writer.AddAttribute("onclick", KnockoutHelper.GenerateClientPostBackScript(nameof(Click), clickBinding, this), true, ";"); + writer.AddAttribute("onclick", clickScript, append: true, appendSeparator: ";"); } } diff --git a/src/Framework/Framework/Controls/ButtonBase.cs b/src/Framework/Framework/Controls/ButtonBase.cs index 6ef10903d6..03bd417bec 100644 --- a/src/Framework/Framework/Controls/ButtonBase.cs +++ b/src/Framework/Framework/Controls/ButtonBase.cs @@ -37,6 +37,18 @@ public Command? Click public static readonly DotvvmProperty ClickProperty = DotvvmProperty.Register(t => t.Click, null); + /// + /// Gets or sets a collection of arguments passed to the command when the button is clicked. + /// This property is typically used from the code-behind to allow sharing the same binding expressions among multiple buttons. + /// + [MarkupOptions(MappingMode = MappingMode.Exclude)] + public object?[]? ClickArguments + { + get { return (object?[])GetValue(ClickArgumentsProperty)!; } + set { SetValue(ClickArgumentsProperty, value); } + } + public static readonly DotvvmProperty ClickArgumentsProperty + = DotvvmProperty.Register(c => c.ClickArguments, null); /// /// Gets or sets a value indicating whether the button is enabled and can be clicked on. @@ -67,6 +79,17 @@ public ButtonBase(string tagName) : base(tagName) { } + /// Creates the contents of `onclick` attribute. + protected virtual string? CreateClickScript() + { + var clickBinding = GetCommandBinding(ClickProperty); + if (clickBinding is null) + return null; + + return KnockoutHelper.GenerateClientPostBackScript( + nameof(Click), clickBinding, this, + new PostbackScriptOptions(commandArgs: BindingHelper.GetParametrizedCommandArgs(this, ClickArguments))); + } /// /// Adds all attributes that should be added to the control begin tag. @@ -95,5 +118,6 @@ public bool ValidateCommand(DotvvmProperty targetProperty) } return false; } + } } diff --git a/src/Framework/Framework/Controls/DataPager.cs b/src/Framework/Framework/Controls/DataPager.cs index 69509e5c76..087dd0229f 100644 --- a/src/Framework/Framework/Controls/DataPager.cs +++ b/src/Framework/Framework/Controls/DataPager.cs @@ -20,45 +20,30 @@ namespace DotVVM.Framework.Controls [ControlMarkupOptions(AllowContent = false)] public class DataPager : HtmlGenericControl { - public class CommonBindings - { - public readonly CommandBindingExpression GoToNextPageCommand; - public readonly CommandBindingExpression GoToThisPageCommand; - public readonly CommandBindingExpression GoToPrevPageCommand; - public readonly CommandBindingExpression GoToFirstPageCommand; - public readonly CommandBindingExpression GoToLastPageCommand; + private readonly GridViewDataSetBindingProvider gridViewDataSetBindingProvider; + private readonly BindingCompilationService bindingCompilationService; - public CommonBindings(BindingCompilationService service) - { - GoToNextPageCommand = new CommandBindingExpression(service, h => ((IPageableGridViewDataSet)h[0]).GoToNextPage(), "__$DataPager_GoToNextPage"); - GoToThisPageCommand = new CommandBindingExpression(service, h => ((IPageableGridViewDataSet)h[1]).GoToPage((int)h[0]), "__$DataPager_GoToThisPage"); - GoToPrevPageCommand = new CommandBindingExpression(service, h => ((IPageableGridViewDataSet)h[0]).GoToPreviousPage(), "__$DataPager_GoToPrevPage"); - GoToFirstPageCommand = new CommandBindingExpression(service, h => ((IPageableGridViewDataSet)h[0]).GoToFirstPage(), "__$DataPager_GoToFirstPage"); - GoToLastPageCommand = new CommandBindingExpression(service, h => ((IPageableGridViewDataSet)h[0]).GoToLastPage(), "__$DataPager_GoToLastPage"); - } - } + private DataPagerBindings? pagerBindings; - private readonly CommonBindings commonBindings; - private readonly BindingCompilationService bindingService; - public DataPager(CommonBindings commonBindings, BindingCompilationService bindingService) - : base("div", false) + public DataPager(GridViewDataSetBindingProvider gridViewDataSetBindingProvider, BindingCompilationService bindingCompilationService) + : base("ul", false) { - this.commonBindings = commonBindings; - this.bindingService = bindingService; + this.gridViewDataSetBindingProvider = gridViewDataSetBindingProvider; + this.bindingCompilationService = bindingCompilationService; } /// /// Gets or sets the GridViewDataSet object in the viewmodel. /// - [MarkupOptions(AllowHardCodedValue = false)] - public IPageableGridViewDataSet? DataSet + [MarkupOptions(AllowHardCodedValue = false, Required = true)] + public IPageableGridViewDataSet? DataSet { - get { return (IPageableGridViewDataSet?)GetValue(DataSetProperty); } + get { return (IPageableGridViewDataSet?)GetValue(DataSetProperty); } set { SetValue(DataSetProperty, value); } } public static readonly DotvvmProperty DataSetProperty = - DotvvmProperty.Register(c => c.DataSet); + DotvvmProperty.Register?, DataPager>(c => c.DataSet); /// /// Gets or sets the template of the button which moves the user to the first page. @@ -141,10 +126,22 @@ public bool Enabled public static readonly DotvvmProperty EnabledProperty = DotvvmPropertyWithFallback.Register(nameof(Enabled), FormControls.EnabledProperty); + /// + /// Gets or sets the (static) command that will be triggered when the DataPager needs to load data (when navigating to different page). + /// The command accepts one argument of type and should return a new or . + /// + public ICommandBinding? LoadData + { + get => (ICommandBinding?)GetValue(LoadDataProperty); + set => SetValue(LoadDataProperty, value); + } + public static readonly DotvvmProperty LoadDataProperty = + DotvvmProperty.Register(nameof(LoadData)); + protected HtmlGenericControl? ContentWrapper { get; set; } protected HtmlGenericControl? GoToFirstPageButton { get; set; } protected HtmlGenericControl? GoToPreviousPageButton { get; set; } - protected PlaceHolder? NumberButtonsPlaceHolder { get; set; } + protected Repeater? NumberButtonsRepeater { get; set; } protected HtmlGenericControl? GoToNextPageButton { get; set; } protected HtmlGenericControl? GoToLastPageButton { get; set; } protected virtual string ActiveItemCssClass => "active"; @@ -166,74 +163,127 @@ protected virtual void DataBind(Hosting.IDotvvmRequestContext context) { Children.Clear(); - var dataContextType = DataContextStack.Create(typeof(IPageableGridViewDataSet), this.GetDataContextType()); - ContentWrapper = CreateWrapperList(dataContextType); - Children.Add(ContentWrapper); + var dataSetBinding = GetValueBinding(DataSetProperty)!; + var dataSetType = dataSetBinding.ResultType; - var bindings = context.Services.GetRequiredService(); + var commandType = LoadData is {} ? GridViewDataSetCommandType.LoadDataDelegate : GridViewDataSetCommandType.Default; - object enabledValue = GetValueRaw(EnabledProperty)!; + pagerBindings = gridViewDataSetBindingProvider.GetDataPagerBindings(this.GetDataContextType().NotNull(), dataSetBinding, commandType); - GoToFirstPageButton = CreateNavigationButton("««", FirstPageTemplate, enabledValue, bindings.GoToFirstPageCommand,context); - ContentWrapper.Children.Add(GoToFirstPageButton); + var globalEnabled = GetValueOrBinding(EnabledProperty)!; - GoToPreviousPageButton = CreateNavigationButton("«", PreviousPageTemplate, enabledValue, bindings.GoToPrevPageCommand,context); - ContentWrapper.Children.Add(GoToPreviousPageButton); + ContentWrapper = CreateWrapperList(); + Children.Add(ContentWrapper); - // number fields - NumberButtonsPlaceHolder = new PlaceHolder(); - ContentWrapper.Children.Add(NumberButtonsPlaceHolder); + if (typeof(IPageableGridViewDataSet).IsAssignableFrom(dataSetType)) + { + var disabled = new ValueOrBinding(pagerBindings.IsFirstPage.NotNull()); + GoToFirstPageButton = CreateNavigationButton("««", FirstPageTemplate, globalEnabled.And(disabled.Negate()), pagerBindings.GoToFirstPage!, context); + GoToFirstPageButton.CssClasses.Add(DisabledItemCssClass, disabled); + AddItemCssClass(GoToFirstPageButton, context); + ContentWrapper.Children.Add(GoToFirstPageButton); + } - var dataSet = DataSet; - if (dataSet != null) + if (typeof(IPageableGridViewDataSet).IsAssignableFrom(dataSetType)) { - var i = 0; - foreach (var number in dataSet.PagingOptions.NearPageIndexes) - { - var li = new HtmlGenericControl("li"); - li.SetBinding(DataContextProperty, GetNearIndexesBinding(context, i, dataContextType)); - if (number == dataSet.PagingOptions.PageIndex) - { - li.Attributes.Set("class", ActiveItemCssClass); - } - var link = new LinkButton() { Text = (number + 1).ToString() }; - link.SetBinding(ButtonBase.ClickProperty, bindings.GoToThisPageCommand); - if (!true.Equals(enabledValue)) link.SetValue(LinkButton.EnabledProperty, enabledValue); - li.Children.Add(link); - NumberButtonsPlaceHolder.Children.Add(li); - - i++; - } + var disabled = new ValueOrBinding(pagerBindings.IsFirstPage.NotNull()); + GoToPreviousPageButton = CreateNavigationButton("«", PreviousPageTemplate, globalEnabled.And(disabled.Negate()), pagerBindings.GoToPreviousPage!, context); + GoToPreviousPageButton.CssClasses.Add(DisabledItemCssClass, disabled); + AddItemCssClass(GoToPreviousPageButton, context); + ContentWrapper.Children.Add(GoToPreviousPageButton); } - GoToNextPageButton = CreateNavigationButton("»", NextPageTemplate, enabledValue, bindings.GoToNextPageCommand, context); - ContentWrapper.Children.Add(GoToNextPageButton); + if (pagerBindings.PageNumbers is {}) + { + // number fields + var liTemplate = CreatePageNumberButton(globalEnabled, pagerBindings, context); + AddItemCssClass(liTemplate, context); + + NumberButtonsRepeater = new Repeater() { + DataSource = pagerBindings.PageNumbers, + RenderWrapperTag = false, + RenderAsNamedTemplate = false, + ItemTemplate = new CloneTemplate(liTemplate) + }; + ContentWrapper.Children.Add(NumberButtonsRepeater); + } - GoToLastPageButton = CreateNavigationButton("»»", LastPageTemplate, enabledValue, bindings.GoToLastPageCommand, context); - ContentWrapper.Children.Add(GoToLastPageButton); + if (typeof(IPageableGridViewDataSet).IsAssignableFrom(dataSetType)) + { + var disabled = new ValueOrBinding(pagerBindings.IsLastPage.NotNull()); + GoToNextPageButton = CreateNavigationButton("»", NextPageTemplate, globalEnabled.And(disabled.Negate()), pagerBindings.GoToNextPage!, context); + GoToNextPageButton.CssClasses.Add(DisabledItemCssClass, disabled); + AddItemCssClass(GoToNextPageButton, context); + ContentWrapper.Children.Add(GoToNextPageButton); + } + + if (typeof(IPageableGridViewDataSet).IsAssignableFrom(dataSetType)) + { + var disabled = new ValueOrBinding(pagerBindings.IsLastPage.NotNull()); + GoToLastPageButton = CreateNavigationButton("»»", LastPageTemplate, globalEnabled.And(disabled.Negate()), pagerBindings.GoToLastPage!, context); + GoToLastPageButton.CssClasses.Add(DisabledItemCssClass, disabled); + AddItemCssClass(GoToLastPageButton, context); + ContentWrapper.Children.Add(GoToLastPageButton); + } } - protected virtual HtmlGenericControl CreateWrapperList(DataContextStack dataContext) + protected virtual HtmlGenericControl CreateWrapperList() { var list = new HtmlGenericControl("ul"); - list.SetDataContextType(dataContext); - list.SetBinding(DataContextProperty, GetDataSetBinding()); + + // If Visible property was set to something, it would be overwritten by this + if (HideWhenOnlyOnePage && pagerBindings?.HasMoreThanOnePage is {} hasMoreThanOnePage) + { + list.SetProperty( + HtmlGenericControl.VisibleProperty, + new ValueOrBinding(hasMoreThanOnePage).And(GetValueOrBinding(VisibleProperty)) + ); + } + else + { + list.SetProperty(HtmlGenericControl.VisibleProperty, GetValueOrBinding(VisibleProperty)); + } + + return list; } + protected override void AddVisibleAttributeOrBinding(in RenderState r, IHtmlWriter writer) { } // handled by the wrapper list + + protected virtual HtmlGenericControl CreatePageNumberButton(ValueOrBinding globalEnabled, DataPagerBindings pagerBindings, IDotvvmRequestContext context) + { + var liTemplate = new HtmlGenericControl("li"); + liTemplate.CssClasses.Add(ActiveItemCssClass, new ValueOrBinding(pagerBindings.NotNull().IsActivePage.NotNull())); + var link = new LinkButton(); + link.SetBinding(ButtonBase.ClickProperty, pagerBindings.NotNull().GoToPage.NotNull()); + SetPageNumberButtonContent(link, pagerBindings, context); + if (!RenderLinkForCurrentPage) link.SetBinding(IncludeInPageProperty, pagerBindings.IsActivePage.NotNull().Negate()); + if (!true.Equals(globalEnabled)) link.SetValue(ButtonBase.EnabledProperty, globalEnabled); + liTemplate.Children.Add(link); + + if (!RenderLinkForCurrentPage) + { + var notLink = new Literal(); + SetPageNumberSpanContent(notLink, pagerBindings, context); + notLink.RenderSpanElement = true; + notLink.SetBinding(IncludeInPageProperty, pagerBindings.IsActivePage); + liTemplate.Children.Add(notLink); + } + return liTemplate; + } + protected virtual HtmlGenericControl CreateNavigationButton(string defaultText, ITemplate? userDefinedContentTemplate, object enabledValue, ICommandBinding clickCommandBindingExpression,IDotvvmRequestContext context) { var li = new HtmlGenericControl("li"); var link = new LinkButton(); - SetButtonContent(context, link, defaultText, userDefinedContentTemplate); + SetNavigationButtonContent(context, link, defaultText, userDefinedContentTemplate); link.SetBinding(ButtonBase.ClickProperty, clickCommandBindingExpression); - if (!true.Equals(enabledValue)) - link.SetValue(LinkButton.EnabledProperty, enabledValue); + if (!true.Equals(enabledValue)) link.SetValue(ButtonBase.EnabledProperty, enabledValue); li.Children.Add(link); return li; } - protected virtual void SetButtonContent(Hosting.IDotvvmRequestContext context, LinkButton button, string text, ITemplate? contentTemplate) + protected virtual void SetNavigationButtonContent(IDotvvmRequestContext context, LinkButton button, string text, ITemplate? contentTemplate) { if (contentTemplate != null) { @@ -245,18 +295,18 @@ protected virtual void SetButtonContent(Hosting.IDotvvmRequestContext context, L } } - private ConditionalWeakTable> _nearIndexesBindingCache - = new ConditionalWeakTable>(); + protected virtual void SetPageNumberSpanContent(Literal notLink, DataPagerBindings pagerBindings, IDotvvmRequestContext context) + { + notLink.SetBinding(Literal.TextProperty, pagerBindings.PageNumberText.NotNull()); + } + + protected virtual void SetPageNumberButtonContent(LinkButton link, DataPagerBindings pagerBindings, IDotvvmRequestContext context) + { + link.SetBinding(ButtonBase.TextProperty, pagerBindings.PageNumberText.NotNull()); + } - private ValueBindingExpression GetNearIndexesBinding(IDotvvmRequestContext context, int i, DataContextStack? dataContext = null) + protected virtual void AddItemCssClass(HtmlGenericControl item, IDotvvmRequestContext context) { - return - _nearIndexesBindingCache.GetOrCreateValue(context.Configuration) - .GetOrAdd(i, _ => - ValueBindingExpression.CreateBinding( - bindingService.WithoutInitialization(), - h => ((IPageableGridViewDataSet)h[0]!).PagingOptions.NearPageIndexes[i], - dataContext)); } protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequestContext context) @@ -266,19 +316,15 @@ protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequest throw new DotvvmControlException(this, "The DataPager control cannot be rendered in the RenderSettings.Mode='Server'."); } - base.AddAttributesToRender(writer, context); - - // If Visible property was set to something, it will be overwritten by this. TODO: is it how it should behave? - if (HideWhenOnlyOnePage) + if (this.LoadData is {} loadData) { - if (IsPropertySet(VisibleProperty)) - throw new Exception("Visible can't be set on a DataPager when HideWhenOnlyOnePage is true. You can wrap it in an element that hide that or set HideWhenOnlyOnePage to false"); - writer.AddKnockoutDataBind("visible", $"({GetDataSetBinding().GetKnockoutBindingExpression(this, unwrapped: true)}).PagingOptions().PagesCount() > 1"); + var helperBinding = new KnockoutBindingGroup(); + helperBinding.Add("dataSet", GetDataSetBinding().GetKnockoutBindingExpression(this, unwrapped: true)); + var loadDataExpression = KnockoutHelper.GenerateClientPostbackLambda("LoadData", loadData, this, new PostbackScriptOptions(elementAccessor: "$element", koContext: CodeParameterAssignment.FromIdentifier("$context"))); + helperBinding.Add("loadDataSet", loadDataExpression); + writer.AddKnockoutDataBind("dotvvm-gridviewdataset", helperBinding.ToString()); } - } - protected override void RenderBeginTag(IHtmlWriter writer, IDotvvmRequestContext context) - { if (GetValueBinding(EnabledProperty) is IValueBinding enabledBinding) { var disabledBinding = enabledBinding.GetProperty().Binding.CastTo(); @@ -289,12 +335,7 @@ protected override void RenderBeginTag(IHtmlWriter writer, IDotvvmRequestContext writer.AddAttribute("class", DisabledItemCssClass, true); } - writer.AddKnockoutDataBind("with", this, DataSetProperty, renderEvenInServerRenderingMode: true); - writer.RenderBeginTag("ul"); - } - - protected virtual void AddItemCssClass(IHtmlWriter writer, IDotvvmRequestContext context) - { + base.AddAttributesToRender(writer, context); } protected virtual void AddKnockoutDisabledCssDataBind(IHtmlWriter writer, IDotvvmRequestContext context, string expression) @@ -302,97 +343,21 @@ protected virtual void AddKnockoutDisabledCssDataBind(IHtmlWriter writer, IDotvv writer.AddKnockoutDataBind("css", $"{{ '{DisabledItemCssClass}': {expression} }}"); } - protected virtual void AddKnockoutActiveCssDataBind(IHtmlWriter writer, IDotvvmRequestContext context, string expression) - { - writer.AddKnockoutDataBind("css", $"{{ '{ActiveItemCssClass}': {expression} }}"); - } - - private static ParametrizedCode currentPageTextJs = new JsBinaryExpression(new JsBinaryExpression(new JsLiteral(1), BinaryOperatorType.Plus, new JsSymbolicParameter(JavascriptTranslator.KnockoutViewModelParameter)), BinaryOperatorType.Plus, new JsLiteral("")).FormatParametrizedScript(); - protected override void RenderContents(IHtmlWriter writer, IDotvvmRequestContext context) { - AddItemCssClass(writer, context); - AddKnockoutDisabledCssDataBind(writer, context, "PagingOptions().IsFirstPage()"); - GoToFirstPageButton!.Render(writer, context); - - AddItemCssClass(writer, context); - AddKnockoutDisabledCssDataBind(writer, context, "PagingOptions().IsFirstPage()"); - GoToPreviousPageButton!.Render(writer, context); - - // render template - writer.WriteKnockoutForeachComment("PagingOptions().NearPageIndexes"); - - // render page number - NumberButtonsPlaceHolder!.Children.Clear(); - RenderPageNumberButton(writer, context); - - writer.WriteKnockoutDataBindEndComment(); - - AddItemCssClass(writer, context); - AddKnockoutDisabledCssDataBind(writer, context, "PagingOptions().IsLastPage()"); - GoToNextPageButton!.Render(writer, context); - - AddItemCssClass(writer, context); - AddKnockoutDisabledCssDataBind(writer, context, "PagingOptions().IsLastPage()"); - GoToLastPageButton!.Render(writer, context); + base.RenderContents(writer, context); } - protected virtual void RenderPageNumberButton(IHtmlWriter writer, IDotvvmRequestContext context) + protected override void RenderBeginTag(IHtmlWriter writer, IDotvvmRequestContext context) { - HtmlGenericControl li = new HtmlGenericControl("li"); - var currentPageTextContext = DataContextStack.Create(typeof(int), NumberButtonsPlaceHolder!.GetDataContextType()); - li.SetDataContextType(currentPageTextContext); - li.DataContext = null; - var currentPageTextBinding = ValueBindingExpression.CreateBinding(bindingService.WithoutInitialization(), - vm => ((int) vm[0]! + 1).ToString(), - currentPageTextJs, - currentPageTextContext); - - if (!RenderLinkForCurrentPage) - { - writer.AddKnockoutDataBind("visible", "$data == $parent.PagingOptions().PageIndex()"); - AddItemCssClass(writer, context); - writer.AddAttribute("class", ActiveItemCssClass, true); - var literal = new Literal(); - literal.DataContext = 0; - literal.SetBinding(Literal.TextProperty, currentPageTextBinding); - li.Children.Add(literal); - NumberButtonsPlaceHolder!.Children.Add(li); - - li.Render(writer, context); - - writer.AddKnockoutDataBind("visible", "$data != $parent.PagingOptions().PageIndex()"); - } - - li = new HtmlGenericControl("li"); - li.SetDataContextType(currentPageTextContext); - li.DataContext = null; - - NumberButtonsPlaceHolder!.Children.Add(li); - AddItemCssClass(writer, context); - - if (RenderLinkForCurrentPage) - AddKnockoutActiveCssDataBind(writer, context, "$data == $parent.PagingOptions().PageIndex()"); - - li.SetValue(Internal.PathFragmentProperty, "PagingOptions.NearPageIndexes[$index]"); - var link = new LinkButton(); - li.Children.Add(link); - link.SetDataContextType(currentPageTextContext); - link.SetBinding(ButtonBase.TextProperty, currentPageTextBinding); - link.SetBinding(ButtonBase.ClickProperty, commonBindings.GoToThisPageCommand); - object enabledValue = GetValueRaw(EnabledProperty)!; - if (!true.Equals(enabledValue)) link.SetValue(LinkButton.EnabledProperty, enabledValue); - - li.Render(writer, context); + // don't, delegated to the ContentWrapper html element } - protected override void RenderEndTag(IHtmlWriter writer, IDotvvmRequestContext context) { - writer.RenderEndTag(); } + private IValueBinding GetDataSetBinding() => GetValueBinding(DataSetProperty) ?? throw new DotvvmControlException(this, "The DataSet property of the dot:DataPager control must be set!"); } - } diff --git a/src/Framework/Framework/Controls/DataPagerCommands.cs b/src/Framework/Framework/Controls/DataPagerCommands.cs new file mode 100644 index 0000000000..7bf4f48147 --- /dev/null +++ b/src/Framework/Framework/Controls/DataPagerCommands.cs @@ -0,0 +1,23 @@ +using System.Collections; +using System.Collections.Generic; +using DotVVM.Framework.Binding.Expressions; + +namespace DotVVM.Framework.Controls +{ + /// Contains pre-created command and value bindings for the components. An instance can be obtained from + public class DataPagerBindings + { + public ICommandBinding? GoToFirstPage { get; init; } + public ICommandBinding? GoToPreviousPage { get; init; } + public ICommandBinding? GoToNextPage { get; init; } + public ICommandBinding? GoToLastPage { get; init; } + public ICommandBinding? GoToPage { get; init; } + + public IStaticValueBinding? IsFirstPage { get; init; } + public IStaticValueBinding? IsLastPage { get; init; } + public IStaticValueBinding>? PageNumbers { get; init; } + public IStaticValueBinding? IsActivePage { get; init; } + public IStaticValueBinding? PageNumberText { get; init; } + public IStaticValueBinding? HasMoreThanOnePage { get; init; } + } +} diff --git a/src/Framework/Framework/Controls/GridView.cs b/src/Framework/Framework/Controls/GridView.cs index 7579410934..bad12e48f3 100644 --- a/src/Framework/Framework/Controls/GridView.cs +++ b/src/Framework/Framework/Controls/GridView.cs @@ -24,6 +24,8 @@ namespace DotVVM.Framework.Controls [ControlMarkupOptions(AllowContent = false, DefaultContentProperty = nameof(Columns))] public class GridView : ItemsControl { + private readonly GridViewDataSetBindingProvider gridViewDataSetBindingProvider; + private readonly BindingCompilationService bindingCompilationService; private EmptyData? emptyDataContainer; private int numberOfRows; private HtmlGenericControl? head; @@ -31,8 +33,11 @@ public class GridView : ItemsControl private DataItemContainer? clientEditTemplate; - public GridView() : base("table") + public GridView(GridViewDataSetBindingProvider gridViewDataSetBindingProvider, BindingCompilationService bindingCompilationService) : base("table") { + this.gridViewDataSetBindingProvider = gridViewDataSetBindingProvider; + this.bindingCompilationService = bindingCompilationService; + SetValue(Internal.IsNamingContainerProperty, true); Columns = new List(); @@ -161,6 +166,19 @@ public bool InlineEditing public static readonly DotvvmProperty InlineEditingProperty = DotvvmProperty.Register(t => t.InlineEditing, false); + /// + /// Gets or sets the (static) command that will be triggered when the GridView needs to load data (e.g. when the sort order has changed). + /// The command accepts one argument of type and should return a new or . + /// + public ICommandBinding? LoadData + { + get => (ICommandBinding?)GetValue(LoadDataProperty); + set => SetValue(LoadDataProperty, value); + } + public static readonly DotvvmProperty LoadDataProperty = + DotvvmProperty.Register(nameof(LoadData)); + + protected internal override void OnLoad(IDotvvmRequestContext context) { DataBind(context); @@ -186,16 +204,7 @@ private void DataBind(IDotvvmRequestContext context) var dataSourceBinding = GetDataSourceBinding(); var dataSource = DataSource; - var sortCommand = SortChanged; - sortCommand ??= - typeof(ISortableGridViewDataSet).IsAssignableFrom((GetBinding(DataSourceProperty) as IStaticValueBinding)?.ResultType) - ? (Action)SortChangedCommand - : null; - - sortCommand ??= s => - throw new DotvvmControlException(this, "Cannot sort when DataSource is null."); - - CreateHeaderRow(context, sortCommand); + CreateHeaderRow(context); var index = 0; if (dataSource != null) @@ -231,27 +240,7 @@ private void DataBind(IDotvvmRequestContext context) } } - protected virtual void SortChangedCommand(string? expr) - { - var dataSource = this.DataSource; - if (dataSource is null) - throw new DotvvmControlException(this, "Cannot execute sort command, DataSource is null"); - var sortOptions = (dataSource as ISortableGridViewDataSet)?.SortingOptions; - if (sortOptions is null) - throw new DotvvmControlException(this, "Cannot execute sort command, DataSource does not have sorting options"); - if (sortOptions.SortExpression == expr) - { - sortOptions.SortDescending ^= true; - } - else - { - sortOptions.SortExpression = expr; - sortOptions.SortDescending = false; - } - (dataSource as IPageableGridViewDataSet)?.GoToFirstPage(); - } - - protected virtual void CreateHeaderRow(IDotvvmRequestContext context, Action? sortCommand) + protected virtual void CreateHeaderRow(IDotvvmRequestContext context) { head = new HtmlGenericControl("thead"); Children.Add(head); @@ -262,6 +251,7 @@ protected virtual void CreateHeaderRow(IDotvvmRequestContext context, Action + (string sortExpression) => + { + var dataSource = this.DataSource; + if (dataSource is null) + throw new DotvvmControlException(this, "Cannot execute sort command, DataSource is null"); + + SortChanged!(sortExpression); + + (dataSource as IPageableGridViewDataSet)?.PagingOptions.GoToFirstPage(); + (dataSource as IRefreshableGridViewDataSet)?.RequestRefresh(); + }), + new IdBindingProperty($"{this.GetDotvvmUniqueId().GetValue()}_sortBinding") + }); + } + private static void SetCellAttributes(GridViewColumn column, HtmlGenericControl cell, bool isHeaderCell) { var cellAttributes = cell.Attributes; @@ -526,7 +557,16 @@ protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequest var mapping = userColumnMappingService.GetMapping(itemType!); var mappingJson = JsonConvert.SerializeObject(mapping); - writer.AddKnockoutDataBind("dotvvm-gridviewdataset", $"{{'mapping':{mappingJson},'dataSet':{GetDataSourceBinding().GetKnockoutBindingExpression(this, unwrapped: true)}}}"); + var helperBinding = new KnockoutBindingGroup(); + helperBinding.Add("dataSet", GetDataSourceBinding().GetKnockoutBindingExpression(this, unwrapped: true)); + helperBinding.Add("mapping", mappingJson); + if (this.LoadData is { } loadData) + { + var loadDataExpression = KnockoutHelper.GenerateClientPostbackLambda("LoadData", loadData, this, new PostbackScriptOptions(elementAccessor: "$element", koContext: CodeParameterAssignment.FromIdentifier("$context"))); + helperBinding.Add("loadDataSet", loadDataExpression); + } + writer.AddKnockoutDataBind("dotvvm-gridviewdataset", helperBinding.ToString()); + base.AddAttributesToRender(writer, context); } diff --git a/src/Framework/Framework/Controls/GridViewBindings.cs b/src/Framework/Framework/Controls/GridViewBindings.cs new file mode 100644 index 0000000000..186352857e --- /dev/null +++ b/src/Framework/Framework/Controls/GridViewBindings.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Concurrent; +using DotVVM.Framework.Binding.Expressions; + +namespace DotVVM.Framework.Controls +{ + /// Contains pre-created command and value bindings for the components. An instance can be obtained from + public class GridViewBindings + { + private readonly ConcurrentDictionary> isSortColumnAscending = new(concurrencyLevel: 1, capacity: 16); + private readonly ConcurrentDictionary> isSortColumnDescending = new(concurrencyLevel: 1, capacity: 16); + + public ICommandBinding? SetSortExpression { get; init; } + + internal IValueBinding>? IsColumnSortedAscending { get; init; } + internal IValueBinding>? IsColumnSortedDescending { get; init; } + + public IValueBinding? GetIsColumnSortedAscendingBinding(string sortExpression) + { + if (IsColumnSortedAscending == null) + { + return null; + } + return isSortColumnAscending.GetOrAdd(sortExpression, _ => IsColumnSortedAscending.Select(a => a(sortExpression))); + } + + public IValueBinding? GetIsColumnSortedDescendingBinding(string sortExpression) + { + if (IsColumnSortedDescending == null) + { + return null; + } + return isSortColumnDescending.GetOrAdd(sortExpression, _ => IsColumnSortedDescending.Select(a => a(sortExpression))); + } + } +} diff --git a/src/Framework/Framework/Controls/GridViewColumn.cs b/src/Framework/Framework/Controls/GridViewColumn.cs index 5c0ab92a03..3a3560df6e 100644 --- a/src/Framework/Framework/Controls/GridViewColumn.cs +++ b/src/Framework/Framework/Controls/GridViewColumn.cs @@ -189,7 +189,7 @@ public virtual void CreateEditControls(IDotvvmRequestContext context, DotvvmCont EditTemplate.BuildContent(context, container); } - public virtual void CreateHeaderControls(IDotvvmRequestContext context, GridView gridView, Action? sortCommand, HtmlGenericControl cell, IGridViewDataSet? gridViewDataSet) + public virtual void CreateHeaderControls(IDotvvmRequestContext context, GridView gridView, GridViewBindings gridViewCommands, ICommandBinding? sortCommandBindingOverride, HtmlGenericControl cell, IGridViewDataSet? gridViewDataSet) { if (HeaderTemplate != null) { @@ -199,22 +199,28 @@ public virtual void CreateHeaderControls(IDotvvmRequestContext context, GridView if (AllowSorting) { - if (sortCommand == null) + var sortCommandBinding = sortCommandBindingOverride ?? gridViewCommands.SetSortExpression; + if (sortCommandBinding == null) { throw new DotvvmControlException(this, "Cannot use column sorting where no sort command is specified. Either put IGridViewDataSet in the DataSource property of the GridView, or set the SortChanged command on the GridView to implement custom sorting logic!"); } var sortExpression = GetSortExpression(); + if (string.IsNullOrEmpty(sortExpression)) + throw new DotvvmControlException(this, "The SortExpression property must be set when AllowSorting is true!"); + + if ((gridViewDataSet?.SortingOptions as ISortingSetSortExpressionCapability)?.IsSortingAllowed(sortExpression) == false) + throw new DotvvmControlException(this, $"The sort expression '{sortExpression}' is not allowed in the sorting options!"); + var linkButton = new LinkButton(); - linkButton.SetValue(LinkButton.TextProperty, GetValueRaw(HeaderTextProperty)); + linkButton.SetValue(ButtonBase.TextProperty, GetValueRaw(HeaderTextProperty)); + linkButton.ClickArguments = new object?[] { sortExpression }; cell.Children.Add(linkButton); - var bindingId = linkButton.GetDotvvmUniqueId().GetValue() + "_sortBinding"; - var binding = new CommandBindingExpression(context.Services.GetRequiredService().WithoutInitialization(), h => sortCommand(sortExpression), bindingId); - linkButton.SetBinding(ButtonBase.ClickProperty, binding); + linkButton.SetBinding(ButtonBase.ClickProperty, sortCommandBinding); - SetSortedCssClass(cell, gridViewDataSet, gridView.GetValueBinding(GridView.DataSourceProperty)!); + SetSortedCssClass(cell, gridViewDataSet, gridViewCommands); } else { @@ -234,25 +240,32 @@ public virtual void CreateFilterControls(IDotvvmRequestContext context, GridView } } - private void SetSortedCssClass(HtmlGenericControl cell, ISortableGridViewDataSet? sortableGridViewDataSet, IValueBinding dataSourceBinding) + private void SetSortedCssClass(HtmlGenericControl cell, ISortableGridViewDataSet? gridViewDataSet, GridViewBindings gridViewCommands) { - if (sortableGridViewDataSet != null) + if (gridViewDataSet is ISortableGridViewDataSet sortableGridViewDataSet && + GetSortExpression() is {} sortExpression) { var cellAttributes = cell.Attributes; if (!RenderOnServer) { - var gridViewDataSetExpr = dataSourceBinding.GetKnockoutBindingExpression(cell, unwrapped: true); - cellAttributes["data-bind"] = $"css: {{ '{SortDescendingHeaderCssClass}': ({gridViewDataSetExpr}).SortingOptions().SortExpression() == '{GetSortExpression()}' && ({gridViewDataSetExpr}).SortingOptions().SortDescending(), '{SortAscendingHeaderCssClass}': ({gridViewDataSetExpr}).SortingOptions().SortExpression() == '{GetSortExpression()}' && !({gridViewDataSetExpr}).SortingOptions().SortDescending()}}"; + if (!string.IsNullOrWhiteSpace(SortAscendingHeaderCssClass)) + { + cell.AddCssClass(SortAscendingHeaderCssClass, gridViewCommands.GetIsColumnSortedAscendingBinding(sortExpression)); + } + if (!string.IsNullOrWhiteSpace(SortDescendingHeaderCssClass)) + { + cell.AddCssClass(SortDescendingHeaderCssClass, gridViewCommands.GetIsColumnSortedDescendingBinding(sortExpression)); + } } - else if (sortableGridViewDataSet.SortingOptions.SortExpression == GetSortExpression()) + else { - if (sortableGridViewDataSet.SortingOptions.SortDescending) + if (sortableGridViewDataSet.SortingOptions.IsColumnSortedAscending(sortExpression)) { - cellAttributes["class"] = SortDescendingHeaderCssClass; + cellAttributes["class"] = SortAscendingHeaderCssClass; } - else + else if (sortableGridViewDataSet.SortingOptions.IsColumnSortedDescending(sortExpression)) { - cellAttributes["class"] = SortAscendingHeaderCssClass; + cellAttributes["class"] = SortDescendingHeaderCssClass; } } } diff --git a/src/Framework/Framework/Controls/GridViewDataSetBindingProvider.cs b/src/Framework/Framework/Controls/GridViewDataSetBindingProvider.cs new file mode 100644 index 0000000000..99b9490a5c --- /dev/null +++ b/src/Framework/Framework/Controls/GridViewDataSetBindingProvider.cs @@ -0,0 +1,380 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading.Tasks; +using DotVVM.Framework.Binding; +using DotVVM.Framework.Binding.Expressions; +using DotVVM.Framework.Binding.Properties; +using DotVVM.Framework.Compilation; +using DotVVM.Framework.Compilation.ControlTree; +using DotVVM.Framework.Compilation.Javascript; +using DotVVM.Framework.Compilation.Javascript.Ast; +using DotVVM.Framework.Utils; +using FastExpressionCompiler; + +namespace DotVVM.Framework.Controls; + +/// Creates data bindings for GridView, DataPager and related components. +public class GridViewDataSetBindingProvider +{ + private readonly BindingCompilationService service; + + private readonly ConcurrentDictionary<(DataContextStack dataContextStack, IValueBinding dataSetBinding, GridViewDataSetCommandType commandType), DataPagerBindings> dataPagerCommands = new(); + private readonly ConcurrentDictionary<(DataContextStack dataContextStack, IValueBinding dataSetBinding, GridViewDataSetCommandType commandType), GridViewBindings> gridViewCommands = new(); + + public GridViewDataSetBindingProvider(BindingCompilationService service) + { + this.service = service; + } + + /// Returns pre-created DataPager bindings for a given data context and data source (result is cached). + public DataPagerBindings GetDataPagerBindings(DataContextStack dataContextStack, IValueBinding dataSetBinding, GridViewDataSetCommandType commandType) + { + return dataPagerCommands.GetOrAdd((dataContextStack, dataSetBinding, commandType), x => GetDataPagerCommandsCore(x.dataContextStack, x.dataSetBinding, x.commandType)); + } + + /// Returns pre-created GridView bindings for a given data context and data source (result is cached). + public GridViewBindings GetGridViewBindings(DataContextStack dataContextStack, IValueBinding dataSetBinding, GridViewDataSetCommandType commandType) + { + return gridViewCommands.GetOrAdd((dataContextStack, dataSetBinding, commandType), x => GetGridViewBindingsCore(x.dataContextStack, x.dataSetBinding, x.commandType)); + } + + private DataPagerBindings GetDataPagerCommandsCore(DataContextStack dataContextStack, IValueBinding dataSetBinding, GridViewDataSetCommandType commandType) + { + var dataSetExpr = dataSetBinding.GetProperty().Expression; + ICommandBinding? GetCommandOrNull(DataContextStack dataContextStack, string methodName, params Expression[] arguments) + { + return typeof(T).IsAssignableFrom(dataSetExpr.Type) + ? CreateCommandBinding(commandType, dataSetExpr, dataContextStack, methodName, arguments) + : null; + } + + IStaticValueBinding? GetValueBindingOrNull(Expression> expression) + { + if (typeof(T).IsAssignableFrom(dataSetExpr.Type)) + { + return (IStaticValueBinding)ValueOrBindingExtensions.SelectImpl(dataSetBinding, expression); + } + else + { + return null; + } + } + + ParameterExpression CreateParameter(DataContextStack dataContextStack, string name = "_this") + { + return Expression.Parameter(dataContextStack.DataContextType, name).AddParameterAnnotation(new BindingParameterAnnotation(dataContextStack)); + } + + var pageIndexDataContext = DataContextStack.CreateCollectionElement( + typeof(int), dataContextStack + ); + + var isFirstPage = GetValueBindingOrNull, bool>(d => d.PagingOptions.IsFirstPage) ?? + GetValueBindingOrNull, bool>(d => d.PagingOptions.IsFirstPage); + var isLastPage = GetValueBindingOrNull, bool>(d => d.PagingOptions.IsLastPage) ?? + GetValueBindingOrNull, bool>(d => d.PagingOptions.IsLastPage); + + return new DataPagerBindings() + { + GoToFirstPage = GetCommandOrNull>( + dataContextStack, + nameof(IPagingFirstPageCapability.GoToFirstPage)), + + GoToPreviousPage = GetCommandOrNull>( + dataContextStack, + nameof(IPagingPreviousPageCapability.GoToPreviousPage)), + + GoToNextPage = GetCommandOrNull>( + dataContextStack, + nameof(IPagingNextPageCapability.GoToNextPage)), + + GoToLastPage = GetCommandOrNull>( + dataContextStack, + nameof(IPagingLastPageCapability.GoToLastPage)), + + GoToPage = GetCommandOrNull>( + pageIndexDataContext, + nameof(IPagingPageIndexCapability.GoToPage), + CreateParameter(pageIndexDataContext, "_thisIndex")), + + IsFirstPage = isFirstPage, + IsLastPage = isLastPage, + PageNumbers = + GetValueBindingOrNull, IEnumerable>(d => d.PagingOptions.NearPageIndexes), + + IsActivePage = // _this == _parent.DataSet.PagingOptions.PageIndex + typeof(IPageableGridViewDataSet).IsAssignableFrom(dataSetExpr.Type) + ? new ValueBindingExpression(service, new object[] { + pageIndexDataContext, + new ParsedExpressionBindingProperty(Expression.Equal( + CreateParameter(pageIndexDataContext, "_thisIndex"), + Expression.Property(Expression.Property(dataSetExpr, "PagingOptions"), "PageIndex") + )), + }) + : null, + + PageNumberText = + service.Cache.CreateValueBinding("_this + 1", pageIndexDataContext), + HasMoreThanOnePage = + GetValueBindingOrNull, bool>(d => d.PagingOptions.PagesCount > 1) ?? + (isFirstPage != null && isLastPage != null ? + new ValueBindingExpression(service, new object[] { + dataContextStack, + new ParsedExpressionBindingProperty(Expression.Not(Expression.AndAlso( + isFirstPage.GetProperty().Expression, + isLastPage.GetProperty().Expression + ))) + }) : null) + }; + } + + private GridViewBindings GetGridViewBindingsCore(DataContextStack dataContextStack, IValueBinding dataSetBinding, GridViewDataSetCommandType commandType) + { + var dataSetExpr = dataSetBinding.GetProperty().Expression; + ICommandBinding? GetCommandOrNull(DataContextStack dataContextStack, string methodName, Expression[] arguments, Func? transformExpression) + { + return typeof(T).IsAssignableFrom(dataSetExpr.Type) + ? CreateCommandBinding(commandType, dataSetExpr, dataContextStack, methodName, arguments, transformExpression) + : null; + } + IValueBinding? GetValueBindingOrNull(DataContextStack dataContextStack, string methodName, Expression[] arguments, Func? transformExpression) + { + return typeof(T).IsAssignableFrom(dataSetExpr.Type) + ? CreateValueBinding(dataSetExpr, dataContextStack, methodName, arguments, transformExpression) + : null; + } + + var setSortExpressionParam = Expression.Parameter(typeof(string), "_sortExpression"); + return new GridViewBindings() + { + SetSortExpression = GetCommandOrNull>( + dataContextStack, + nameof(ISortingSetSortExpressionCapability.SetSortExpression), + new Expression[] { setSortExpressionParam }, + // transform to sortExpression => command lambda + e => Expression.Lambda(e, setSortExpressionParam)), + IsColumnSortedAscending = GetValueBindingOrNull, Func>( + dataContextStack, + nameof(ISortingStateCapability.IsColumnSortedAscending), + new Expression[] { setSortExpressionParam }, + // transform to sortExpression => command lambda + e => Expression.Lambda(e, setSortExpressionParam)), + IsColumnSortedDescending = GetValueBindingOrNull, Func>( + dataContextStack, + nameof(ISortingStateCapability.IsColumnSortedDescending), + new Expression[] { setSortExpressionParam }, + // transform to sortExpression => command lambda + e => Expression.Lambda(e, setSortExpressionParam)), + }; + } + + private IValueBinding CreateValueBinding(Expression dataSet, DataContextStack dataContextStack, string methodName, Expression[] arguments, Func? transformExpression = null) + { + // get concrete type from implementation of IXXXableGridViewDataSet + var optionsConcreteType = GetOptionsConcreteType(dataSet.Type, out var optionsProperty); + + // call dataSet.XXXOptions.Method(...); + Expression expression = Expression.Call( + Expression.Convert(Expression.Property(dataSet, optionsProperty), optionsConcreteType), + optionsConcreteType.GetMethod(methodName)!, + arguments); + + if (transformExpression != null) + { + expression = transformExpression(expression); + } + + return new ValueBindingExpression(service, + new object[] + { + new ParsedExpressionBindingProperty(expression), + dataContextStack + }); + } + + private ICommandBinding CreateCommandBinding(GridViewDataSetCommandType commandType, Expression dataSet, DataContextStack dataContextStack, string methodName, Expression[] arguments, Func? transformExpression = null) + { + if (commandType == GridViewDataSetCommandType.Default) + { + // create a binding {command: dataSet.XXXOptions.YYY(args); dataSet.RequestRefresh() } + return CreateDefaultCommandBinding(dataSet, dataContextStack, methodName, arguments, transformExpression); + } + else if (commandType == GridViewDataSetCommandType.LoadDataDelegate) + { + // create a binding {staticCommand: GridViewDataSetBindingProvider.LoadDataSet(dataSet, options => { options.XXXOptions.YYY(args); }, loadDataDelegate, postProcessor) } + return CreateLoadDataDelegateCommandBinding(dataSet, dataContextStack, methodName, arguments, transformExpression); + } + else + { + throw new NotSupportedException($"The command type {commandType} is not supported!"); + } + } + + private ICommandBinding CreateDefaultCommandBinding(Expression dataSet, DataContextStack dataContextStack, string methodName, Expression[] arguments, Func? transformExpression) + { + var body = new List(); + + // get concrete type from implementation of IXXXableGridViewDataSet + var optionsConcreteType = GetOptionsConcreteType(dataSet.Type, out var optionsProperty); + + // call dataSet.XXXOptions.Method(...); + var callMethodOnOptions = Expression.Call( + Expression.Convert(Expression.Property(dataSet, optionsProperty), optionsConcreteType), + optionsConcreteType.GetMethod(methodName)!, + arguments); + body.Add(callMethodOnOptions); + + // if we are on a server, call the dataSet.RequestRefresh if supported + if (typeof(IRefreshableGridViewDataSet).IsAssignableFrom(dataSet.Type)) + { + var callRequestRefresh = Expression.Call( + Expression.Convert(dataSet, typeof(IRefreshableGridViewDataSet)), + typeof(IRefreshableGridViewDataSet).GetMethod(nameof(IRefreshableGridViewDataSet.RequestRefresh))! + ); + body.Add(callRequestRefresh); + } + + // build command binding + Expression expression = Expression.Block(body); + if (transformExpression != null) + { + expression = transformExpression(expression); + } + + return new CommandBindingExpression(service, + new object[] + { + new ParsedExpressionBindingProperty(expression), + new OriginalStringBindingProperty($"DataPager({dataSet.Type.ToCode()}): {dataSet.ToCSharpString().TrimEnd(';')}.{methodName}({string.Join(", ", arguments.AsEnumerable())})"), // For ID generation + dataContextStack + }); + } + + private ICommandBinding CreateLoadDataDelegateCommandBinding(Expression dataSet, DataContextStack dataContextStack, string methodName, Expression[] arguments, Func? transformExpression) + { + var loadDataSetMethod = typeof(GridViewDataSetBindingProvider).GetMethod(nameof(DataSetClientSideLoad))!; + + // build the concrete type of GridViewDataSetOptions<,,> + GetOptionsConcreteType(dataSet.Type, out var filteringOptionsProperty); + GetOptionsConcreteType(dataSet.Type, out var sortingOptionsProperty); + GetOptionsConcreteType(dataSet.Type, out var pagingOptionsProperty); + GetOptionsConcreteType(dataSet.Type, out var itemProperty); + var itemType = itemProperty.PropertyType.GetEnumerableType()!; + + var optionsType = typeof(GridViewDataSetOptions<,,>).MakeGenericType(filteringOptionsProperty.PropertyType, sortingOptionsProperty.PropertyType, pagingOptionsProperty.PropertyType); + var resultType = typeof(GridViewDataSetResult<,,,>).MakeGenericType(itemType, filteringOptionsProperty.PropertyType, sortingOptionsProperty.PropertyType, pagingOptionsProperty.PropertyType); + + // get concrete type from implementation of IXXXableGridViewDataSet + var modifiedOptionsType = GetOptionsConcreteType(dataSet.Type, out var modifiedOptionsProperty); + + // call options.XXXOptions.Method(...); + var optionsParameter = Expression.Parameter(optionsType, "options"); + var callMethodOnOptions = Expression.Call( + Expression.Convert(Expression.Property(optionsParameter, modifiedOptionsProperty.Name), modifiedOptionsType), + modifiedOptionsType.GetMethod(methodName)!, + arguments); + + // build options => options.XXXOptions.Method(...) + var optionsTransformLambdaType = typeof(Action<>).MakeGenericType(optionsType); + var optionsTransformLambda = Expression.Lambda(optionsTransformLambdaType, callMethodOnOptions, optionsParameter); + + var expression = (Expression)Expression.Call( + loadDataSetMethod.MakeGenericMethod(dataSet.Type, itemType, filteringOptionsProperty.PropertyType, sortingOptionsProperty.PropertyType, pagingOptionsProperty.PropertyType), + dataSet, + optionsTransformLambda, + Expression.Constant(null, typeof(Func<,>).MakeGenericType(optionsType, typeof(Task<>).MakeGenericType(resultType))), + Expression.Constant(null, typeof(Action<,>).MakeGenericType(dataSet.Type, resultType))); + + if (transformExpression != null) + { + expression = transformExpression(expression); + } + return new StaticCommandBindingExpression(service, + new object[] + { + new ParsedExpressionBindingProperty(expression), + BindingParserOptions.StaticCommand, + dataContextStack + }); + } + + public static CodeSymbolicParameter LoadDataDelegate = new CodeSymbolicParameter( + "LoadDataDelegate", + CodeParameterAssignment.FromExpression(JavascriptTranslator.KnockoutContextParameter.ToExpression().Member("$gridViewDataSetHelper").Member("loadDataSet")) + ); + public static CodeSymbolicParameter PostProcessorDelegate = new CodeSymbolicParameter( + "PostProcessorDelegate", + CodeParameterAssignment.FromExpression(JavascriptTranslator.KnockoutContextParameter.ToExpression().Member("$gridViewDataSetHelper").Member("postProcessor")) + ); + + /// + /// A sentinel method which is translated to load the GridViewDataSet on the client side using the Load delegate. + /// Do not call this method on the server. + /// + public static Task DataSetClientSideLoad( + IBaseGridViewDataSet dataSet, + Action> optionsTransformer, + Func, Task>> loadDataDelegate, + Action> postProcessor) + where TFilteringOptions : IFilteringOptions + where TSortingOptions : ISortingOptions + where TPagingOptions : IPagingOptions + { + throw new InvalidOperationException("This method cannot be called on the server!"); + } + + private static Type GetOptionsConcreteType(Type dataSetConcreteType, out PropertyInfo optionsProperty) + { + if (!typeof(TDataSetInterface).IsAssignableFrom(dataSetConcreteType)) + { + throw new ArgumentException($"The type {typeof(TDataSetInterface)} must be a generic type and must be implemented by the type {dataSetConcreteType} specified in {nameof(dataSetConcreteType)} argument!"); + } + + // resolve options property + var genericInterface = typeof(TDataSetInterface).IsGenericType ? typeof(TDataSetInterface).GetGenericTypeDefinition() : typeof(TDataSetInterface); + if (genericInterface == typeof(IFilterableGridViewDataSet<>) || genericInterface == typeof(IFilterableGridViewDataSet)) + { + optionsProperty = typeof(TDataSetInterface).GetProperty(nameof(IFilterableGridViewDataSet.FilteringOptions))!; + genericInterface = typeof(IFilterableGridViewDataSet<>); + } + else if (genericInterface == typeof(ISortableGridViewDataSet<>) || genericInterface == typeof(ISortableGridViewDataSet)) + { + optionsProperty = typeof(TDataSetInterface).GetProperty(nameof(ISortableGridViewDataSet.SortingOptions))!; + genericInterface = typeof(ISortableGridViewDataSet<>); + } + else if (genericInterface == typeof(IPageableGridViewDataSet<>) || genericInterface == typeof(IPageableGridViewDataSet)) + { + optionsProperty = typeof(TDataSetInterface).GetProperty(nameof(IPageableGridViewDataSet.PagingOptions))!; + genericInterface = typeof(IPageableGridViewDataSet<>); + } + else if (genericInterface == typeof(IBaseGridViewDataSet<>) || genericInterface == typeof(IBaseGridViewDataSet)) + { + optionsProperty = typeof(TDataSetInterface).GetProperty(nameof(IBaseGridViewDataSet.Items))!; + genericInterface = typeof(IBaseGridViewDataSet<>); + } + else + { + throw new ArgumentException($"The {typeof(TDataSetInterface)} can only be {nameof(IFilterableGridViewDataSet)}, {nameof(ISortableGridViewDataSet)} or {nameof(IPageableGridViewDataSet)} with one generic argument!"); + } + + var interfaces = dataSetConcreteType.GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == genericInterface) + .Distinct() + .ToList(); + if (interfaces.Count < 1) + { + throw new ArgumentException($"The {dataSetConcreteType} doesn't implement {genericInterface.Name}."); + } + else if (interfaces.Count > 1) + { + throw new ArgumentException($"The {dataSetConcreteType} implements multiple interfaces where {genericInterface.Name} ({interfaces.Select(i => i.ToCode()).StringJoin(", ")}). Only one implementation is allowed."); + } + + var pagingOptionsConcreteType = interfaces[0].GetGenericArguments()[0]; + return pagingOptionsConcreteType; + } +} diff --git a/src/Framework/Framework/Controls/GridViewDataSetCommandType.cs b/src/Framework/Framework/Controls/GridViewDataSetCommandType.cs new file mode 100644 index 0000000000..54f3dd55d1 --- /dev/null +++ b/src/Framework/Framework/Controls/GridViewDataSetCommandType.cs @@ -0,0 +1,14 @@ +namespace DotVVM.Framework.Controls +{ + public enum GridViewDataSetCommandType + { + /// + /// The will create command bindings which set the sorting/paging options and let the user handle data loading in the PreRender phase. + /// + Default, + /// + /// The commands returned by utilize the LoadData function provided in the JS parameter. + /// + LoadDataDelegate + } +} diff --git a/src/Framework/Framework/Controls/KnockoutBindingGroup.cs b/src/Framework/Framework/Controls/KnockoutBindingGroup.cs index aadc0a5c07..644cd3ee72 100644 --- a/src/Framework/Framework/Controls/KnockoutBindingGroup.cs +++ b/src/Framework/Framework/Controls/KnockoutBindingGroup.cs @@ -85,7 +85,21 @@ protected virtual string GetKnockoutBindingExpression(DotvvmBindableObject obj, public override string ToString() { if (entries.Count == 0) return "{}"; - return "{ " + string.Join(", ", entries) + " }"; + bool multiline = false; + foreach (var entry in entries) +#if DotNetCore + if (entry.Expression.Contains('\n')) +#else + if (entry.Expression.Contains("\n")) +#endif + { + multiline = true; + break; + } + if (multiline) + return "{\n" + string.Join(",\n", entries) + "\n}"; + else + return "{ " + string.Join(", ", entries) + " }"; } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); diff --git a/src/Framework/Framework/Controls/KnockoutHelper.cs b/src/Framework/Framework/Controls/KnockoutHelper.cs index e0438393ea..2d1bfb5a9f 100644 --- a/src/Framework/Framework/Controls/KnockoutHelper.cs +++ b/src/Framework/Framework/Controls/KnockoutHelper.cs @@ -114,20 +114,20 @@ public static void AddKnockoutForeachDataBind(this IHtmlWriter writer, string ex /// Generates a function expression that invokes the command with specified commandArguments. Creates code like `(...commandArguments) => dotvvm.postBack(...)` public static string GenerateClientPostbackLambda(string propertyName, ICommandBinding command, DotvvmBindableObject control, PostbackScriptOptions? options = null) { - options ??= new PostbackScriptOptions( - elementAccessor: "$element", - koContext: CodeParameterAssignment.FromIdentifier("$context", true) - ); + options ??= PostbackScriptOptions.KnockoutBinding; - var hasArguments = command is IStaticCommandBinding || command.CommandJavascript.EnumerateAllParameters().Any(p => p == CommandBindingExpression.CommandArgumentsParameter); - options.CommandArgs = hasArguments ? new CodeParameterAssignment(new ParametrizedCode("args", OperatorPrecedence.Max)) : default; + var addArguments = options.CommandArgs is null && (command is IStaticCommandBinding || command.CommandJavascript.EnumerateAllParameters().Any(p => p == CommandBindingExpression.CommandArgumentsParameter)); + if (addArguments) + { + options = options with { CommandArgs = new CodeParameterAssignment(new ParametrizedCode("args", OperatorPrecedence.Max)) }; + } // just few commands have arguments so it's worth checking if we need to clutter the output with argument propagation var call = KnockoutHelper.GenerateClientPostBackExpression( propertyName, command, control, options); - return hasArguments ? $"(...args)=>({call})" : $"()=>({call})"; + return addArguments ? $"(...args)=>({call})" : $"()=>({call})"; } /// Generates Javascript code which executes the specified command binding . @@ -205,6 +205,10 @@ string getHandlerScript() var adjustedExpression = JavascriptTranslator.AdjustKnockoutScriptContext(jsExpression, dataContextLevel: expression.FindDataContextTarget(control).stepsUp); + if (options.ParameterAssignment is {}) + { + adjustedExpression = adjustedExpression.AssignParameters(options.ParameterAssignment); + } // when the expression changes the dataContext, we need to override the default knockout context fo the command binding. CodeParameterAssignment knockoutContext; CodeParameterAssignment viewModel = default; diff --git a/src/Framework/Framework/Controls/LinkButton.cs b/src/Framework/Framework/Controls/LinkButton.cs index 0be751d95c..a15087a6e6 100644 --- a/src/Framework/Framework/Controls/LinkButton.cs +++ b/src/Framework/Framework/Controls/LinkButton.cs @@ -33,10 +33,9 @@ protected override void AddAttributesToRender(IHtmlWriter writer, IDotvvmRequest base.AddAttributesToRender(writer, context); - var clickBinding = GetCommandBinding(ClickProperty); - if (clickBinding != null) + if (CreateClickScript() is {} clickScript) { - writer.AddAttribute("onclick", KnockoutHelper.GenerateClientPostBackScript(nameof(Click), clickBinding, this), true, ";"); + writer.AddAttribute("onclick", clickScript, append: true, appendSeparator: ";"); } } diff --git a/src/Framework/Framework/Controls/PostbackScriptOptions.cs b/src/Framework/Framework/Controls/PostbackScriptOptions.cs index 745a99db01..2a77dd9cb4 100644 --- a/src/Framework/Framework/Controls/PostbackScriptOptions.cs +++ b/src/Framework/Framework/Controls/PostbackScriptOptions.cs @@ -1,25 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Text; using DotVVM.Framework.Compilation.Javascript; namespace DotVVM.Framework.Controls { /// Options for the method. - public class PostbackScriptOptions + public sealed record PostbackScriptOptions { /// If true, the command invocation will be wrapped in window.setTimeout with timeout 0. This is necessary for some event handlers, when the handler is invoked before the change is actually applied. - public bool UseWindowSetTimeout { get; set; } + public bool UseWindowSetTimeout { get; init; } /// Return value of the event handler. If set to false, the script will also include event.stopPropagation() - public bool? ReturnValue { get; set; } - public bool IsOnChange { get; set; } + public bool? ReturnValue { get; init; } + public bool IsOnChange { get; init; } /// Javascript variable where the sender element can be found. Set to $element when in knockout binding. - public CodeParameterAssignment ElementAccessor { get; set; } + public CodeParameterAssignment ElementAccessor { get; init; } /// Javascript variable current knockout binding context can be found. By default, `ko.contextFor({elementAccessor})` is used - public CodeParameterAssignment? KoContext { get; set; } + public CodeParameterAssignment? KoContext { get; init; } /// Javascript expression returning an array of command arguments. - public CodeParameterAssignment? CommandArgs { get; set; } + public CodeParameterAssignment? CommandArgs { get; init; } /// When set to false, postback handlers will not be invoked for this command. - public bool AllowPostbackHandlers { get; } + public bool AllowPostbackHandlers { get; init; } /// Javascript expression returning AbortSignal which can be used to cancel the postback (it's a JS variant of CancellationToken). - public CodeParameterAssignment? AbortSignal { get; } + public CodeParameterAssignment? AbortSignal { get; init; } + public Func? ParameterAssignment { get; init; } /// If true, the command invocation will be wrapped in window.setTimeout with timeout 0. This is necessary for some event handlers, when the handler is invoked before the change is actually applied. /// Return value of the event handler. If set to false, the script will also include event.stopPropagation() @@ -36,7 +40,8 @@ public PostbackScriptOptions(bool useWindowSetTimeout = false, CodeParameterAssignment? koContext = null, CodeParameterAssignment? commandArgs = null, bool allowPostbackHandlers = true, - CodeParameterAssignment? abortSignal = null) + CodeParameterAssignment? abortSignal = null, + Func? parameterAssignment = null) { this.UseWindowSetTimeout = useWindowSetTimeout; this.ReturnValue = returnValue; @@ -45,7 +50,27 @@ public PostbackScriptOptions(bool useWindowSetTimeout = false, this.KoContext = koContext; this.CommandArgs = commandArgs; this.AllowPostbackHandlers = allowPostbackHandlers; - AbortSignal = abortSignal; + this.AbortSignal = abortSignal; + this.ParameterAssignment = parameterAssignment; + } + + /// Default postback options, optimal for placing the script into a `onxxx` event attribute. + public static readonly PostbackScriptOptions JsEvent = new PostbackScriptOptions(); + public static readonly PostbackScriptOptions KnockoutBinding = new PostbackScriptOptions(elementAccessor: "$element", koContext: new CodeParameterAssignment("$context", OperatorPrecedence.Max, isGlobalContext: true)); + + public override string ToString() + { + var fields = new List(); + if (UseWindowSetTimeout) fields.Add("useWindowSetTimeout: true"); + if (ReturnValue != false) fields.Add($"returnValue: {(ReturnValue == true ? "true" : "null")}"); + if (IsOnChange) fields.Add("isOnChange: true"); + if (ElementAccessor.ToString() != "this") fields.Add($"elementAccessor: \"{ElementAccessor}\""); + if (KoContext != null) fields.Add($"koContext: \"{KoContext}\""); + if (CommandArgs != null) fields.Add($"commandArgs: \"{CommandArgs}\""); + if (!AllowPostbackHandlers) fields.Add("allowPostbackHandlers: false"); + if (AbortSignal != null) fields.Add($"abortSignal: \"{AbortSignal}\""); + if (ParameterAssignment != null) fields.Add($"parameterAssignment: \"{ParameterAssignment}\""); + return new StringBuilder("new PostbackScriptOptions(").Append(string.Join(", ", fields.ToArray())).Append(")").ToString(); } } } diff --git a/src/Framework/Framework/DependencyInjection/DotVVMServiceCollectionExtensions.cs b/src/Framework/Framework/DependencyInjection/DotVVMServiceCollectionExtensions.cs index 9efb2c044c..a446f281e3 100644 --- a/src/Framework/Framework/DependencyInjection/DotVVMServiceCollectionExtensions.cs +++ b/src/Framework/Framework/DependencyInjection/DotVVMServiceCollectionExtensions.cs @@ -77,7 +77,7 @@ public static IServiceCollection RegisterDotVVMServices(IServiceCollection servi services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Framework/Framework/DotVVM.Framework.csproj b/src/Framework/Framework/DotVVM.Framework.csproj index 9bafd8d706..0e163e9521 100644 --- a/src/Framework/Framework/DotVVM.Framework.csproj +++ b/src/Framework/Framework/DotVVM.Framework.csproj @@ -81,6 +81,9 @@ TextTemplatingFilePreprocessor JQueryGlobalizeRegisterTemplate.cs + + Code + @@ -125,6 +128,7 @@ + diff --git a/src/Framework/Framework/Resources/Scripts/binding-handlers/all-handlers.ts b/src/Framework/Framework/Resources/Scripts/binding-handlers/all-handlers.ts index 6480b3a0a2..11c0ed71f7 100644 --- a/src/Framework/Framework/Resources/Scripts/binding-handlers/all-handlers.ts +++ b/src/Framework/Framework/Resources/Scripts/binding-handlers/all-handlers.ts @@ -11,6 +11,7 @@ import namedCommand from './named-command' import fileUpload from './file-upload' import jsComponents from './js-component' import modalDialog from './modal-dialog' +import appendableDataPager from './appendable-data-pager' type KnockoutHandlerDictionary = { [name: string]: KnockoutBindingHandler @@ -28,7 +29,8 @@ const allHandlers: KnockoutHandlerDictionary = { ...namedCommand, ...fileUpload, ...jsComponents, - ...modalDialog + ...modalDialog, + ...appendableDataPager } export default allHandlers diff --git a/src/Framework/Framework/Resources/Scripts/binding-handlers/appendable-data-pager.ts b/src/Framework/Framework/Resources/Scripts/binding-handlers/appendable-data-pager.ts new file mode 100644 index 0000000000..9a76cfd05f --- /dev/null +++ b/src/Framework/Framework/Resources/Scripts/binding-handlers/appendable-data-pager.ts @@ -0,0 +1,65 @@ +import { getStateManager } from "../dotvvm-base"; + +type AppendableDataPagerBinding = { + autoLoadWhenInViewport: boolean, + loadNextPage: () => Promise, + dataSet: any +}; + +export default { + 'dotvvm-appendable-data-pager': { + init: (element: HTMLInputElement, valueAccessor: () => AppendableDataPagerBinding, allBindings?: any, viewModel?: any, bindingContext?: KnockoutBindingContext) => { + const binding = valueAccessor(); + + const isLoading = ko.observable(false); + const canLoadNextPage = ko.computed(() => !isLoading() && valueAccessor().dataSet?.PagingOptions()?.IsLastPage() === false); + + // prepare the context with $appendableDataPager object + const state = { + loadNextPage: async () => { + try { + isLoading(true); + + await binding.loadNextPage(); + } + finally { + isLoading(false); + } + }, + canLoadNextPage, + isLoading: ko.computed(() => isLoading()) + }; + + // set up intersection observer + if (binding.autoLoadWhenInViewport) { + + // track the scroll position and load the next page when the element is in the viewport + const observer = new IntersectionObserver(async (entries) => { + let entry = entries[0]; + if (entry?.isIntersecting) { + if (!canLoadNextPage()) return; + + // load the next page + await state.loadNextPage(); + + // when the loading was finished, check whether we need to load another page + await new Promise(r => window.setTimeout(r, 500)); + observer.unobserve(element); + observer.observe(element); + } + }, { + rootMargin: "20px" + }); + observer.observe(element); + ko.utils.domNodeDisposal.addDisposeCallback(element, () => observer.disconnect()); + } + + // extend the context + const innerBindingContext = bindingContext!.extend({ + $appendableDataPager: state + }); + ko.applyBindingsToDescendants(innerBindingContext, element); + return { controlsDescendantBindings: true }; // do not apply binding again + } + } +} diff --git a/src/Framework/Framework/Resources/Scripts/dataset/loader.ts b/src/Framework/Framework/Resources/Scripts/dataset/loader.ts new file mode 100644 index 0000000000..4413ec3e96 --- /dev/null +++ b/src/Framework/Framework/Resources/Scripts/dataset/loader.ts @@ -0,0 +1,60 @@ +import { StateManager } from "../state-manager"; + +type GridViewDataSet = { + PagingOptions: object, + SortingOptions: object, + FilteringOptions: object, + Items: any[], + IsRefreshRequired?: boolean +}; +type GridViewDataSetOptions = { + PagingOptions: object, + SortingOptions: object, + FilteringOptions: object +}; +type GridViewDataSetResult = { + Items: any[], + PagingOptions: object, + SortingOptions: object, + FilteringOptions: object +}; + +export function getOptions(dataSetObservable: DotvvmObservable): GridViewDataSetOptions { + const dataSet = dataSetObservable.state + return structuredClone({ + FilteringOptions: dataSet.FilteringOptions, + SortingOptions: dataSet.SortingOptions, + PagingOptions: dataSet.PagingOptions + }) +} + +export async function loadDataSet( + dataSetObservable: DotvvmObservable, + transformOptions: (options: GridViewDataSetOptions) => void, + loadData: (options: GridViewDataSetOptions) => Promise, + postProcessor: (dataSet: DotvvmObservable, result: GridViewDataSetResult) => void = postProcessors.replace +) { + const options = getOptions(dataSetObservable); + transformOptions(options); + + const result = await loadData(options); + const commandResult = result.commandResult as GridViewDataSetResult; + + postProcessor(dataSetObservable, commandResult); +} + +export const postProcessors = { + + replace(dataSet: DotvvmObservable, result: GridViewDataSetResult) { + dataSet.updateState(ds => ({...ds, ...result})); + }, + + append(dataSet: DotvvmObservable, result: GridViewDataSetResult) { + dataSet.updateState(ds => ({ + ...ds, + ...result, + Items: [...ds.Items, ...result.Items] + })); + } + +}; diff --git a/src/Framework/Framework/Resources/Scripts/dataset/translations.ts b/src/Framework/Framework/Resources/Scripts/dataset/translations.ts new file mode 100644 index 0000000000..0b4145a714 --- /dev/null +++ b/src/Framework/Framework/Resources/Scripts/dataset/translations.ts @@ -0,0 +1,140 @@ +import { getOptions } from "./loader"; + +type PagingOptions = { + PageIndex: number, + PagesCount: number +}; + +type NextTokenPagingOptions = { + CurrentToken: string | null, + NextPageToken: string | null +}; + +type NextTokenHistoryPagingOptions = { + PageIndex: number, + TokenHistory: (string | null)[] +}; + +type SortingOptions = { + SortExpression: string | null, + SortDescending: boolean +}; + +type MultiCriteriaSortingOptions = { + Criteria: SortCriterion[], + MaxSortCriteriaCount: number +}; + +type SortCriterion = { + SortExpression: string, + SortDescending: boolean +}; + +export const translations = { + getOptions, + PagingOptions: { + goToFirstPage(options: PagingOptions) { + options.PageIndex = 0; + }, + goToLastPage(options: PagingOptions) { + options.PageIndex = options.PagesCount - 1; + }, + goToNextPage(options: PagingOptions) { + if (options.PageIndex < options.PagesCount - 1) { + options.PageIndex = options.PageIndex + 1; + } + }, + goToPreviousPage(options: PagingOptions) { + if (options.PageIndex > 0) { + options.PageIndex = options.PageIndex - 1; + } + }, + goToPage(options: PagingOptions, pageIndex: number) { + if (options.PageIndex >= 0 && options.PageIndex < options.PagesCount) { + options.PageIndex = pageIndex; + } + } + }, + NextTokenPagingOptions: { + goToFirstPage(options: NextTokenPagingOptions) { + options.CurrentToken = null; + options.NextPageToken = null; + }, + goToNextPage(options: NextTokenPagingOptions) { + if (options.NextPageToken != null) { + options.CurrentToken = options.NextPageToken; + options.NextPageToken = null; + } + } + }, + NextTokenHistoryPagingOptions: { + goToFirstPage(options: NextTokenHistoryPagingOptions) { + options.PageIndex = 0; + }, + goToNextPage(options: NextTokenHistoryPagingOptions) { + if (options.PageIndex < options.TokenHistory.length - 1) { + options.PageIndex = options.PageIndex + 1; + } + }, + goToPreviousPage(options: NextTokenHistoryPagingOptions) { + if (options.PageIndex > 0) { + options.PageIndex = options.PageIndex - 1; + } + }, + goToPage(options: NextTokenHistoryPagingOptions, pageIndex: number) { + if (options.PageIndex >= 0 && options.PageIndex < options.TokenHistory.length) { + options.PageIndex = pageIndex; + } + } + }, + + SortingOptions: { + setSortExpression(options: SortingOptions, sortExpression: string) { + if (sortExpression == null) { + options.SortExpression = null; + options.SortDescending = false; + } + else if (sortExpression == options.SortExpression) { + options.SortDescending = !options.SortDescending; + } + else { + options.SortExpression = sortExpression; + options.SortDescending = false; + } + }, + isColumnSortedAscending(options: SortingOptions, sortExpression: string) { + return options && options.SortExpression === sortExpression && !options.SortDescending; + }, + isColumnSortedDescending(options: SortingOptions, sortExpression: string) { + return options && options.SortExpression === sortExpression && options.SortDescending; + } + }, + MultiCriteriaSortingOptions: { + setSortExpression(options: MultiCriteriaSortingOptions, sortExpression: string) { + if (sortExpression == null) { + options.Criteria = []; + return; + } + + const index = options.Criteria.findIndex(c => c.SortExpression == sortExpression); + if (index === 0) { + options.Criteria[index].SortDescending = !options.Criteria[index].SortDescending; + } + else if (index > 0) { + options.Criteria.splice(index, 1); + options.Criteria.unshift({ SortExpression: sortExpression, SortDescending: false }); + } + else { + options.Criteria.unshift({ SortExpression: sortExpression, SortDescending: false }); + } + + options.Criteria.splice(options.MaxSortCriteriaCount); + }, + isColumnSortedAscending(options: MultiCriteriaSortingOptions, sortExpression: string) { + return options?.Criteria?.some(c => c.SortExpression === sortExpression && !c.SortDescending); + }, + isColumnSortedDescending(options: MultiCriteriaSortingOptions, sortExpression: string) { + return options?.Criteria?.some(c => c.SortExpression === sortExpression && c.SortDescending); + } + } +}; diff --git a/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts b/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts index 40ca264fc5..c783bf3398 100644 --- a/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts +++ b/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts @@ -27,6 +27,8 @@ import * as metadataHelper from './metadata/metadataHelper' import { StateManager } from "./state-manager" import { DotvvmEvent } from "./events" import translations from './translations/translations' +import { loadDataSet, postProcessors as loaderPostProcessors } from './dataset/loader' +import * as dataSetTranslations from './dataset/translations' if (window["dotvvm"]) { throw new Error('DotVVM is already loaded!') @@ -124,6 +126,11 @@ const dotvvmExports = { }, options, translations: translations as any, + dataSet: { + loadDataSet: loadDataSet, + postProcessors: loaderPostProcessors, + translations: dataSetTranslations.translations + }, StateManager, DotvvmEvent, } diff --git a/src/Framework/Framework/Resources/Scripts/state-manager.ts b/src/Framework/Framework/Resources/Scripts/state-manager.ts index 01ad509509..db436af246 100644 --- a/src/Framework/Framework/Resources/Scripts/state-manager.ts +++ b/src/Framework/Framework/Resources/Scripts/state-manager.ts @@ -46,7 +46,7 @@ export class StateManager { constructor( initialState: DeepReadonly, - public stateUpdateEvent: DotvvmEvent> + public stateUpdateEvent?: DotvvmEvent> ) { this._state = coerce(initialState, initialState.$type || { type: "dynamic" }) this.stateObservable = createWrappedObservable(initialState, (initialState as any)["$type"], () => this._state, u => this.update(u as any)) @@ -73,7 +73,7 @@ export class StateManager { isViewModelUpdating = true ko.delaySync.pause() - this.stateUpdateEvent.trigger(this._state); + this.stateUpdateEvent?.trigger(this._state); (this.stateObservable as any)[notifySymbol as any](this._state) } finally { diff --git a/src/Framework/Testing/BindingTestHelper.cs b/src/Framework/Testing/BindingTestHelper.cs index bf67a3d1e4..6c3c4f275d 100644 --- a/src/Framework/Testing/BindingTestHelper.cs +++ b/src/Framework/Testing/BindingTestHelper.cs @@ -152,6 +152,25 @@ public StaticCommandBindingExpression StaticCommand(string expression, DataConte }); } + /// Creates a command binding by parsing the specified expression. + /// Hierarchy of data contexts. First element is _root, last element is _this. + /// If specified, an implicit conversion into this type will be applied in the expression. + public CommandBindingExpression Command(string expression, Type[] contexts, Type? expectedType = null) => + Command(expression, CreateDataContext(contexts), expectedType); + + /// Creates a command binding by parsing the specified expression. + /// If specified, an implicit conversion into this type will be applied in the expression. + public CommandBindingExpression Command(string expression, DataContextStack context, Type? expectedType = null) + { + expectedType ??= typeof(Command); + return new CommandBindingExpression(BindingService, new object[] { + context, + new OriginalStringBindingProperty(expression), + BindingParserOptions.Value.AddImports(context.NamespaceImports).AddImports(Configuration.Markup.ImportedNamespaces), + new ExpectedTypeBindingProperty(expectedType) + }); + } + /// Creates a value binding by parsing the specified expression. The expression will be implicitly converted to /// Hierarchy of data contexts. First element is _root, last element is _this. /// Convert the result to this type instead of converting it to . The type must be assignable to T. diff --git a/src/Framework/Testing/ControlTestHelper.cs b/src/Framework/Testing/ControlTestHelper.cs index 75ece29cce..a81a7b7bd4 100644 --- a/src/Framework/Testing/ControlTestHelper.cs +++ b/src/Framework/Testing/ControlTestHelper.cs @@ -335,11 +335,8 @@ public string FormattedHtml return filtered.Single(); } - public async Task RunCommand(string text, Func? viewModel = null, bool applyChanges = true, object[]? args = null, CultureInfo? culture = null) + public async Task RunCommand(CommandBindingExpression command, DotvvmBindableObject control, bool applyChanges = true, object[]? args = null, CultureInfo? culture = null) { - var (control, property, binding) = FindCommand(text, viewModel); - if (binding is CommandBindingExpression command) - { var path = control .GetAllAncestors(true) .Select(a => a.GetDataContextPathFragment()) @@ -365,6 +362,13 @@ public async Task RunCommand(string text, Func? } return r; } + public async Task RunCommand(string text, Func? viewModel = null, bool applyChanges = true, object[]? args = null, CultureInfo? culture = null) + { + var (control, property, binding) = FindCommand(text, viewModel); + if (binding is CommandBindingExpression command) + { + return await RunCommand(command, control, applyChanges, args, culture); + } else { throw new NotSupportedException($"{binding} is not supported."); diff --git a/src/Samples/Common/DotVVM.Samples.Common.csproj b/src/Samples/Common/DotVVM.Samples.Common.csproj index 41bcf2a7ef..a5cb54ad45 100644 --- a/src/Samples/Common/DotVVM.Samples.Common.csproj +++ b/src/Samples/Common/DotVVM.Samples.Common.csproj @@ -59,6 +59,7 @@ all runtime; build; native; contentfiles; analyzers + diff --git a/src/Samples/Common/DotvvmStartup.cs b/src/Samples/Common/DotvvmStartup.cs index 8b8809f9f1..00f8b02d5e 100644 --- a/src/Samples/Common/DotvvmStartup.cs +++ b/src/Samples/Common/DotvvmStartup.cs @@ -77,7 +77,6 @@ public void Configure(DotvvmConfiguration config, string applicationPath) map.Property(nameof(SerializationViewModel.Value2)).Bind(Direction.ClientToServer); map.Property(nameof(SerializationViewModel.IgnoredProperty)).Ignore(); }); - // new GithubApiClient.GithubApiClient().Repos.GetIssues() config.RegisterApiGroup(typeof(Common.Api.Owin.TestWebApiClientOwin), "http://localhost:61453/", "Scripts/TestWebApiClientOwin.js", "_apiOwin"); config.RegisterApiClient(typeof(Common.Api.AspNetCore.TestWebApiClientAspNetCore), "http://localhost:50001/", "Scripts/TestWebApiClientAspNetCore.js", "_apiCore"); diff --git a/src/Samples/Common/Services/GitHubService.cs b/src/Samples/Common/Services/GitHubService.cs new file mode 100644 index 0000000000..76ce82e6a5 --- /dev/null +++ b/src/Samples/Common/Services/GitHubService.cs @@ -0,0 +1,55 @@ +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using DotVVM.Samples.BasicSamples; + +namespace DotVVM.Samples.Common.Services; + +public class GitHubService +{ + + public async Task<(IssueDto[] items, string nextToken)> GetGitHubIssues(string currentToken) + { + var client = GetGitHubClient(); + + var response = await client.GetAsync(currentToken ?? "https://api.github.com/repos/riganti/dotvvm/issues"); + response.EnsureSuccessStatusCode(); + var items = await response.Content.ReadFromJsonAsync(); + + return (items, ParseNextToken(response)); + } + + private static HttpClient GetGitHubClient() + { + var client = new HttpClient(); + var token = SampleConfiguration.Instance.AppSettings[DotvvmStartup.GitHubTokenConfigName]; + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); + client.DefaultRequestHeaders.Add("User-Agent", "tomasherceg"); + return client; + } + + private string ParseNextToken(HttpResponseMessage response) + { + var linkHeader = response.Headers.GetValues("Link").FirstOrDefault(); + if (string.IsNullOrWhiteSpace(linkHeader)) + { + return null; + } + + var match = Regex.Match(linkHeader, @"<([^>]+)>; rel=""next"""); + return match.Success ? match.Groups[1].Value : null; + } +} + +public class IssueDto +{ + public long Id { get; set; } + public long Number { get; set; } + public string Title { get; set; } + public string State { get; set; } +} diff --git a/src/Samples/Common/ViewModels/ControlSamples/AppendableDataPager/AppendableDataPagerViewModel.cs b/src/Samples/Common/ViewModels/ControlSamples/AppendableDataPager/AppendableDataPagerViewModel.cs new file mode 100644 index 0000000000..cbb16add08 --- /dev/null +++ b/src/Samples/Common/ViewModels/ControlSamples/AppendableDataPager/AppendableDataPagerViewModel.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Framework.ViewModel; +using DotVVM.Framework.Controls; +using DotVVM.Samples.BasicSamples.ViewModels.ControlSamples.GridView; + +namespace DotVVM.Samples.Common.ViewModels.ControlSamples.AppendableDataPager +{ + public class AppendableDataPagerViewModel : DotvvmViewModelBase + { + public GridViewDataSet Customers { get; set; } = new() { + PagingOptions = new PagingOptions { + PageSize = 3 + } + }; + + private static IQueryable GetData() + { + return new[] + { + new CustomerData {CustomerId = 1, Name = "John Doe", BirthDate = DateTime.Parse("1976-04-01")}, + new CustomerData {CustomerId = 2, Name = "John Deer", BirthDate = DateTime.Parse("1984-03-02")}, + new CustomerData {CustomerId = 3, Name = "Johnny Walker", BirthDate = DateTime.Parse("1934-01-03")}, + new CustomerData {CustomerId = 4, Name = "Jim Hacker", BirthDate = DateTime.Parse("1912-11-04")}, + new CustomerData {CustomerId = 5, Name = "Joe E. Brown", BirthDate = DateTime.Parse("1947-09-05")}, + new CustomerData {CustomerId = 6, Name = "Jack Daniels", BirthDate = DateTime.Parse("1956-07-06")}, + new CustomerData {CustomerId = 7, Name = "James Bond", BirthDate = DateTime.Parse("1965-05-07")}, + new CustomerData {CustomerId = 8, Name = "John Smith", BirthDate = DateTime.Parse("1974-03-08")}, + new CustomerData {CustomerId = 9, Name = "Jack & Jones", BirthDate = DateTime.Parse("1976-03-22")}, + new CustomerData {CustomerId = 10, Name = "Jim Bill", BirthDate = DateTime.Parse("1974-09-20")}, + new CustomerData {CustomerId = 11, Name = "James Joyce", BirthDate = DateTime.Parse("1982-11-28")}, + new CustomerData {CustomerId = 12, Name = "Joudy Jane", BirthDate = DateTime.Parse("1958-12-14")} + }.AsQueryable(); + } + public override Task PreRender() + { + // fill dataset + if (!Context.IsPostBack) + { + Customers.LoadFromQueryable(GetData()); + } + return base.PreRender(); + } + + [AllowStaticCommand] + public static async Task> LoadNextPage(GridViewDataSetOptions options) + { + await Task.Delay(2000); + var dataSet = new GridViewDataSet(); + dataSet.ApplyOptions(options); + dataSet.LoadFromQueryable(GetData()); + return dataSet; + } + } +} + diff --git a/src/Samples/Common/ViewModels/ControlSamples/GridView/GridViewPagingSortingViewModel.cs b/src/Samples/Common/ViewModels/ControlSamples/GridView/GridViewPagingSortingViewModel.cs index 4052140606..64439a2fac 100644 --- a/src/Samples/Common/ViewModels/ControlSamples/GridView/GridViewPagingSortingViewModel.cs +++ b/src/Samples/Common/ViewModels/ControlSamples/GridView/GridViewPagingSortingViewModel.cs @@ -51,6 +51,9 @@ private static IQueryable GetData() public List Null { get; set; } + public GridViewDataSet NullDataSet { get; set; } + + public string CustomNameForName { get; set; } = "Name"; public override Task PreRender() diff --git a/src/Samples/Common/ViewModels/ControlSamples/GridView/GridViewStaticCommandViewModel.cs b/src/Samples/Common/ViewModels/ControlSamples/GridView/GridViewStaticCommandViewModel.cs index 886eeed48e..a41ce1b149 100644 --- a/src/Samples/Common/ViewModels/ControlSamples/GridView/GridViewStaticCommandViewModel.cs +++ b/src/Samples/Common/ViewModels/ControlSamples/GridView/GridViewStaticCommandViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using DotVVM.Framework.Controls; @@ -8,28 +9,35 @@ namespace DotVVM.Samples.BasicSamples.ViewModels.ControlSamples.GridView { public class GridViewStaticCommandViewModel : DotvvmViewModelBase { - public GridViewStaticCommandViewModel() - { - CustomersDataSet = new GridViewDataSet + public GridViewDataSet StandardDataSet { get; set; } = new() { + PagingOptions = new PagingOptions { - PagingOptions = new PagingOptions - { - PageSize = 10 - } - }; - } + PageSize = 10 + } + }; + + public NextTokenGridViewDataSet NextTokenDataSet { get; set; } = new(); + + public NextTokenHistoryGridViewDataSet NextTokenHistoryDataSet { get; set; } = new(); - public GridViewDataSet CustomersDataSet { get; set; } + public MultiSortGridViewDataSet MultiSortDataSet { get; set; } = new(); private static IQueryable GetData() { return new[] { - new CustomerData {CustomerId = 1, Name = "John Doe", BirthDate = DateTime.Parse("1976-04-01")}, - new CustomerData {CustomerId = 2, Name = "John Deer", BirthDate = DateTime.Parse("1984-03-02")}, - new CustomerData {CustomerId = 3, Name = "Johnny Walker", BirthDate = DateTime.Parse("1934-01-03")}, - new CustomerData {CustomerId = 4, Name = "Jim Hacker", BirthDate = DateTime.Parse("1912-11-04")}, - new CustomerData {CustomerId = 5, Name = "Joe E. Brown", BirthDate = DateTime.Parse("1947-09-05")} + new CustomerData {CustomerId = 1, Name = "John Doe", BirthDate = DateTime.Parse("1976-04-01"), MessageReceived = false}, + new CustomerData {CustomerId = 2, Name = "John Deer", BirthDate = DateTime.Parse("1984-03-02"), MessageReceived = false}, + new CustomerData {CustomerId = 3, Name = "Johnny Walker", BirthDate = DateTime.Parse("1934-01-03"), MessageReceived = false}, + new CustomerData {CustomerId = 4, Name = "Jim Hacker", BirthDate = DateTime.Parse("1912-11-04"), MessageReceived = false}, + new CustomerData {CustomerId = 5, Name = "Joe E. Brown", BirthDate = DateTime.Parse("1947-09-05"), MessageReceived = true}, + new CustomerData {CustomerId = 6, Name = "Jack Daniels", BirthDate = DateTime.Parse("1934-01-03"), MessageReceived = false}, + new CustomerData {CustomerId = 7, Name = "James Bond", BirthDate = DateTime.Parse("1965-05-07"), MessageReceived = true}, + new CustomerData {CustomerId = 8, Name = "John Smith", BirthDate = DateTime.Parse("1974-03-08"), MessageReceived = false}, + new CustomerData {CustomerId = 9, Name = "Jack & Jones", BirthDate = DateTime.Parse("1934-01-03"), MessageReceived = true}, + new CustomerData {CustomerId = 10, Name = "Jim Bill", BirthDate = DateTime.Parse("1934-01-03"), MessageReceived = true}, + new CustomerData {CustomerId = 11, Name = "James Joyce", BirthDate = DateTime.Parse("1982-11-28"), MessageReceived = true}, + new CustomerData {CustomerId = 12, Name = "Joudy Jane", BirthDate = DateTime.Parse("1934-01-03"), MessageReceived = true} }.AsQueryable(); } @@ -38,16 +46,129 @@ public override Task PreRender() // fill dataset if (!Context.IsPostBack) { - CustomersDataSet.LoadFromQueryable(GetData()); + StandardDataSet.LoadFromQueryable(GetData()); + NextTokenDataSet.LoadFromQueryable(GetData()); + NextTokenHistoryDataSet.LoadFromQueryable(GetData()); + MultiSortDataSet.LoadFromQueryable(GetData()); } return base.PreRender(); } [AllowStaticCommand] - public void DeleteCustomerData(int customerId) + public static async Task> LoadStandard(GridViewDataSetOptions options) + { + var dataSet = new GridViewDataSet(); + dataSet.ApplyOptions(options); + dataSet.LoadFromQueryable(GetData()); + return dataSet; + } + + [AllowStaticCommand] + public static async Task LoadToken(GridViewDataSetOptions options) + { + var dataSet = new NextTokenGridViewDataSet(); + dataSet.ApplyOptions(options); + dataSet.LoadFromQueryable(GetData()); + return dataSet; + } + + [AllowStaticCommand] + public static async Task LoadTokenHistory(GridViewDataSetOptions options) + { + var dataSet = new NextTokenHistoryGridViewDataSet(); + dataSet.ApplyOptions(options); + dataSet.LoadFromQueryable(GetData()); + return dataSet; + } + + [AllowStaticCommand] + public static async Task LoadMultiSort(GridViewDataSetOptions options) + { + var dataSet = new MultiSortGridViewDataSet(); + dataSet.ApplyOptions(options); + dataSet.LoadFromQueryable(GetData()); + return dataSet; + } + + public class NextTokenGridViewDataSet() : GenericGridViewDataSet, RowEditOptions>( + new(), new(), new(), new(), new() + ); + + public class CustomerDataNextTokenPagingOptions : NextTokenPagingOptions, IApplyToQueryable, IPagingOptionsLoadingPostProcessor + { + public IQueryable ApplyToQueryable(IQueryable queryable) + { + var token = int.Parse(CurrentToken ?? "0"); + + return queryable.Cast() + .OrderBy(c => c.CustomerId) + .Where(c => c.CustomerId > token) + .Take(3) + .Cast(); + } + + public void ProcessLoadedItems(IQueryable filteredQueryable, IList items) + { + var lastToken = items.Cast() + .OrderByDescending(c => c.CustomerId) + .FirstOrDefault()?.CustomerId; + + lastToken ??= 0; + if (lastToken == 12) + { + NextPageToken = null; + } + else + { + NextPageToken = lastToken.ToString(); + } + } + } + + public class NextTokenHistoryGridViewDataSet() : GenericGridViewDataSet, RowEditOptions>( + new(), new(), new(), new(), new() + ); + + public class CustomerDataNextTokenHistoryPagingOptions : NextTokenHistoryPagingOptions, IApplyToQueryable, IPagingOptionsLoadingPostProcessor { - var customer = CustomersDataSet.Items.First(s => s != null && s.CustomerId == customerId); - CustomersDataSet.Items.Remove(customer); + private const int PageSize = 3; + + public IQueryable ApplyToQueryable(IQueryable queryable) + { + if (TokenHistory.Count == 0) + { + TokenHistory.Add(null); + } + var token = PageIndex < TokenHistory.Count ? int.Parse(TokenHistory[PageIndex] ?? "0") : 0; + + return queryable.Cast() + .OrderBy(c => c.CustomerId) + .Where(c => c.CustomerId > token) + .Take(PageSize + 1) + .Cast(); + } + + public void ProcessLoadedItems(IQueryable filteredQueryable, IList items) + { + var hasMoreItems = false; + while (items.Count > PageSize) + { + items.RemoveAt(items.Count - 1); + hasMoreItems = true; + } + + if (PageIndex == TokenHistory.Count - 1 && hasMoreItems) + { + var lastToken = items.Cast() + .LastOrDefault()?.CustomerId; + + TokenHistory.Add((lastToken ?? 0).ToString()); + } + } } + + public class MultiSortGridViewDataSet() : GenericGridViewDataSet, RowEditOptions>( + new(), new(), new(), new(), new() + ); } -} \ No newline at end of file +} diff --git a/src/Samples/Common/ViewModels/FeatureSamples/DataSet/GitHubApiStaticCommandsViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/DataSet/GitHubApiStaticCommandsViewModel.cs new file mode 100644 index 0000000000..4b057f7301 --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/DataSet/GitHubApiStaticCommandsViewModel.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Framework.Controls; +using DotVVM.Framework.ViewModel; +using DotVVM.Framework.Hosting; +using DotVVM.Samples.Common.Services; + +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.DataSet +{ + public class GitHubApiStaticCommandsViewModel : DotvvmViewModelBase + { + public GenericGridViewDataSet Issues { get; set; } = new(new(), new(), new(), new(), new()); + public GenericGridViewDataSet Issues2 { get; set; } = new(new(), new(), new(), new(), new()); + + public override async Task PreRender() + { + if (!Context.IsPostBack) + { + await Issues.LoadAsync(LoadIssues); + await Issues2.LoadAsync(LoadIssues2); + } + + await base.PreRender(); + } + + [AllowStaticCommand] + public static async Task> LoadIssues(GridViewDataSetOptions options) + { + var gitHubService = new GitHubService(); + + var result = await gitHubService.GetGitHubIssues(options.PagingOptions.CurrentToken); + options.PagingOptions.NextPageToken = result.nextToken; + + return new(result.items, options); + } + + [AllowStaticCommand] + public static async Task> LoadIssues2(GridViewDataSetOptions options) + { + var gitHubService = new GitHubService(); + + var result = await gitHubService.GetGitHubIssues(options.PagingOptions.GetCurrentPageToken()); + options.PagingOptions.SaveNextPageToken(result.nextToken); + + return new(result.items, options); + } + } +} + diff --git a/src/Samples/Common/ViewModels/FeatureSamples/DataSet/GitHubApiViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/DataSet/GitHubApiViewModel.cs new file mode 100644 index 0000000000..df83342f4a --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/DataSet/GitHubApiViewModel.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Framework.Controls; +using DotVVM.Framework.ViewModel; +using DotVVM.Framework.Hosting; +using DotVVM.Samples.Common.Services; + +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.DataSet +{ + public class GitHubApiViewModel : DotvvmViewModelBase + { + public GenericGridViewDataSet Issues { get; set; } = new(new(), new(), new(), new(), new()); + public GenericGridViewDataSet Issues2 { get; set; } = new(new(), new(), new(), new(), new()); + + + public override async Task PreRender() + { + var gitHubService = new GitHubService(); + + if (Issues.IsRefreshRequired) + { + var result = await gitHubService.GetGitHubIssues(Issues.PagingOptions.CurrentToken); + Issues.Items = result.items; + Issues.PagingOptions.NextPageToken = result.nextToken; + } + + if (Issues2.IsRefreshRequired) + { + var result = await gitHubService.GetGitHubIssues(Issues2.PagingOptions.GetCurrentPageToken()); + Issues2.Items = result.items; + Issues2.PagingOptions.SaveNextPageToken(result.nextToken); + } + + await base.PreRender(); + } + } + +} + diff --git a/src/Samples/Common/Views/ControlSamples/AppendableDataPager/AppendableDataPager.dothtml b/src/Samples/Common/Views/ControlSamples/AppendableDataPager/AppendableDataPager.dothtml new file mode 100644 index 0000000000..862c3d7711 --- /dev/null +++ b/src/Samples/Common/Views/ControlSamples/AppendableDataPager/AppendableDataPager.dothtml @@ -0,0 +1,37 @@ +@viewModel DotVVM.Samples.Common.ViewModels.ControlSamples.AppendableDataPager.AppendableDataPagerViewModel, DotVVM.Samples.Common + + + + + + + + + + + + + + + + + + + + + + + + + Your data are on the way... + + + You reached to the end of the Earth. Now you shall see the 🐢🐢🐢🐢 and 🐘. + + + + + + + diff --git a/src/Samples/Common/Views/ControlSamples/AppendableDataPager/AppendableDataPagerAutoLoad.dothtml b/src/Samples/Common/Views/ControlSamples/AppendableDataPager/AppendableDataPagerAutoLoad.dothtml new file mode 100644 index 0000000000..60404006ff --- /dev/null +++ b/src/Samples/Common/Views/ControlSamples/AppendableDataPager/AppendableDataPagerAutoLoad.dothtml @@ -0,0 +1,46 @@ +@viewModel DotVVM.Samples.Common.ViewModels.ControlSamples.AppendableDataPager.AppendableDataPagerViewModel, DotVVM.Samples.Common + + + + + + + + + + + +
+

Customer {{value: CustomerId}}

+

Name: {{value: Name}}

+

Birth date: {{value: BirthDate.ToString("d")}}

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed et enim tristique, facilisis risus ut, mattis arcu. Pellentesque vitae egestas est, vitae placerat arcu. Aenean quis ipsum lacinia, sollicitudin ex nec, maximus mauris. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nulla imperdiet semper risus, at varius sem tristique vel. Vivamus fringilla euismod nulla. Aliquam feugiat accumsan aliquam. Vivamus et dolor ac augue congue vestibulum. Pellentesque gravida dignissim nibh, at rhoncus leo euismod sit amet. Donec consectetur, turpis sed mattis luctus, nisi diam laoreet libero, eu tincidunt dolor justo quis sem. Nunc pulvinar eget sem ac tincidunt. Sed mattis enim non euismod feugiat. Nullam sed ipsum faucibus, finibus elit ac, eleifend nibh. +

+

+ Phasellus vel tortor ac diam volutpat lacinia. Nulla maximus, mauris non hendrerit pulvinar, ante ex suscipit orci, et aliquam lorem ipsum at tortor. Nulla tempus ante accumsan libero consequat, ac pellentesque massa malesuada. In convallis tortor ac purus tristique luctus. Maecenas id purus eget magna consequat porta a id sapien. Proin rhoncus eros efficitur consectetur feugiat. Praesent bibendum lorem et eros tempus, elementum sagittis tortor commodo. Proin elementum mi leo, sed mollis mauris luctus a. Etiam a arcu sit amet sem dignissim bibendum vel et velit. Cras pulvinar bibendum venenatis. Vivamus quis urna lacus. Proin scelerisque tempus erat quis dictum. Integer posuere facilisis sem id condimentum. Aliquam tortor lorem, finibus rhoncus consectetur porttitor, molestie a odio. Quisque aliquam commodo neque, ut aliquet quam vestibulum nec. +

+

+ Nullam vel neque nisi. Ut mattis arcu interdum sodales commodo. Curabitur convallis, lectus nec vehicula placerat, ipsum tellus interdum lectus, ut pellentesque orci lorem sed elit. Praesent mi enim, dictum sit amet eros quis, aliquam sodales lorem. Integer sagittis orci eget mollis sodales. Nunc vel euismod libero. Aenean consectetur vel lorem in dapibus. Nam volutpat aliquet urna et pretium. +

+

+ Aliquam dolor dui, tincidunt at sem eget, placerat scelerisque neque. Phasellus eros risus, luctus a libero nec, blandit iaculis magna. Aenean turpis justo, venenatis vel tortor vel, placerat cursus massa. Sed malesuada metus sit amet ante aliquet rhoncus. Nullam ut sodales velit. Donec accumsan tempor magna, eu mattis lectus suscipit vel. Nam est nunc, porta ac egestas et, commodo a dolor. Quisque vel ante non tellus pellentesque laoreet et non massa. Pellentesque tincidunt facilisis neque eu porttitor. Aliquam interdum maximus orci. Vivamus faucibus tincidunt mi ac vehicula. In nec dolor magna. Nam nec ligula enim. Morbi aliquam accumsan lacus a ornare. +

+

+ Sed at gravida purus. Morbi quis porttitor diam. Donec imperdiet mi et dolor faucibus, nec placerat tellus placerat. Donec mollis magna a tortor elementum, vitae ultrices mauris pellentesque. Maecenas consequat interdum risus in pulvinar. Cras metus ligula, pharetra eget tincidunt sed, commodo eu mi. Morbi mattis odio molestie, faucibus arcu eget, faucibus ante. Nullam maximus ultrices lorem nec fermentum. Suspendisse laoreet venenatis velit a consectetur. In consectetur ligula leo, at suscipit turpis posuere tristique. Integer vel porta odio. Phasellus efficitur sed felis ut molestie. Vivamus finibus nisl at turpis iaculis, nec molestie nisi vestibulum. Sed vel turpis eleifend, egestas eros varius, congue enim. +

+
+
+ + + + Your data are on the way... + + + You reached to the end of the Earth. Now you shall see the 🐢🐢🐢🐢 and 🐘. + + + + + diff --git a/src/Samples/Common/Views/ControlSamples/GridView/GridViewServerRender.dothtml b/src/Samples/Common/Views/ControlSamples/GridView/GridViewServerRender.dothtml index c2c3569821..6286b0bb69 100644 --- a/src/Samples/Common/Views/ControlSamples/GridView/GridViewServerRender.dothtml +++ b/src/Samples/Common/Views/ControlSamples/GridView/GridViewServerRender.dothtml @@ -19,12 +19,13 @@ - + Birth date - - + + + @@ -51,13 +52,25 @@

GridView with null DataSource

- - - + + + - - +

 

+

 

+

 

+ + +

GridView with null GridViewDataSet

+ + + + + + + +

EmptyData with data source

This is not displayed because data is not empty @@ -67,16 +80,16 @@ This is displayed because data is empty -

GridView with empty dataset and ShowHeaderWhenNoData true

- - - - - - - - - +

GridView with empty dataset and ShowHeaderWhenNoData true

+ + + + + + + + + @@ -84,4 +97,4 @@ Rename the `Name` string in Grid's headers:

- \ No newline at end of file + diff --git a/src/Samples/Common/Views/ControlSamples/GridView/GridViewStaticCommand.dothtml b/src/Samples/Common/Views/ControlSamples/GridView/GridViewStaticCommand.dothtml index c59d0bdd3c..95da72b52b 100644 --- a/src/Samples/Common/Views/ControlSamples/GridView/GridViewStaticCommand.dothtml +++ b/src/Samples/Common/Views/ControlSamples/GridView/GridViewStaticCommand.dothtml @@ -5,26 +5,75 @@ +
-

GridView with IGridViewDataSet

- +

Standard data set

+ - - - - - - + + + + +

NextToken paging options

+ + + + + + + + + - +

NextTokenHistory data set

+ + + + + + + + + + +

MultiSort data set

+ + + + + + + - + + +
  • {{value: SortExpression}} {{value: SortDescending ? "DESC" : "ASC"}}
  • +
    - \ No newline at end of file + diff --git a/src/Samples/Common/Views/FeatureSamples/DataSet/GitHubApi.dothtml b/src/Samples/Common/Views/FeatureSamples/DataSet/GitHubApi.dothtml new file mode 100644 index 0000000000..1b2bccc7bb --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/DataSet/GitHubApi.dothtml @@ -0,0 +1,41 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.DataSet.GitHubApiViewModel, DotVVM.Samples.Common + + + + + + + + + + +

    GitHub Issue Browser

    + +
    +
    +

    NextToken

    + + + + + + + +
    + +
    +

    NextTokenHistory

    + + + + + + + +
    +
    + + + + + diff --git a/src/Samples/Common/Views/FeatureSamples/DataSet/GitHubApiStaticCommands.dothtml b/src/Samples/Common/Views/FeatureSamples/DataSet/GitHubApiStaticCommands.dothtml new file mode 100644 index 0000000000..58c9d85499 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/DataSet/GitHubApiStaticCommands.dothtml @@ -0,0 +1,44 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.DataSet.GitHubApiStaticCommandsViewModel, DotVVM.Samples.Common + + + + + + + + + + +

    GitHub Issue Browser

    + +
    +
    +

    NextToken

    + + + + + + + +
    + +
    +

    NextTokenHistory

    + + + + + + + +
    +
    + + + + diff --git a/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj b/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj index 1b85ac7325..7a1472bcfa 100644 --- a/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj +++ b/src/Samples/Owin/DotVVM.Samples.BasicSamples.Owin.csproj @@ -39,5 +39,6 @@ +
    diff --git a/src/Samples/Owin/Properties/launchSettings.json b/src/Samples/Owin/Properties/launchSettings.json index 00ab196b05..5083b9d1b0 100644 --- a/src/Samples/Owin/Properties/launchSettings.json +++ b/src/Samples/Owin/Properties/launchSettings.json @@ -3,8 +3,8 @@ "DotVVM.Samples.BasicSamples.Owin": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "http://localhost:5407", - "applicationUrl": "http://localhost:5407", + "launchUrl": "http://localhost:65481/", + "applicationUrl": "http://localhost:65481/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/Samples/Owin/Web.config b/src/Samples/Owin/Web.config index b5f4f6e328..e7ffd97b9e 100644 --- a/src/Samples/Owin/Web.config +++ b/src/Samples/Owin/Web.config @@ -25,13 +25,13 @@ - + - + @@ -49,9 +49,21 @@ - + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs index 6b287d9ae9..19e8632046 100644 --- a/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs +++ b/src/Samples/Tests/Abstractions/SamplesRouteUrls.designer.cs @@ -41,6 +41,8 @@ public partial class SamplesRouteUrls public const string ComplexSamples_TaskList_TaskListAsyncCommands = "ComplexSamples/TaskList/TaskListAsyncCommands"; public const string ComplexSamples_ViewModelDependencyInjection_Sample = "ComplexSamples/ViewModelDependencyInjection/Sample"; public const string ComplexSamples_ViewModelPopulate_ViewModelPopulate = "ComplexSamples/ViewModelPopulate/ViewModelPopulate"; + public const string ControlSamples_AppendableDataPager_AppendableDataPager = "ControlSamples/AppendableDataPager/AppendableDataPager"; + public const string ControlSamples_AppendableDataPager_AppendableDataPagerAutoLoad = "ControlSamples/AppendableDataPager/AppendableDataPagerAutoLoad"; public const string ControlSamples_AuthenticatedView_AuthenticatedViewTest = "ControlSamples/AuthenticatedView/AuthenticatedViewTest"; public const string ControlSamples_Button_Button = "ControlSamples/Button/Button"; public const string ControlSamples_Button_ButtonEnabled = "ControlSamples/Button/ButtonEnabled"; @@ -244,6 +246,8 @@ public partial class SamplesRouteUrls public const string FeatureSamples_CustomPrimitiveTypes_TextBox = "FeatureSamples/CustomPrimitiveTypes/TextBox"; public const string FeatureSamples_CustomPrimitiveTypes_UsedInControls = "FeatureSamples/CustomPrimitiveTypes/UsedInControls"; public const string FeatureSamples_CustomResponseProperties_SimpleExceptionFilter = "FeatureSamples/CustomResponseProperties/SimpleExceptionFilter"; + public const string FeatureSamples_DataSet_GitHubApi = "FeatureSamples/DataSet/GitHubApi"; + public const string FeatureSamples_DataSet_GitHubApiStaticCommands = "FeatureSamples/DataSet/GitHubApiStaticCommands"; public const string FeatureSamples_DateTimeSerialization_DateTimeSerialization = "FeatureSamples/DateTimeSerialization/DateTimeSerialization"; public const string FeatureSamples_DependencyInjection_ViewModelScopedService = "FeatureSamples/DependencyInjection/ViewModelScopedService"; public const string FeatureSamples_Directives_ImportDirective = "FeatureSamples/Directives/ImportDirective"; diff --git a/src/Samples/Tests/Tests/Control/AppendableDataPagerTests.cs b/src/Samples/Tests/Tests/Control/AppendableDataPagerTests.cs new file mode 100644 index 0000000000..c40f5c3ce0 --- /dev/null +++ b/src/Samples/Tests/Tests/Control/AppendableDataPagerTests.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Samples.Tests.Base; +using DotVVM.Testing.Abstractions; +using Riganti.Selenium.Core; +using Xunit; +using Xunit.Abstractions; + +namespace DotVVM.Samples.Tests.Control +{ + public class AppendableDataPagerTests : AppSeleniumTest + { + public AppendableDataPagerTests(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Control_AppendableDataPager_AppendableDataPager() + { + RunInAllBrowsers(browser => + { + browser.NavigateToUrl(SamplesRouteUrls.ControlSamples_AppendableDataPager_AppendableDataPager); + + var table = browser.Single("table"); + table.FindElements("tbody tr").ThrowIfDifferentCountThan(3); + + // load more data + for (var i = 1; i < 4; i++) + { + browser.Single("input[type=button]").Click(); + browser.WaitFor(() => { + browser.FindElements(".loading").ThrowIfDifferentCountThan(1, WaitForOptions.Disabled); + }, 2000); + table.FindElements("tbody tr").ThrowIfDifferentCountThan((i + 1) * 3); + } + + // check we are at the end + browser.FindElements("input[type=button]").ThrowIfDifferentCountThan(0); + browser.FindElements(".loaded").ThrowIfDifferentCountThan(1); + }); + } + + [Fact] + public void Control_AppendableDataPager_AppendableDataPagerAutoLoad() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.ControlSamples_AppendableDataPager_AppendableDataPagerAutoLoad); + + browser.FindElements(".customer h1").ThrowIfDifferentCountThan(3); + browser.Wait(1000); + + // load more data by scrolling to the bottom + for (var i = 1; i < 4; i++) + { + browser.GetJavaScriptExecutor() + .ExecuteScript("window.scrollTo(0, document.body.scrollHeight - 10)"); + browser.WaitFor(() => { + browser.FindElements(".loading").ThrowIfDifferentCountThan(1, WaitForOptions.Disabled); + }, 2000); + browser.FindElements(".customer h1").ThrowIfDifferentCountThan((i + 1) * 3); + } + + // check we are at the end + browser.FindElements(".loaded").ThrowIfDifferentCountThan(1); + }); + } + } +} diff --git a/src/Samples/Tests/Tests/Control/DataPagerTests.cs b/src/Samples/Tests/Tests/Control/DataPagerTests.cs index a03ebe029e..aadf996cbb 100644 --- a/src/Samples/Tests/Tests/Control/DataPagerTests.cs +++ b/src/Samples/Tests/Tests/Control/DataPagerTests.cs @@ -1,4 +1,4 @@ - + using System.Collections.Generic; using System.Linq; using DotVVM.Samples.Tests.Base; @@ -53,21 +53,23 @@ public void Control_DataPager_DataPager_ActiveCssClass() RunInAllBrowsers(browser => { browser.NavigateToUrl(SamplesRouteUrls.ControlSamples_DataPager_DataPager); - // the first li should be visible because it contains text, the second with the link should be hidden var pageIndex1 = browser.First("#pager1").ElementAt("li", 2); AssertUI.NotContainsElement(pageIndex1, "a"); AssertUI.HasClass(pageIndex1, "active"); AssertUI.IsDisplayed(pageIndex1); + AssertUI.TextEquals(pageIndex1, "1"); var pageIndex2 = browser.First("#pager1").ElementAt("li", 3); AssertUI.ContainsElement(pageIndex2, "a"); - AssertUI.IsNotDisplayed(pageIndex2); + AssertUI.HasNotClass(pageIndex2, "active"); + AssertUI.TextEquals(pageIndex2, "»"); // the first li should note be there because only hyperlinks are rendered var pageIndex3 = browser.First("#pager3").ElementAt("li", 2); AssertUI.ContainsElement(pageIndex3, "a"); AssertUI.HasClass(pageIndex3, "active"); AssertUI.IsDisplayed(pageIndex3); + AssertUI.TextEquals(pageIndex3, "1"); }); } @@ -78,14 +80,20 @@ public void Control_DataPager_DataPager_DisabledAttribute() RunInAllBrowsers(browser => { browser.NavigateToUrl(SamplesRouteUrls.ControlSamples_DataPager_DataPager); + browser.Single("populate-button", this.SelectByDataUi).Click(); + // the first ul should not be disabled - AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 0), "disabled"); - AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 1), "disabled"); - AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 2), "disabled"); - AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 3), "disabled"); - AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 4), "disabled"); + AssertUI.HasNotClass(browser.Single("#pager1"), "disabled"); + AssertUI.HasAttribute(browser.Single("#pager1").ElementAt("li a", 0), "disabled"); // first + AssertUI.HasAttribute(browser.Single("#pager1").ElementAt("li a", 1), "disabled"); // prev + AssertUI.NotContainsElement(browser.Single("#pager1").ElementAt("li", 2), "a"); // 1 (current) + AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 2), "disabled"); // 2 + AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 3), "disabled"); // 3 + AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 4), "disabled"); // 4 + AssertUI.HasNotAttribute(browser.Single("#pager1").Last("li a"), "disabled"); // last page // the forth ul should be disabled + AssertUI.HasClass(browser.Single("#pager4"), "disabled"); AssertUI.HasAttribute(browser.Single("#pager4").ElementAt("li a", 0), "disabled"); AssertUI.HasAttribute(browser.Single("#pager4").ElementAt("li a", 1), "disabled"); AssertUI.HasAttribute(browser.Single("#pager4").ElementAt("li a", 2), "disabled"); @@ -94,6 +102,7 @@ public void Control_DataPager_DataPager_DisabledAttribute() // verify element is disabled after click browser.Single("#enableCheckbox input[type=checkbox]").Click(); + AssertUI.HasClass(browser.Single("#pager1"), "disabled"); AssertUI.HasAttribute(browser.Single("#pager1").ElementAt("li a", 0), "disabled"); AssertUI.HasAttribute(browser.Single("#pager1").ElementAt("li a", 1), "disabled"); AssertUI.HasAttribute(browser.Single("#pager1").ElementAt("li a", 2), "disabled"); @@ -102,11 +111,20 @@ public void Control_DataPager_DataPager_DisabledAttribute() // verify element is not disabled after another click browser.Single("#enableCheckbox input[type=checkbox]").Click(); - AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 0), "disabled"); - AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 1), "disabled"); + AssertUI.HasNotClass(browser.Single("#pager1"), "disabled"); + AssertUI.HasAttribute(browser.Single("#pager1").ElementAt("li a", 0), "disabled"); + AssertUI.HasAttribute(browser.Single("#pager1").ElementAt("li a", 1), "disabled"); AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 2), "disabled"); AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 3), "disabled"); AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 4), "disabled"); + + // go to page 2 + browser.Single("#pager1").ElementAt("li a", 2).Click(); + AssertUI.TextEquals(browser.Single("#pager1").Single("li.active"), "2"); + AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 0), "disabled"); + AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 1), "disabled"); + AssertUI.HasNotAttribute(browser.Single("#pager1").ElementAt("li a", 2), "disabled"); + AssertUI.HasNotAttribute(browser.Single("#pager1").Last("li a"), "disabled"); }); } diff --git a/src/Samples/Tests/Tests/Control/GridViewTests.cs b/src/Samples/Tests/Tests/Control/GridViewTests.cs index d7a367d2b7..9f694f2285 100644 --- a/src/Samples/Tests/Tests/Control/GridViewTests.cs +++ b/src/Samples/Tests/Tests/Control/GridViewTests.cs @@ -1,6 +1,8 @@ using System.Linq; +using System.Text.RegularExpressions; using DotVVM.Samples.Tests.Base; using DotVVM.Testing.Abstractions; +using OpenQA.Selenium; using Riganti.Selenium.Core; using Riganti.Selenium.Core.Abstractions; using Riganti.Selenium.DotVVM; @@ -72,22 +74,225 @@ public void Control_GridView_GridViewInlineEditingValidation() } [Fact] - public void Control_GridView_GridViewStaticCommand() + public void Control_GridView_GridViewStaticCommand_Standard() { RunInAllBrowsers(browser => { browser.NavigateToUrl(SamplesRouteUrls.ControlSamples_GridView_GridViewStaticCommand); - //check rows - browser.FindElements("table tbody tr").ThrowIfDifferentCountThan(5); - //check first row Id - AssertUI.InnerTextEquals(browser.First("table tbody tr td span"), "1"); - //cal static command for delete row - browser.First("table tbody tr input[type=button]").Click(); - browser.WaitForPostback(); - //check rows again - browser.FindElements("table tbody tr").ThrowIfDifferentCountThan(4); - //check first row Id - AssertUI.InnerTextEquals(browser.First("table tbody tr td span"), "2"); + // Standard + var grid = browser.Single("standard-grid", SelectByDataUi); + var pager = browser.Single("standard-pager", SelectByDataUi); + + // verify initial data + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(10); + AssertUI.TextEquals(grid.First("tbody tr td"), "1"); + + // sort by name + grid.ElementAt("thead th", 1).Single("a").Click(); + AssertUI.TextEquals(grid.First("tbody tr td"), "9"); + + // sort by birth date + grid.ElementAt("thead th", 2).Single("a").Click(); + AssertUI.TextEquals(grid.First("tbody tr td"), "4"); + + // click on page 2 + pager.ElementAt("li", 3).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(2); + AssertUI.TextEquals(grid.First("tbody tr td"), "11"); + + // click on page 1 + pager.ElementAt("li", 2).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(10); + AssertUI.TextEquals(grid.First("tbody tr td"), "4"); + + // click on next button + pager.ElementAt("li", 4).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(2); + AssertUI.TextEquals(grid.First("tbody tr td"), "11"); + + // click on previous page + pager.ElementAt("li", 1).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(10); + AssertUI.TextEquals(grid.First("tbody tr td"), "4"); + + // click on last button + pager.ElementAt("li", 5).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(2); + AssertUI.TextEquals(grid.First("tbody tr td"), "11"); + + // click on first page + pager.ElementAt("li", 0).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(10); + AssertUI.TextEquals(grid.First("tbody tr td"), "4"); + }); + } + + [Fact] + public void Control_GridView_GridViewStaticCommand_Next() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.ControlSamples_GridView_GridViewStaticCommand); + + // NextToken + var grid = browser.Single("next-grid", SelectByDataUi); + var pager = browser.Single("next-pager", SelectByDataUi); + + // verify initial data + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(3); + AssertUI.TextEquals(grid.First("tbody tr td"), "1"); + AssertUI.HasAttribute(pager.ElementAt("li", 0).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 1).Single("a"), "disabled"); + + // click on next button + pager.ElementAt("li", 1).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(3); + AssertUI.TextEquals(grid.First("tbody tr td"), "4"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 0).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 1).Single("a"), "disabled"); + + // click on first page + pager.ElementAt("li", 0).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(3); + AssertUI.TextEquals(grid.First("tbody tr td"), "1"); + AssertUI.HasAttribute(pager.ElementAt("li", 0).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 1).Single("a"), "disabled"); + + // click on next button + pager.ElementAt("li", 1).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(3); + AssertUI.TextEquals(grid.First("tbody tr td"), "4"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 0), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 1), "disabled"); + + // click on next button + pager.ElementAt("li", 1).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(3); + AssertUI.TextEquals(grid.First("tbody tr td"), "7"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 0).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 1).Single("a"), "disabled"); + + // click on next button + pager.ElementAt("li", 1).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(3); + AssertUI.TextEquals(grid.First("tbody tr td"), "10"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 0).Single("a"), "disabled"); + AssertUI.HasAttribute(pager.ElementAt("li", 1).Single("a"), "disabled"); + }); + } + + [Fact] + public void Control_GridView_GridViewStaticCommand_NextHistory() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.ControlSamples_GridView_GridViewStaticCommand); + + // NextTokenHistory + var grid = browser.Single("next-history-grid", SelectByDataUi); + var pager = browser.Single("next-history-pager", SelectByDataUi); + + // verify initial data + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(3); + pager.FindElements("li").ThrowIfDifferentCountThan(5); + AssertUI.TextEquals(grid.First("tbody tr td"), "1"); + AssertUI.HasAttribute(pager.ElementAt("li", 0).Single("a"), "disabled"); + AssertUI.HasAttribute(pager.ElementAt("li", 1).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 4).Single("a"), "disabled"); + + // click on next button + pager.ElementAt("li", 4).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(3); + pager.FindElements("li").ThrowIfDifferentCountThan(6); + AssertUI.TextEquals(grid.First("tbody tr td"), "4"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 0).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 1).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 5).Single("a"), "disabled"); + + // click on first page + pager.ElementAt("li", 0).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(3); + pager.FindElements("li").ThrowIfDifferentCountThan(6); + AssertUI.TextEquals(grid.First("tbody tr td"), "1"); + AssertUI.HasAttribute(pager.ElementAt("li", 0).Single("a"), "disabled"); + AssertUI.HasAttribute(pager.ElementAt("li", 1).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 5).Single("a"), "disabled"); + + // click on page 2 + pager.ElementAt("li", 3).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(3); + pager.FindElements("li").ThrowIfDifferentCountThan(6); + AssertUI.TextEquals(grid.First("tbody tr td"), "4"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 0).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 1).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 5).Single("a"), "disabled"); + + // click on page 3 + pager.ElementAt("li", 4).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(3); + pager.FindElements("li").ThrowIfDifferentCountThan(7); + AssertUI.TextEquals(grid.First("tbody tr td"), "7"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 0).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 1).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 6).Single("a"), "disabled"); + + // click on previous button + pager.ElementAt("li", 1).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(3); + pager.FindElements("li").ThrowIfDifferentCountThan(7); + AssertUI.TextEquals(grid.First("tbody tr td"), "4"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 0).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 1).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 6).Single("a"), "disabled"); + + // click on page 4 + pager.ElementAt("li", 5).Single("a").Click(); + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(3); + pager.FindElements("li").ThrowIfDifferentCountThan(7); + AssertUI.TextEquals(grid.First("tbody tr td"), "10"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 0).Single("a"), "disabled"); + AssertUI.HasNotAttribute(pager.ElementAt("li", 1).Single("a"), "disabled"); + AssertUI.HasAttribute(pager.ElementAt("li", 6).Single("a"), "disabled"); + }); + } + + [Fact] + public void Control_GridView_GridViewStaticCommand_MultiSort() + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(SamplesRouteUrls.ControlSamples_GridView_GridViewStaticCommand); + + // MultiSort + var grid = browser.Single("multi-sort-grid", SelectByDataUi); + var criteria = browser.Single("multi-sort-criteria", SelectByDataUi); + + // verify initial data + grid.FindElements("tbody tr").ThrowIfDifferentCountThan(12); + AssertUI.TextEquals(grid.First("tbody tr td"), "1"); + AssertUI.TextEquals(criteria, ""); + + // sort by name + grid.ElementAt("thead th", 1).Single("a").Click(); + AssertUI.TextEquals(grid.First("tbody tr td"), "9"); + AssertUI.TextEquals(criteria, "Name ASC"); + + // add sort by message received + grid.ElementAt("thead th", 3).Single("a").Click(); + AssertUI.TextEquals(grid.First("tbody tr td"), "6"); + AssertUI.Text(criteria, t => Regex.Replace(t, "\\s+", " ") == "MessageReceived ASC Name ASC"); + + // reverse sort by message received + grid.ElementAt("thead th", 3).Single("a").Click(); + AssertUI.TextEquals(grid.First("tbody tr td"), "9"); + AssertUI.Text(criteria, t => Regex.Replace(t, "\\s+", " ") == "MessageReceived DESC Name ASC"); + + // add sort by birth date + grid.ElementAt("thead th", 2).Single("a").Click(); + AssertUI.TextEquals(grid.First("tbody tr td"), "4"); + AssertUI.Text(criteria, t => Regex.Replace(t, "\\s+", " ") == "BirthDate ASC MessageReceived DESC Name ASC"); + + // add sort by customer id + grid.ElementAt("thead th", 0).Single("a").Click(); + AssertUI.TextEquals(grid.First("tbody tr td"), "1"); + AssertUI.Text(criteria, t => Regex.Replace(t, "\\s+", " ") == "CustomerId ASC BirthDate ASC MessageReceived DESC"); }); } diff --git a/src/Samples/Tests/Tests/Feature/DataSetTests.cs b/src/Samples/Tests/Tests/Feature/DataSetTests.cs new file mode 100644 index 0000000000..9273030ad0 --- /dev/null +++ b/src/Samples/Tests/Tests/Feature/DataSetTests.cs @@ -0,0 +1,115 @@ +using System.Linq; +using DotVVM.Samples.Tests.Base; +using DotVVM.Testing.Abstractions; +using OpenQA.Selenium; +using Riganti.Selenium.Core; +using Riganti.Selenium.Core.Abstractions; +using Xunit; +using Xunit.Abstractions; + +namespace DotVVM.Samples.Tests.Feature +{ + public class DataSetTests : AppSeleniumTest + { + public DataSetTests(ITestOutputHelper output) : base(output) + { + } + + [Theory] + [InlineData(SamplesRouteUrls.FeatureSamples_DataSet_GitHubApi)] + [InlineData(SamplesRouteUrls.FeatureSamples_DataSet_GitHubApiStaticCommands)] + public void Feature_DataSet_GitHubApi_Next(string url) + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(url); + VerifyPageIsNotThrowingAuthError(browser); + + var grid = browser.Single("next-grid", SelectByDataUi); + var pager = browser.Single("next-pager", SelectByDataUi); + + // get first issue on the first page + var issueId = grid.ElementAt("tbody tr td", 0).GetInnerText(); + + // go next + pager.ElementAt("li", 1).Single("a").Click().Wait(1000); + + grid = browser.Single("next-grid", SelectByDataUi); + pager = browser.Single("next-pager", SelectByDataUi); + AssertUI.TextNotEquals(grid.ElementAt("tbody tr td", 0), issueId); + + // go to first page + pager.ElementAt("li", 0).Single("a").Click().Wait(1000); + + grid = browser.Single("next-grid", SelectByDataUi); + pager = browser.Single("next-pager", SelectByDataUi); + AssertUI.TextEquals(grid.ElementAt("tbody tr td", 0), issueId); + }); + } + + [Theory] + [InlineData(SamplesRouteUrls.FeatureSamples_DataSet_GitHubApi)] + [InlineData(SamplesRouteUrls.FeatureSamples_DataSet_GitHubApiStaticCommands)] + public void Feature_DataSet_GitHubApi_NextHistory(string url) + { + RunInAllBrowsers(browser => { + browser.NavigateToUrl(url); + VerifyPageIsNotThrowingAuthError(browser); + + var grid = browser.Single("next-history-grid", SelectByDataUi); + var pager = browser.Single("next-history-pager", SelectByDataUi); + + // get first issue on the first page + var issueId1 = grid.ElementAt("tbody tr td", 0).GetInnerText(); + + // go to page 2 + pager.ElementAt("li", 3).Single("a").Click().Wait(500); + + grid = browser.Single("next-history-grid", SelectByDataUi); + pager = browser.Single("next-history-pager", SelectByDataUi); + AssertUI.TextNotEquals(grid.ElementAt("tbody tr td", 0), issueId1); + var issueId2 = grid.ElementAt("tbody tr td", 0).GetInnerText(); + + // go to next page + pager.ElementAt("li", 5).Single("a").Click().Wait(500); + + grid = browser.Single("next-history-grid", SelectByDataUi); + pager = browser.Single("next-history-pager", SelectByDataUi); + AssertUI.TextNotEquals(grid.ElementAt("tbody tr td", 0), issueId1); + AssertUI.TextNotEquals(grid.ElementAt("tbody tr td", 0), issueId2); + var issueId3 = grid.ElementAt("tbody tr td", 0).GetInnerText(); + + // go to first page + pager.ElementAt("li", 0).Single("a").Click().Wait(500); + + grid = browser.Single("next-history-grid", SelectByDataUi); + pager = browser.Single("next-history-pager", SelectByDataUi); + AssertUI.TextEquals(grid.ElementAt("tbody tr td", 0), issueId1); + + // go to page 4 + pager.ElementAt("li", 5).Single("a").Click().Wait(500); + + grid = browser.Single("next-history-grid", SelectByDataUi); + pager = browser.Single("next-history-pager", SelectByDataUi); + AssertUI.TextNotEquals(grid.ElementAt("tbody tr td", 0), issueId1); + AssertUI.TextNotEquals(grid.ElementAt("tbody tr td", 0), issueId2); + AssertUI.TextNotEquals(grid.ElementAt("tbody tr td", 0), issueId3); + + // go to previous page + pager.ElementAt("li", 1).Single("a").Click().Wait(500); + + grid = browser.Single("next-history-grid", SelectByDataUi); + pager = browser.Single("next-history-pager", SelectByDataUi); + AssertUI.TextEquals(grid.ElementAt("tbody tr td", 0), issueId3); + }); + } + private void VerifyPageIsNotThrowingAuthError(IBrowserWrapper browser) + { + var elms = browser.FindElements(".exceptionMessage", By.CssSelector); + var errorMessage = elms.FirstOrDefault(); + + if (errorMessage is null) return; + AssertUI.Text(errorMessage, e => !e.Contains("401"), "GitHub Authentication Failed!", + waitForOptions: WaitForOptions.FromTimeout(1000)); + } + } +} diff --git a/src/Tests/ControlTests/DataPagerTests.cs b/src/Tests/ControlTests/DataPagerTests.cs new file mode 100644 index 0000000000..385f3a3a9a --- /dev/null +++ b/src/Tests/ControlTests/DataPagerTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Threading.Tasks; +using CheckTestOutput; +using DotVVM.Framework.Compilation; +using DotVVM.Framework.Controls; +using DotVVM.Framework.Tests.Binding; +using DotVVM.Framework.ViewModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using DotVVM.Framework.Testing; +using System.Security.Claims; +using System.Linq; +using DotVVM.Framework.Binding.Properties; +using DotVVM.Framework.Binding; +using FastExpressionCompiler; +using DotVVM.Framework.Binding.Expressions; +using System.Reflection.Metadata; + +namespace DotVVM.Framework.Tests.ControlTests +{ + [TestClass] + public class DataPagerTests + { + static readonly ControlTestHelper cth = new ControlTestHelper(config: config => { + }); + OutputChecker check = new OutputChecker("testoutputs"); + + [TestMethod] + public async Task CommandDataPager() + { + var r = await cth.RunPage(typeof(GridViewModel), """ + + """ + ); + + var commandExpressions = r.Commands + .Select(c => (c.control, c.command, str: c.command.GetProperty().Expression.ToCSharpString().Trim().TrimEnd(';'))) + .OrderBy(c => c.str) + .ToArray(); + check.CheckLines(commandExpressions.GroupBy(c => c.command).Select(c => c.First().str), checkName: "command-bindings", fileExtension: "txt"); + + check.CheckString(r.FormattedHtml, fileExtension: "html"); + + var nextPage = commandExpressions.Single(c => c.str.Contains(".GoToNextPage()")); + var prevPage = commandExpressions.Single(c => c.str.Contains(".GoToPreviousPage()")); + var firstPage = commandExpressions.Single(c => c.str.Contains(".GoToFirstPage()")); + var lastPage = commandExpressions.Single(c => c.str.Contains(".GoToLastPage()")); + + await r.RunCommand((CommandBindingExpression)nextPage.command, nextPage.control); + Assert.AreEqual(1, (int)r.ViewModel.Customers.PagingOptions.PageIndex); + } + + [TestMethod] + public async Task StaticCommandPager() + { + var r = await cth.RunPage(typeof(GridViewModel), """ + + """ + ); + check.CheckString(r.FormattedHtml, fileExtension: "html"); + } + + [TestMethod] + public async Task StaticCommandApendablePager() + { + var r = await cth.RunPage(typeof(GridViewModel), """ + + +
    + +
    +
    + end +
    + """ + ); + + check.CheckString(r.FormattedHtml, fileExtension: "html"); + } + + [TestMethod] + public async Task HideableDataPager() + { + var r = await cth.RunPage(typeof(GridViewModel), """ + + """ + ); + + XAssert.Equal("visible: Customers()?.PagingOptions()?.PagesCount() > 1 && BooleanProperty", r.Html.QuerySelector("ul").GetAttribute("data-bind")); + } + + public class GridViewModel: DotvvmViewModelBase + { + public GridViewDataSet Customers { get; set; } = new GridViewDataSet() + { + PagingOptions = new PagingOptions() + { + PageSize = 5 + }, + }; + + public bool BooleanProperty { get; set; } = true; + + public override async Task PreRender() + { + if (Customers.IsRefreshRequired) + { + Customers.LoadFromQueryable( + Enumerable.Range(0, 100).Select(i => new CustomerData() { Id = i, Name = "Name" + i }).AsQueryable() + ); + } + } + + public class CustomerData + { + public int Id { get; set; } + [Required] + public string Name { get; set; } + } + + [AllowStaticCommand] + public static GridViewDataSetResult LoadCustomers(GridViewDataSetOptions request) + { + throw new NotImplementedException(); + } + } + } +} diff --git a/src/Tests/ControlTests/GridViewTests.cs b/src/Tests/ControlTests/GridViewTests.cs index 219b029874..7f2b980ccd 100644 --- a/src/Tests/ControlTests/GridViewTests.cs +++ b/src/Tests/ControlTests/GridViewTests.cs @@ -154,6 +154,21 @@ vvv enabled customer vvv check.CheckString(r.FormattedHtml, fileExtension: "html"); } + + [TestMethod] + public async Task SortedChangedStaticCommand() + { + var r = await cth.RunPage(typeof(BasicTestViewModel), """ + _js.Invoke("resort", dir)}> + + + + + """, directives: "@js dotvvm.internal"); + + check.CheckString(r.FormattedHtml, fileExtension: "html"); + } + public class BasicTestViewModel: DotvvmViewModelBase { [Bind(Name = "int")] diff --git a/src/Tests/ControlTests/testoutputs/AutoUITests.BasicColumn.html b/src/Tests/ControlTests/testoutputs/AutoUITests.BasicColumn.html index 287823dd09..b418550161 100644 --- a/src/Tests/ControlTests/testoutputs/AutoUITests.BasicColumn.html +++ b/src/Tests/ControlTests/testoutputs/AutoUITests.BasicColumn.html @@ -3,7 +3,7 @@ - +
    diff --git a/src/Tests/ControlTests/testoutputs/AutoUITests.BasicGrid.html b/src/Tests/ControlTests/testoutputs/AutoUITests.BasicGrid.html index ab33b4c923..117a6f076c 100644 --- a/src/Tests/ControlTests/testoutputs/AutoUITests.BasicGrid.html +++ b/src/Tests/ControlTests/testoutputs/AutoUITests.BasicGrid.html @@ -3,7 +3,7 @@ - +
    diff --git a/src/Tests/ControlTests/testoutputs/DataPagerTests.CommandDataPager-command-bindings.txt b/src/Tests/ControlTests/testoutputs/DataPagerTests.CommandDataPager-command-bindings.txt new file mode 100644 index 0000000000..dd845c50d5 --- /dev/null +++ b/src/Tests/ControlTests/testoutputs/DataPagerTests.CommandDataPager-command-bindings.txt @@ -0,0 +1,10 @@ +((PagingOptions)_this.Customers.PagingOptions).GoToFirstPage(); +((IRefreshableGridViewDataSet)_this.Customers).RequestRefresh() +((PagingOptions)_this.Customers.PagingOptions).GoToLastPage(); +((IRefreshableGridViewDataSet)_this.Customers).RequestRefresh() +((PagingOptions)_this.Customers.PagingOptions).GoToNextPage(); +((IRefreshableGridViewDataSet)_this.Customers).RequestRefresh() +((PagingOptions)_this.Customers.PagingOptions).GoToPage(_thisIndex); +((IRefreshableGridViewDataSet)_this.Customers).RequestRefresh() +((PagingOptions)_this.Customers.PagingOptions).GoToPreviousPage(); +((IRefreshableGridViewDataSet)_this.Customers).RequestRefresh() diff --git a/src/Tests/ControlTests/testoutputs/DataPagerTests.CommandDataPager.html b/src/Tests/ControlTests/testoutputs/DataPagerTests.CommandDataPager.html new file mode 100644 index 0000000000..a061797212 --- /dev/null +++ b/src/Tests/ControlTests/testoutputs/DataPagerTests.CommandDataPager.html @@ -0,0 +1,29 @@ + + + +
      +
    • + «« +
    • +
    • + « +
    • + +
    • + + + + + + +
    • + +
    • + » +
    • +
    • + »» +
    • +
    + + diff --git a/src/Tests/ControlTests/testoutputs/DataPagerTests.StaticCommandApendablePager.html b/src/Tests/ControlTests/testoutputs/DataPagerTests.StaticCommandApendablePager.html new file mode 100644 index 0000000000..8a9290a44f --- /dev/null +++ b/src/Tests/ControlTests/testoutputs/DataPagerTests.StaticCommandApendablePager.html @@ -0,0 +1,25 @@ + + + +
    + + +
    + +
    + + + end + +
    + + diff --git a/src/Tests/ControlTests/testoutputs/DataPagerTests.StaticCommandPager.html b/src/Tests/ControlTests/testoutputs/DataPagerTests.StaticCommandPager.html new file mode 100644 index 0000000000..cc1446269e --- /dev/null +++ b/src/Tests/ControlTests/testoutputs/DataPagerTests.StaticCommandPager.html @@ -0,0 +1,44 @@ + + + +
      +
    • + «« +
    • +
    • + « +
    • + +
    • + + + + + + +
    • + +
    • + » +
    • +
    • + »» +
    • +
    + + diff --git a/src/Tests/ControlTests/testoutputs/GridViewTests.GridView_RowDecorators_AddChildren.html b/src/Tests/ControlTests/testoutputs/GridViewTests.GridView_RowDecorators_AddChildren.html index 75e9ff09ae..a31bca1b8a 100644 --- a/src/Tests/ControlTests/testoutputs/GridViewTests.GridView_RowDecorators_AddChildren.html +++ b/src/Tests/ControlTests/testoutputs/GridViewTests.GridView_RowDecorators_AddChildren.html @@ -3,7 +3,7 @@ - +
    diff --git a/src/Tests/ControlTests/testoutputs/GridViewTests.RequiredResourceInEditTemplate.html b/src/Tests/ControlTests/testoutputs/GridViewTests.RequiredResourceInEditTemplate.html index 0e56043937..d164cdd122 100644 --- a/src/Tests/ControlTests/testoutputs/GridViewTests.RequiredResourceInEditTemplate.html +++ b/src/Tests/ControlTests/testoutputs/GridViewTests.RequiredResourceInEditTemplate.html @@ -3,7 +3,7 @@ - +
    diff --git a/src/Tests/ControlTests/testoutputs/GridViewTests.SortedChangedStaticCommand.html b/src/Tests/ControlTests/testoutputs/GridViewTests.SortedChangedStaticCommand.html new file mode 100644 index 0000000000..92ac2cbd9a --- /dev/null +++ b/src/Tests/ControlTests/testoutputs/GridViewTests.SortedChangedStaticCommand.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + +
    + Name +
    + +
    + + + diff --git a/src/Tests/ControlTests/testoutputs/MarkupControlTests.MarkupControl_PassingStaticCommand.html b/src/Tests/ControlTests/testoutputs/MarkupControlTests.MarkupControl_PassingStaticCommand.html index 1f82d436d3..43851c8d8c 100644 --- a/src/Tests/ControlTests/testoutputs/MarkupControlTests.MarkupControl_PassingStaticCommand.html +++ b/src/Tests/ControlTests/testoutputs/MarkupControlTests.MarkupControl_PassingStaticCommand.html @@ -3,9 +3,11 @@ -
    (dotvvm.applyPostbackHandlers(async (options) => { await dotvvm.staticCommandPostback("WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuVGVzdFNlcnZpY2UsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMiLCJTYXZlIixbXSwxLG51bGwsIkFRQT0iXQ==", [$context.$parent.int.state], options); -},$element,[],args,$context)) }"> +},$element,[],args,$context)) +}"> -
    (dotvvm.applyPostbackHandlers(async (options) => { await dotvvm.staticCommandPostback("WARNING/NOT/ENCRYPTED+++WyJEb3RWVk0uRnJhbWV3b3JrLlRlc3RzLkJpbmRpbmcuVGVzdFNlcnZpY2UsIERvdFZWTS5GcmFtZXdvcmsuVGVzdHMiLCJTYXZlIixbXSwxLG51bGwsIkFRQT0iXQ==", [$context.$rawData.state], options); -},$element,[],args,$context)) }"> +},$element,[],args,$context)) +}"> +
    @@ -23,7 +23,7 @@ - +
    diff --git a/src/Tests/Runtime/DotvvmControlRenderedHtmlTests.cs b/src/Tests/Runtime/DotvvmControlRenderedHtmlTests.cs index 18fc676565..f23705aec7 100644 --- a/src/Tests/Runtime/DotvvmControlRenderedHtmlTests.cs +++ b/src/Tests/Runtime/DotvvmControlRenderedHtmlTests.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Threading.Tasks; +using CheckTestOutput; using DotVVM.Framework.Binding; using DotVVM.Framework.Binding.Expressions; using DotVVM.Framework.Compilation.ControlTree; @@ -18,10 +20,12 @@ namespace DotVVM.Framework.Tests.Runtime [TestClass] public class DotvvmControlRenderedHtmlTests : DotvvmControlTestBase { + static readonly BindingTestHelper bindingHelper = new BindingTestHelper(); + readonly OutputChecker outputChecker = new OutputChecker("testoutputs"); [TestMethod] public void GridViewTextColumn_RenderedHtmlTest_ServerRendering() { - var gridView = new GridView() { + var gridView = new GridView(new GridViewDataSetBindingProvider(BindingService), BindingService) { Columns = new List { new GridViewTextColumn() { HeaderCssClass = "lol", HeaderText="Header Text", ValueBinding = ValueBindingExpression.CreateBinding(BindingService, h => (object)h[0], (DataContextStack)null) } @@ -211,6 +215,22 @@ public void Literal_DateTimeToBrowserLocalTime_RenderOnServer() }); } + [TestMethod] + public void Button_ClickArgumentsCommand() + { + var vm = new LiteralDateTimeViewModel(); + var command = bindingHelper.Command("null", [ typeof(LiteralDateTimeViewModel) ], typeof(Func)); + var button = new Button("text", command) { + ClickArguments = new object[] { + bindingHelper.ValueBinding("DateTime", [ typeof(LiteralDateTimeViewModel) ]), + 1 + } + }; + + var html = InvokeLifecycleAndRender(button, CreateContext(vm)); + outputChecker.CheckString(html, "Button_ClickArgumentsCommand", fileExtension: "html"); + } + public class OrderedDataBindTextBox : TextBox { diff --git a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json index 4f5cde2cf5..ecc1ad6424 100644 --- a/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json +++ b/src/Tests/Runtime/config-tests/ConfigurationSerializationTests.SerializeDefaultConfig.json @@ -362,6 +362,49 @@ "mappingMode": "InnerElement" } }, + "DotVVM.Framework.Controls.AppendableDataPager": { + "DataSet": { + "type": "DotVVM.Framework.Controls.IPageableGridViewDataSet, DotVVM.Core", + "required": true, + "onlyBindings": true + }, + "EndTemplate": { + "type": "DotVVM.Framework.Controls.ITemplate, DotVVM.Framework", + "mappingMode": "InnerElement", + "onlyHardcoded": true + }, + "LoadData": { + "type": "DotVVM.Framework.Binding.Expressions.ICommandBinding, DotVVM.Framework", + "required": true, + "onlyBindings": true + }, + "LoadingTemplate": { + "type": "DotVVM.Framework.Controls.ITemplate, DotVVM.Framework", + "dataContextChange": [ + { + "$type": "DotVVM.Framework.Binding.HelperNamespace.DataPagerApi+AddParameterDataContextChangeAttribute, DotVVM.Framework", + "Name": "_dataPager", + "NestDataContext": false, + "TypeId": "DotVVM.Framework.Binding.HelperNamespace.DataPagerApi+AddParameterDataContextChangeAttribute, DotVVM.Framework" + } + ], + "mappingMode": "InnerElement", + "onlyHardcoded": true + }, + "LoadTemplate": { + "type": "DotVVM.Framework.Controls.ITemplate, DotVVM.Framework", + "dataContextChange": [ + { + "$type": "DotVVM.Framework.Binding.HelperNamespace.DataPagerApi+AddParameterDataContextChangeAttribute, DotVVM.Framework", + "Name": "_dataPager", + "NestDataContext": false, + "TypeId": "DotVVM.Framework.Binding.HelperNamespace.DataPagerApi+AddParameterDataContextChangeAttribute, DotVVM.Framework" + } + ], + "mappingMode": "InnerElement", + "onlyHardcoded": true + } + }, "DotVVM.Framework.Controls.AuthenticatedView": { "AuthenticatedTemplate": { "type": "DotVVM.Framework.Controls.ITemplate, DotVVM.Framework", @@ -389,6 +432,10 @@ "type": "DotVVM.Framework.Binding.Expressions.Command, DotVVM.Framework", "isCommand": true }, + "ClickArguments": { + "type": "System.Object[]", + "mappingMode": "Exclude" + }, "Enabled": { "type": "System.Boolean", "defaultValue": true @@ -517,7 +564,8 @@ }, "DotVVM.Framework.Controls.DataPager": { "DataSet": { - "type": "DotVVM.Framework.Controls.IPageableGridViewDataSet, DotVVM.Core", + "type": "DotVVM.Framework.Controls.IPageableGridViewDataSet`1[[DotVVM.Framework.Controls.IPagingOptions, DotVVM.Core, Version=***, Culture=neutral, PublicKeyToken=23f3607db32275da]], DotVVM.Core", + "required": true, "onlyBindings": true }, "Enabled": { @@ -539,6 +587,10 @@ "mappingMode": "InnerElement", "onlyHardcoded": true }, + "LoadData": { + "type": "DotVVM.Framework.Binding.Expressions.ICommandBinding, DotVVM.Framework", + "onlyBindings": true + }, "NextPageTemplate": { "type": "DotVVM.Framework.Controls.ITemplate, DotVVM.Framework", "mappingMode": "InnerElement", @@ -740,6 +792,10 @@ "defaultValue": false, "onlyHardcoded": true }, + "LoadData": { + "type": "DotVVM.Framework.Binding.Expressions.ICommandBinding, DotVVM.Framework", + "onlyBindings": true + }, "RowDecorators": { "type": "System.Collections.Generic.List`1[[DotVVM.Framework.Controls.Decorator, DotVVM.Framework, Version=***, Culture=neutral, PublicKeyToken=23f3607db32275da]]", "dataContextChange": [ @@ -2002,6 +2058,12 @@ "baseType": "DotVVM.Framework.Controls.Decorator, DotVVM.Framework", "withoutContent": true }, + "DotVVM.Framework.Controls.AppendableDataPager": { + "assembly": "DotVVM.Framework", + "baseType": "DotVVM.Framework.Controls.HtmlGenericControl, DotVVM.Framework", + "defaultContentProperty": "LoadTemplate", + "withoutContent": true + }, "DotVVM.Framework.Controls.AuthenticatedView": { "assembly": "DotVVM.Framework", "baseType": "DotVVM.Framework.Controls.ConfigurableHtmlControl, DotVVM.Framework", diff --git a/src/Tests/Runtime/testoutputs/DotvvmControlRenderedHtmlTests.Button_ClickArgumentsCommand-Button_ClickArgumentsCommand.html b/src/Tests/Runtime/testoutputs/DotvvmControlRenderedHtmlTests.Button_ClickArgumentsCommand-Button_ClickArgumentsCommand.html new file mode 100644 index 0000000000..b9b51c393d --- /dev/null +++ b/src/Tests/Runtime/testoutputs/DotvvmControlRenderedHtmlTests.Button_ClickArgumentsCommand-Button_ClickArgumentsCommand.html @@ -0,0 +1 @@ + diff --git a/src/Tests/ViewModel/GridViewDataSetTests.cs b/src/Tests/ViewModel/GridViewDataSetTests.cs new file mode 100644 index 0000000000..d9994475e1 --- /dev/null +++ b/src/Tests/ViewModel/GridViewDataSetTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Framework.Binding; +using DotVVM.Framework.Binding.Expressions; +using DotVVM.Framework.Compilation.ControlTree; +using DotVVM.Framework.Compilation.Javascript; +using DotVVM.Framework.Controls; +using DotVVM.Framework.Controls.Infrastructure; +using DotVVM.Framework.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotVVM.Framework.Tests.ViewModel +{ + [TestClass] + public class GridViewDataSetTests + { + private readonly BindingCompilationService bindingService; + private readonly GridViewDataSetBindingProvider commandProvider; + private readonly GridViewDataSet vm; + private readonly DataContextStack dataContextStack; + private readonly DotvvmControl control; + private readonly ValueBindingExpression> dataSetBinding; + + public GridViewDataSetTests() + { + bindingService = DotvvmTestHelper.DefaultConfig.ServiceProvider.GetRequiredService(); + commandProvider = new GridViewDataSetBindingProvider(bindingService); + + // build viewmodel + vm = new GridViewDataSet() + { + PagingOptions = + { + PageSize = 10, + TotalItemsCount = 65 + }, + SortingOptions = { SortExpression = nameof(TestDto.Id) } + }; + + // create page + dataContextStack = DataContextStack.Create(vm.GetType()); + control = new DotvvmView() { DataContext = vm }; + dataSetBinding = ValueBindingExpression.CreateThisBinding>(bindingService, dataContextStack); + } + + [TestMethod] + public void GridViewDataSet_DataPagerCommands_Command() + { + // create control with page index data context + var pageIndexControl = new PlaceHolder(); + var pageIndexDataContextStack = DataContextStack.CreateCollectionElement(typeof(int), dataContextStack); + pageIndexControl.SetDataContextType(pageIndexDataContextStack); + pageIndexControl.SetProperty(p => p.DataContext, ValueOrBinding.FromBoxedValue(1)); + control.Children.Add(pageIndexControl); + + // get pager commands + var commands = commandProvider.GetDataPagerBindings(dataContextStack, dataSetBinding, GridViewDataSetCommandType.Default); + + // test evaluation of commands + Assert.IsNotNull(commands.GoToLastPage); + vm.IsRefreshRequired = false; + commands.GoToLastPage.Evaluate(control); + Assert.AreEqual(6, vm.PagingOptions.PageIndex); + Assert.IsTrue(vm.IsRefreshRequired); + + Assert.IsNotNull(commands.GoToPreviousPage); + vm.IsRefreshRequired = false; + commands.GoToPreviousPage.Evaluate(control); + Assert.AreEqual(5, vm.PagingOptions.PageIndex); + Assert.IsTrue(vm.IsRefreshRequired); + + Assert.IsNotNull(commands.GoToNextPage); + vm.IsRefreshRequired = false; + commands.GoToNextPage.Evaluate(control); + Assert.AreEqual(6, vm.PagingOptions.PageIndex); + Assert.IsTrue(vm.IsRefreshRequired); + + Assert.IsNotNull(commands.GoToFirstPage); + vm.IsRefreshRequired = false; + commands.GoToFirstPage.Evaluate(control); + Assert.AreEqual(0, vm.PagingOptions.PageIndex); + Assert.IsTrue(vm.IsRefreshRequired); + + Assert.IsNotNull(commands.GoToPage); + vm.IsRefreshRequired = false; + commands.GoToPage.Evaluate(pageIndexControl); + Assert.AreEqual(1, vm.PagingOptions.PageIndex); + Assert.IsTrue(vm.IsRefreshRequired); + } + + [TestMethod] + public void GridViewDataSet_GridViewCommands_Command() + { + // get gridview commands + var commands = commandProvider.GetGridViewBindings(dataContextStack, dataSetBinding, GridViewDataSetCommandType.Default); + + // test evaluation of commands + Assert.IsNotNull(commands.SetSortExpression); + vm.IsRefreshRequired = false; + commands.SetSortExpression.Evaluate(control, _ => "Name"); + Assert.AreEqual("Name", vm.SortingOptions.SortExpression); + Assert.IsFalse(vm.SortingOptions.SortDescending); + Assert.IsTrue(vm.IsRefreshRequired); + + vm.IsRefreshRequired = false; + commands.SetSortExpression.Evaluate(control, _ => "Name"); + Assert.AreEqual("Name", vm.SortingOptions.SortExpression); + Assert.IsTrue(vm.SortingOptions.SortDescending); + Assert.IsTrue(vm.IsRefreshRequired); + + vm.IsRefreshRequired = false; + commands.SetSortExpression.Evaluate(control, _ => "Id"); + Assert.AreEqual("Id", vm.SortingOptions.SortExpression); + Assert.IsFalse(vm.SortingOptions.SortDescending); + Assert.IsTrue(vm.IsRefreshRequired); + } + + + [TestMethod] + public void GridViewDataSet_DataPagerCommands_StaticCommand() + { + // get pager commands + var commands = commandProvider.GetDataPagerBindings(dataContextStack, dataSetBinding, GridViewDataSetCommandType.LoadDataDelegate); + + var goToFirstPage = CompileBinding(commands.GoToFirstPage); + Console.WriteLine(goToFirstPage); + XAssert.Equal("dotvvm.applyPostbackHandlers(async (options)=>{let cx=options.knockoutContext;return await dotvvm.dataSet.loadDataSet(options.viewModel,(options)=>dotvvm.dataSet.translations.PagingOptions.goToFirstPage(ko.unwrap(options).PagingOptions),cx.$gridViewDataSetHelper.loadDataSet,cx.$gridViewDataSetHelper.postProcessor);},this)", goToFirstPage); + } + + private string CompileBinding(ICommandBinding staticCommand) + { + return KnockoutHelper.GenerateClientPostBackExpression( + "", + staticCommand, + new Literal(), + new PostbackScriptOptions( + allowPostbackHandlers: false, + returnValue: null + )); + } + + class TestDto + { + public int Id { get; set; } + + public string Name { get; set; } + } + } +}