Tables
Htmx.Components provides powerful table functionality with sorting, filtering, pagination, and inline editing capabilities.
Basic Table Setup
Tables in Htmx.Components are configured using model handlers with the [ModelConfig]
attribute. Here's a real example from CruSibyl.Web:
Controller with Model Configuration
[Route("Admin")]
[NavActionGroup(DisplayName = "Admin", Icon = "fas fa-cogs", Order = 2)]
public class AdminController : Controller
{
private readonly AppDbContext _dbContext;
private readonly IModelHandlerFactoryGeneric _modelHandlerFactory;
public AdminController(AppDbContext dbContext, IModelHandlerFactoryGeneric modelHandlerFactory)
{
_dbContext = dbContext;
_modelHandlerFactory = modelHandlerFactory;
}
[HttpGet("Repos")]
[NavAction(DisplayName = "Repos", Icon = "fas fa-database", Order = 0, PushUrl = true, ViewName = "_Repos")]
public async Task<IActionResult> Repos()
{
var modelHandler = await _modelHandlerFactory.Get<Repo, int>(nameof(Repo), ModelUI.Table);
var tableModel = await modelHandler.BuildTableModelAndFetchPageAsync();
return Ok(tableModel);
}
[ModelConfig(nameof(Repo))]
private void ConfigureRepo(ModelHandlerBuilder<Repo, int> builder)
{
builder
.WithKeySelector(r => r.Id)
.WithQueryable(() => _dbContext.Repos)
.WithCreate(async repo =>
{
_dbContext.Repos.Add(repo);
await _dbContext.SaveChangesAsync();
return Result.Value(repo);
})
.WithUpdate(async repo =>
{
_dbContext.Repos.Update(repo);
await _dbContext.SaveChangesAsync();
return Result.Value(repo);
})
.WithDelete(async id =>
{
var repo = await _dbContext.Repos.FindAsync(id);
if (repo != null)
{
_dbContext.Repos.Remove(repo);
await _dbContext.SaveChangesAsync();
}
return Result.Success();
})
.WithTable(table => table
.WithCrudActions()
.AddSelectorColumn(x => x.Name, config => config.WithEditable())
.AddSelectorColumn(x => x.Description!, config => config.WithEditable())
.AddCrudDisplayColumn());
}
}
View File
Create a view file to render the table (e.g., Views/Admin/_Repos.cshtml
):
@using Htmx.Components.Table.Models
@model ITableModel
<div id="admin-repos">
<h2>Repository Management</h2>
@await Component.InvokeAsync("Table", Model)
</div>
JavaScript Requirements
Tables with inline editing require the table-behavior
JavaScript behavior:
<!-- Include all behaviors (includes table-behavior) -->
<htmx-scripts></htmx-scripts>
<!-- Include only table-behavior -->
<htmx-scripts include="table-behavior"></htmx-scripts>
The table-behavior
provides:
- Visual editing states: Highlights rows being edited
- Inline editing coordination: Manages edit mode transitions
Table Configuration Options
Basic Column Types
Selector Columns
Display data from model properties:
.AddSelectorColumn(x => x.Name, config => config.WithEditable())
.AddSelectorColumn(x => x.Description, config => config.WithEditable())
.AddSelectorColumn(x => x.CreatedDate) // Read-only column
CRUD Display Column
Adds edit/delete action buttons:
.AddCrudDisplayColumn()
CRUD Operations
Enable create, update, and delete operations:
.WithTable(table => table
.WithCrudActions() // Enables CRUD functionality
.AddSelectorColumn(x => x.Name, config => config.WithEditable())
.AddCrudDisplayColumn()) // Adds action buttons
Real-World Example: Admin Users
Here's another example from CruSibyl.Web showing a more complex table with custom model:
[ModelConfig(nameof(AdminUserModel))]
private void ConfigureAdminUser(ModelHandlerBuilder<AdminUserModel, int> builder)
{
builder
.WithKeySelector(u => u.Id)
.WithQueryable(() => _dbContext.Users
.Where(u => u.Permissions.Any(p => p.Role.Name == Role.Codes.Admin || p.Role.Name == Role.Codes.System))
.Select(u => new AdminUserModel
{
Id = u.Id,
Name = u.FirstName + " " + u.LastName,
Email = u.Email,
Kerberos = u.Kerberos,
IsSystemAdmin = u.Permissions.Any(p => p.Role.Name == Role.Codes.System)
}))
.WithInput(u => u.Email, config => config
.WithLabel("Email")
.WithPlaceholder("Email to look up")
.WithCssClass("form-control"))
.WithInput(u => u.Kerberos, config => config
.WithLabel("Kerberos")
.WithPlaceholder("Kerberos to look up")
.WithCssClass("form-control"))
.WithInput(u => u.IsSystemAdmin, config => config
.WithLabel("System Admin")
.WithCssClass("form-check"))
.WithTable(table => table
.WithCrudActions()
.AddSelectorColumn(x => x.Name)
.AddSelectorColumn(x => x.Email, config => config.WithEditable())
.AddSelectorColumn(x => x.Kerberos, config => config.WithEditable())
.AddSelectorColumn(x => x.IsSystemAdmin, config => config.WithEditable())
.AddCrudDisplayColumn());
}
Key Features
Automatic Table Rendering
The Table
ViewComponent automatically renders:
- Column headers
- Data rows
- Sorting controls
- Pagination controls
- Filter inputs (when enabled)
- CRUD action buttons (when enabled)
Built-in Functionality
- Sorting: Click column headers to sort
- Filtering: Built-in text filters for columns
- Pagination: Automatic pagination for large datasets
- Inline Editing: Edit data directly in the table
- CRUD Operations: Create, update, delete records
Integration with Entity Framework
Tables work seamlessly with Entity Framework Core through the WithQueryable()
method, providing efficient database queries with proper pagination and filtering.
Next Steps
- Navigation: Learn about NavAction attributes and navigation setup
- Authentication: Configure authentication and AuthStatus components
- Authorization: Set up authorization policies
- Architecture Guide: Understand the underlying patterns and design
Filtering
Built-in Filters
Easily filter tables with built-in text filters:
table.AddSelectorColumn(p => p.Name, col => col
.WithFilter());
Custom Filters
Create specialized filters for complex scenarios:
table.AddSelectorColumn(p => p.Status, col => col
.WithFilter((query, value) =>
{
if (Enum.TryParse<ProductStatus>(value, out var status))
return query.Where(p => p.Status == status);
return query;
}));
Range Filters
Useful for dates and numeric values:
table.AddSelectorColumn(p => p.CreatedDate, col => col
.WithRangeFilter((query, fromDate, toDate) =>
{
var from = DateTime.Parse(fromDate);
var to = DateTime.Parse(toDate);
return query.Where(p => p.CreatedDate >= from && p.CreatedDate <= to);
}));
Sorting
Automatic Sorting
Enabled by default for selector columns:
table.AddSelectorColumn(p => p.Name); // Automatically sortable
Pagination
Pagination is automatically handled by the table provider. Configure page size:
// In your action
var tableState = pageState.GetOrCreate<TableState>("Table", "TableState", () => new TableState
{
PageSize = 25 // Default page size
});
Users can change page size using the built-in pagination controls.
CRUD Operations
Enable CRUD
Configure create, read, update, and delete operations:
builder.WithCreate(CreateProduct)
.WithUpdate(UpdateProduct)
.WithDelete(DeleteProduct)
.WithTable(table =>
{
table.AddCrudDisplayColumn(); // Adds Edit/Delete buttons
table.WithCrudActions(); // Adds Create button
});
CRUD Implementation
Implement the CRUD operations:
private async Task<Result<Product>> CreateProduct(Product product)
{
try
{
if (string.IsNullOrEmpty(product.Name))
return Result.Error("Product name is required");
_context.Products.Add(product);
await _context.SaveChangesAsync();
return Result.Value(product);
}
catch (Exception ex)
{
return Result.Error("Failed to create product: {Error}", ex.Message);
}
}
private async Task<Result<Product>> UpdateProduct(Product product)
{
try
{
_context.Products.Update(product);
await _context.SaveChangesAsync();
return Result.Value(product);
}
catch (Exception ex)
{
return Result.Error("Failed to update product: {Error}", ex.Message);
}
}
private async Task<Result> DeleteProduct(int productId)
{
try
{
var product = await _context.Products.FindAsync(productId);
if (product == null)
return Result.Error("Product not found");
_context.Products.Remove(product);
await _context.SaveChangesAsync();
return Result.Ok("Product deleted successfully");
}
catch (Exception ex)
{
return Result.Error("Failed to delete product: {Error}", ex.Message);
}
}
Inline Editing
Enable Inline Editing
Configure columns for inline editing:
table.AddSelectorColumn(p => p.Name, col => col
.WithEditable());
table.AddSelectorColumn(p => p.Category, col => col
.WithEditable());
Input Configuration
Define how fields are edited:
builder.WithInput(p => p.Name, input => input
.WithLabel("Product Name")
.WithKind(InputKind.Text)
.WithPlaceholder("Enter product name"));
builder.WithInput(p => p.Category, input => input
.WithLabel("Category")
.WithKind(InputKind.Select)
.WithOptions(GetCategoryOptions()));
private List<KeyValuePair<string, string>> GetCategoryOptions()
{
return new List<KeyValuePair<string, string>>
{
new("electronics", "Electronics"),
new("clothing", "Clothing"),
new("books", "Books")
};
}
Custom Table Views
Override Table Templates
Customize table rendering by overriding view paths:
builder.Services.AddHtmxComponents(options =>
{
options.WithViewOverrides(views =>
{
views.Table.Table = "CustomTable";
views.Table.Row = "CustomTableRow";
views.Table.Cell = "CustomTableCell";
});
});
Custom Cell Rendering
Create custom cell templates:
table.AddSelectorColumn(p => p.Status, col => col
.WithCellPartial("_StatusCell"));
Create Views/Shared/_StatusCell.cshtml
:
@using Htmx.Components.Table.Models
@model TableCellPartialModel
@{
var status = (ProductStatus)Model.Column.GetValue(Model.Row);
var statusClass = status switch
{
ProductStatus.Active => "badge-success",
ProductStatus.Inactive => "badge-error",
_ => "badge-neutral"
};
}
<span class="badge @statusClass">@status</span>
Advanced Features
Conditional Actions
Show different actions based on row data:
table.AddDisplayColumn("Actions", col => col
.WithActions((row, actions) =>
{
var product = (Product)row.Item;
if (product.Status == ProductStatus.Active)
{
actions.AddAction(action => action
.WithLabel("Deactivate")
.WithIcon("fas fa-pause")
.WithHxPost($"/Products/Deactivate/{row.Key}"));
}
else
{
actions.AddAction(action => action
.WithLabel("Activate")
.WithIcon("fas fa-play")
.WithHxPost($"/Products/Activate/{row.Key}"));
}
}));
Bulk Operations
Add table-level actions for bulk operations:
table.WithActions((tableModel, actions) =>
{
actions.AddAction(action => action
.WithLabel("Export CSV")
.WithIcon("fas fa-download")
.WithHxGet($"/Products/ExportCsv"));
actions.AddAction(action => action
.WithLabel("Bulk Delete")
.WithIcon("fas fa-trash")
.WithClass("btn-error")
.WithHxPost("/Products/BulkDelete"));
});
Complex Filtering
Implement complex filtering scenarios:
public async Task<IActionResult> FilterByCategory(string category)
{
var modelHandler = await _modelRegistry.GetModelHandler<Product, int>("products", ModelUI.Table);
var tableState = this.GetPageState().GetOrCreate<TableState>("Table", "TableState", () => new());
// Apply custom filter
tableState.Filters["Category"] = category;
var tableModel = await modelHandler.BuildTableModelAndFetchPageAsync(tableState);
return Ok(tableModel);
}
Performance Optimization
Efficient Queries
Optimize your queryables for performance:
builder.WithQueryable(() => _context.Products
.Include(p => p.Category)
.AsNoTracking() // For read-only scenarios
.AsSplitQuery()); // For complex includes
Pagination Strategy
Use efficient pagination for large datasets:
// Consider using cursor-based pagination for very large tables
private async Task<TableModel<Product, int>> GetProductsPage(int page, int pageSize)
{
var skip = (page - 1) * pageSize;
var products = await _context.Products
.OrderBy(p => p.Id)
.Skip(skip)
.Take(pageSize)
.ToListAsync();
// Use the async builder for consistency
var handler = await _modelRegistry.GetModelHandler<Product, int>("products", ModelUI.Table);
var tableModel = await handler.BuildTableModelAsync();
return tableModel;
}
Troubleshooting
Table Not Loading
- Check that the model handler is properly configured
- Verify the queryable returns data
- Ensure the Table component is invoked with correct model type
Filtering Not Working
- Verify filter functions are properly implemented
- Check that columns are marked as filterable
- Ensure filter syntax is correct
CRUD Operations Failing
- Check error handling in CRUD operations
- Verify authorization for operations
- Ensure proper model validation
Performance Issues
- Review query efficiency and includes
- Consider pagination for large datasets
- Implement proper indexing on filtered/sorted columns