Started working on listing page
This commit is contained in:
36
src/HopFrame.Core/Config/PropertyConfig.cs
Normal file
36
src/HopFrame.Core/Config/PropertyConfig.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace HopFrame.Core.Config;
|
||||
|
||||
public class PropertyConfig(PropertyInfo info) {
|
||||
public PropertyInfo Info { get; init; } = info;
|
||||
public string Name { get; set; } = info.Name;
|
||||
public bool List { get; set; } = true;
|
||||
public bool Sortable { get; set; } = true;
|
||||
public bool Searchable { get; set; } = true;
|
||||
}
|
||||
|
||||
public class PropertyConfig<TProp>(PropertyConfig config) {
|
||||
|
||||
public PropertyConfig<TProp> SetDisplayName(string displayName) {
|
||||
config.Name = displayName;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> List(bool display) {
|
||||
config.List = display;
|
||||
config.Searchable = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> Sortable(bool sortable) {
|
||||
config.Sortable = sortable;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> Searchable(bool searchable) {
|
||||
config.Searchable = searchable;
|
||||
return this;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,10 +1,25 @@
|
||||
namespace HopFrame.Core.Config;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
|
||||
public class TableConfig(DbContextConfig config, Type tableType, string propertyName) {
|
||||
public Type TableType { get; } = tableType;
|
||||
public string PropertyName { get; } = propertyName;
|
||||
public DbContextConfig ContextConfig { get; } = config;
|
||||
namespace HopFrame.Core.Config;
|
||||
|
||||
public class TableConfig {
|
||||
public Type TableType { get; }
|
||||
public string PropertyName { get; }
|
||||
public DbContextConfig ContextConfig { get; }
|
||||
public bool Ignored { get; set; }
|
||||
|
||||
public List<PropertyConfig> Properties { get; } = new();
|
||||
|
||||
public TableConfig(DbContextConfig config, Type tableType, string propertyName) {
|
||||
TableType = tableType;
|
||||
PropertyName = propertyName;
|
||||
ContextConfig = config;
|
||||
|
||||
foreach (var info in tableType.GetProperties()) {
|
||||
Properties.Add(new PropertyConfig(info));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class TableConfig<TModel>(TableConfig innerConfig) {
|
||||
@@ -13,5 +28,40 @@ public class TableConfig<TModel>(TableConfig innerConfig) {
|
||||
innerConfig.Ignored = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PropertyConfig<TProp> Property<TProp>(Expression<Func<TModel, TProp>> propertyExpression) {
|
||||
var info = GetPropertyInfo(propertyExpression);
|
||||
var prop = innerConfig.Properties
|
||||
.Single(prop => prop.Info.Name == info.Name);
|
||||
return new PropertyConfig<TProp>(prop);
|
||||
}
|
||||
|
||||
public TableConfig<TModel> Property<TProp>(Expression<Func<TModel, TProp>> propertyExpression, Action<PropertyConfig<TProp>> configurator) {
|
||||
var prop = Property(propertyExpression);
|
||||
configurator.Invoke(prop);
|
||||
return this;
|
||||
}
|
||||
|
||||
private static PropertyInfo GetPropertyInfo<TSource, TProperty>(Expression<Func<TSource, TProperty>> propertyLambda) {
|
||||
if (propertyLambda.Body is not MemberExpression member) {
|
||||
throw new ArgumentException($"Expression '{propertyLambda}' refers to a method, not a property.");
|
||||
}
|
||||
|
||||
if (member.Member is not PropertyInfo propInfo) {
|
||||
throw new ArgumentException($"Expression '{propertyLambda}' refers to a field, not a property.");
|
||||
}
|
||||
|
||||
Type type = typeof(TSource);
|
||||
if (propInfo.ReflectedType != null && type != propInfo.ReflectedType &&
|
||||
!type.IsSubclassOf(propInfo.ReflectedType)) {
|
||||
throw new ArgumentException($"Expression '{propertyLambda}' refers to a property that is not from type {type}.");
|
||||
}
|
||||
|
||||
if (propInfo.Name is null)
|
||||
throw new ArgumentException($"Expression '{propertyLambda}' refers a not existing property.");
|
||||
|
||||
return propInfo;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
namespace HopFrame.Core.Services;
|
||||
|
||||
public interface ITableManager {
|
||||
public Task<IEnumerable<object>> LoadPage(int page, int perPage = 25);
|
||||
public IQueryable<object> LoadPage(int page, int perPage = 20);
|
||||
public (IEnumerable<object>, int) Search(string searchTerm, int page = 0, int perPage = 20);
|
||||
public int TotalPages(int perPage = 20);
|
||||
public Task DeleteItem(object item);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
|
||||
|
||||
public TableConfig? GetTable(string tableName) {
|
||||
foreach (var context in config.Contexts) {
|
||||
var table = context.Tables.FirstOrDefault(table => table.PropertyName == tableName);
|
||||
var table = context.Tables.FirstOrDefault(table => table.PropertyName.Equals(tableName, StringComparison.CurrentCultureIgnoreCase));
|
||||
if (table is not null)
|
||||
return table;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ internal sealed class ContextExplorer(HopFrameConfig config, IServiceProvider pr
|
||||
if (dbContext is null) return null;
|
||||
|
||||
var type = typeof(TableManager<>).MakeGenericType(table.TableType);
|
||||
return Activator.CreateInstance(type, dbContext) as ITableManager;
|
||||
return Activator.CreateInstance(type, dbContext, table) as ITableManager;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,15 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using HopFrame.Core.Config;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace HopFrame.Core.Services.Implementations;
|
||||
|
||||
internal sealed class TableManager<TModel>(DbContext context) : ITableManager where TModel : class {
|
||||
internal sealed class TableManager<TModel>(DbContext context, TableConfig config) : ITableManager where TModel : class {
|
||||
|
||||
public async Task<IEnumerable<object>> LoadPage(int page, int perPage = 25) {
|
||||
public IQueryable<object> LoadPage(int page, int perPage = 20) {
|
||||
var table = context.Set<TModel>();
|
||||
return await table
|
||||
return table
|
||||
.Skip(page * perPage)
|
||||
.Take(perPage)
|
||||
.ToArrayAsync();
|
||||
.Take(perPage);
|
||||
}
|
||||
|
||||
public (IEnumerable<object>, int) Search(string searchTerm, int page = 0, int perPage = 20) {
|
||||
var table = context.Set<TModel>();
|
||||
var all = table
|
||||
.AsEnumerable()
|
||||
.Where(item => ItemSearched(item, searchTerm))
|
||||
.ToList();
|
||||
|
||||
return (all.Skip(page * perPage).Take(perPage), (int)Math.Ceiling(all.Count / (double)perPage));
|
||||
}
|
||||
|
||||
public int TotalPages(int perPage = 20) {
|
||||
var table = context.Set<TModel>();
|
||||
return (int)Math.Ceiling(table.Count() / (double)perPage);
|
||||
}
|
||||
|
||||
public async Task DeleteItem(object item) {
|
||||
var table = context.Set<TModel>();
|
||||
table.Remove((item as TModel)!);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private bool ItemSearched(TModel item, string searchTerm) {
|
||||
foreach (var property in config.Properties) {
|
||||
if (!property.Searchable) continue;
|
||||
var value = property.Info.GetValue(item);
|
||||
if (value is null) continue;
|
||||
|
||||
var strValue = value.ToString();
|
||||
if (strValue?.Contains(searchTerm) == true)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
<br>
|
||||
|
||||
@foreach (var table in Explorer.GetTableNames()) {
|
||||
<FluentAppBarItem Href="@("/admin/" + table)"
|
||||
<FluentAppBarItem Href="@("/admin/" + table.ToLower())"
|
||||
Match="NavLinkMatch.All"
|
||||
IconActive="new Icons.Filled.Size24.Database()"
|
||||
IconRest="new Icons.Regular.Size24.Database()"
|
||||
|
||||
144
src/HopFrame.Web/Components/Pages/HopFrameListView.razor
Normal file
144
src/HopFrame.Web/Components/Pages/HopFrameListView.razor
Normal file
@@ -0,0 +1,144 @@
|
||||
@page "/admin/{TableName}"
|
||||
@layout HopFrameLayout
|
||||
@rendermode InteractiveServer
|
||||
|
||||
@using HopFrame.Core.Config
|
||||
@using HopFrame.Core.Services
|
||||
@using Microsoft.JSInterop
|
||||
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<FluentToolbar Class="hopframe-toolbar">
|
||||
<h3>@_config?.PropertyName</h3>
|
||||
<FluentSpacer />
|
||||
<FluentSearch @oninput="OnSearch" @onchange="OnSearch" />
|
||||
<FluentButton>Add Entry</FluentButton>
|
||||
</FluentToolbar>
|
||||
|
||||
<div style="overflow-y: auto; flex-grow: 1">
|
||||
<FluentDataGrid Items="_currentlyDisplayedModels?.AsQueryable()">
|
||||
@{ var dataIndex = 0; }
|
||||
@foreach (var property in _config!.Properties.Where(prop => prop.List)) {
|
||||
<PropertyColumn Title="@property.Name" Property="o => property.Info.GetValue(o)" Style="min-width: max-content; min-height: 43px" Sortable="@property.Sortable"/>
|
||||
}
|
||||
|
||||
<TemplateColumn Title="Actions" Align="@Align.End" Style="min-height: 43px; min-width: max-content">
|
||||
@{ var currentElement = _currentlyDisplayedModels!.ElementAt(dataIndex); }
|
||||
<FluentButton aria-label="Edit entry">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size16.Edit())"/>
|
||||
</FluentButton>
|
||||
|
||||
<FluentButton aria-label="Delete entry" OnClick="() => { DeleteEntry(currentElement); }">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size16.Delete())" Color="Color.Warning"/>
|
||||
</FluentButton>
|
||||
|
||||
@{
|
||||
dataIndex++;
|
||||
dataIndex %= 20;
|
||||
}
|
||||
</TemplateColumn>
|
||||
</FluentDataGrid>
|
||||
|
||||
@if (_currentlyDisplayedModels?.Any() == true) {
|
||||
<div class="hopframe-paginator">
|
||||
<FluentButton BackgroundColor="transparent" OnClick="() => ChangePage(_currentPage - 1)">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowPrevious())" Color="Color.Neutral" />
|
||||
</FluentButton>
|
||||
|
||||
<span>Page</span>
|
||||
|
||||
<FluentSelect TOption="int"
|
||||
Items="Enumerable.Range(0, _totalPages)"
|
||||
OptionValue="@(p => p.ToString())"
|
||||
OptionText="@(p => (p + 1).ToString())"
|
||||
ValueChanged="s => ChangePage(Convert.ToInt32(s))"
|
||||
Width="max-content" SelectedOption="@_currentPage"/>
|
||||
|
||||
<span>of @_totalPages</span>
|
||||
|
||||
<FluentButton BackgroundColor="transparent" OnClick="() => ChangePage(_currentPage + 1)">
|
||||
<FluentIcon Value="@(new Icons.Regular.Size20.ArrowNext())" Color="Color.Neutral" />
|
||||
</FluentButton>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function removeBg() {
|
||||
const elements = document.querySelectorAll(".col-sort-button");
|
||||
const style = new CSSStyleSheet();
|
||||
style.replaceSync(".control { background: none !important; }");
|
||||
elements.forEach(e => e.shadowRoot.adoptedStyleSheets.push(style));
|
||||
console.log(elements);
|
||||
}
|
||||
|
||||
removeBg();
|
||||
</script>
|
||||
|
||||
@inject IContextExplorer Explorer
|
||||
@inject NavigationManager Navigator
|
||||
@inject IJSRuntime Js
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public required string TableName { get; set; }
|
||||
|
||||
private TableConfig? _config;
|
||||
private ITableManager? _manager;
|
||||
|
||||
private IEnumerable<object>? _currentlyDisplayedModels;
|
||||
private int _currentPage = 0;
|
||||
private int _totalPages;
|
||||
private string? _searchTerm;
|
||||
|
||||
protected override void OnInitialized() {
|
||||
_config = Explorer.GetTable(TableName);
|
||||
|
||||
if (_config is null) {
|
||||
Navigator.NavigateTo("/admin", true);
|
||||
return;
|
||||
}
|
||||
|
||||
_manager = Explorer.GetTableManager(_config.PropertyName);
|
||||
_currentlyDisplayedModels = _manager!.LoadPage(_currentPage).ToArray();
|
||||
_totalPages = _manager.TotalPages();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
await Js.InvokeVoidAsync("removeBg");
|
||||
}
|
||||
|
||||
private CancellationTokenSource _searchCancel = new();
|
||||
private async Task OnSearch(ChangeEventArgs eventArgs) {
|
||||
await _searchCancel.CancelAsync();
|
||||
_searchTerm = eventArgs.Value?.ToString();
|
||||
if (_searchTerm is null) return;
|
||||
_searchCancel = new();
|
||||
|
||||
await Task.Delay(500, _searchCancel.Token);
|
||||
(_currentlyDisplayedModels, _totalPages) = _manager!.Search(_searchTerm);
|
||||
}
|
||||
|
||||
private void ChangePage(int page) {
|
||||
if (page < 0 || page > _totalPages - 1) return;
|
||||
_currentPage = page;
|
||||
if (!string.IsNullOrEmpty(_searchTerm)) {
|
||||
(_currentlyDisplayedModels, _totalPages) = _manager!.Search(_searchTerm, page);
|
||||
}
|
||||
else {
|
||||
_currentlyDisplayedModels = _manager!.LoadPage(page);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteEntry(object element) { //TODO: display confirmation
|
||||
await _manager!.DeleteItem(element);
|
||||
|
||||
if (!string.IsNullOrEmpty(_searchTerm)) {
|
||||
(_currentlyDisplayedModels, _totalPages) = _manager!.Search(_searchTerm);
|
||||
}
|
||||
else {
|
||||
OnInitialized();
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/HopFrame.Web/Components/Pages/HopFrameListView.razor.css
Normal file
12
src/HopFrame.Web/Components/Pages/HopFrameListView.razor.css
Normal file
@@ -0,0 +1,12 @@
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hopframe-paginator {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-block: 20px;
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
}
|
||||
|
||||
.hopframe-content {
|
||||
padding: 0.5rem 1.5rem;
|
||||
align-self: stretch !important;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -14,4 +13,18 @@
|
||||
min-height: calc(100dvh - 86px);
|
||||
color: var(--neutral-foreground-rest);
|
||||
align-items: stretch !important;
|
||||
column-gap: 0 !important;
|
||||
}
|
||||
|
||||
.hopframe-toolbar {
|
||||
width: 100%;
|
||||
padding: 0.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.hopframe-content .empty-content-row.empty-content-cell {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
fluent-option {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user