LiveTable
repository·master·Indexed 42 days ago
https://github.com/gurujada/live_tableA Phoenix LiveView component library for building interactive, real-time data tables. Features include advanced filtering (Boolean, Range, Select, Transformers), multi-column sorting, pagination, and CSV/PDF exports via Oban and Typst. Supports both simple Ecto schema-backed tables and complex tables using custom data providers with joins and aggregations.
What's inside LiveTable
- LiveTable is a Phoenix LiveView component library designed for building interactive, high-performance data tables. It is optimized for large datasets (tested with 1M+ records) and provides built-in support for filtering, sorting, pagination, and professional exports (CSV/PDF). It is ideal for admin dashboards, e-commerce catalogs, and analytics platforms.
Overview of LiveTable features
masterLiveTable is a Phoenix LiveView component library designed for building interactive data tables. Key capabilities include:
- Advanced Filtering: Text search, range filters, select dropdowns, boolean toggles, and transformers.
- Smart Sorting: Multi-column sorting with shift-click support.
- Flexible Pagination: Supports both standard pagination and infinite scroll.
- Export Capabilities: CSV and PDF exports with background processing.
- Real-time Updates: Built specifically for Phoenix LiveView for instant feedback.
- Multiple View Modes: Supports both Table and Card layouts with custom components.
- Complex Queries: Supports joins, aggregations, and computed fields.
Overview of LiveTable
masterLiveTable is a Phoenix LiveView component library designed for building dynamic, interactive data tables with real-time updates. It is suitable for admin panels, dashboards, e-commerce catalogs, and analytics platforms.
Core Architecture
LiveTable uses a three-tier architecture:
- Data Layer: Supports Simple Tables (using
schema: YourApp.Modelfor single-table queries) and Complex Tables (using:data_providerfor custom queries with joins). - Configuration Layer: Uses
fields/0to define data display,filters/0for interactive filtering, andtable_options/0to control behavior. - Presentation Layer: Offers Table Mode (rows/columns), Card Mode (rich card displays), and support for Custom Components to override any part of the UI.
- Data Layer: Supports Simple Tables (using
Choose between Simple and Complex Table patterns
masterLiveTable uses two distinct implementation patterns. Choosing the wrong one will cause field key mismatches or runtime errors.
Pattern A: Simple Tables (Single Schema)
Use this when querying a single Ecto schema directly.
- Implementation: Use
use LiveTable.LiveResource, schema: YourApp.Schema. - Field Keys: Must match the Ecto schema field names exactly.
- Data Provider: No custom
data_providerassignment is needed inmount/3.
Pattern B: Complex Tables (Custom Queries)
Use this for joins, computed fields, or complex logic involving multiple tables.
- Implementation: Use
use LiveTable.LiveResource(do not pass aschema:parameter). - Field Keys: Must match the keys returned in your custom SQL/Ecto
selectclause exactly. - Data Provider: You must assign a custom data provider in
mount/3using the format{Module, Function, Arguments}.
# Pattern A: Simple use LiveTable.LiveResource, schema: YourApp.Product # Pattern B: Complex use LiveTable.LiveResource def mount(_params, _session, socket) do socket = assign(socket, :data_provider, {YourApp.Orders, :list_with_details, []}) {:ok, socket} end- Implementation: Use
How filters work in LiveTable
masterFilters are defined in the
filters/0function of your LiveTable module as a keyword list. Each entry in the list maps a filter name to a specific filter struct. LiveTable supports three primary filter types:- Boolean: Renders as checkboxes for true/false conditions.
- Range: Renders as numeric sliders.
- Select: Renders as dropdowns with either static or dynamic options.
Filters use a consistent constructor pattern:
FilterType.new(field, key, options)field: The database field to filter on. Use an atom for simple fields (e.g.,:active) or a tuple for joined fields (e.g.,{:suppliers, :name}).key: The URL parameter key (string) used for persistence and routing.options: A map of configuration settings specific to the filter type.
def filters do [ active: Boolean.new(:active, "active", %{ label: "Active Only", condition: dynamic([p], p.active == true) }), price_range: Range.new(:price, "price_range", %{ label: "Price Range", min: 0, max: 1000 }), category: Select.new({:category, :name}, "category", %{ label: "Category", options: [ %{label: "Electronics", value: ["electronics"]}, %{label: "Books", value: ["books"]} ] }) ] endStyling and UI customization
masterLiveTable is built on SutraUI and uses Tailwind CSS.
- DaisyUI Integration: If your application uses DaisyUI, LiveTable components will automatically adopt your active Daisy theme.
- Dark Mode: Supports Tailwind
dark:classes and DaisyUI theme tokens. - Complete UI Overrides: If you need to move away from the default look, you can provide custom components for the following areas:
custom_headercustom_controlscustom_contentcustom_footer
How LiveTable architecture works
masterLiveTable uses a three-tier architecture to separate data concerns from presentation:
- Data Layer: Handles data retrieval via Ecto schema integration, custom queries, or flexible Data Providers (functions that return queryable data).
- Configuration Layer: Defines the table's behavior through
Fields(what to display),Filters(how to narrow data), andOptions(pagination, sorting, and display modes). - Presentation Layer: Manages the UI using customizable components, templates, and Tailwind CSS styling.
This separation allows you to switch between simple CRUD tables and complex analytics dashboards by simply changing the configuration and data provider.
Configure Display Modes: Table vs Card
masterChoose between two primary layout modes:
:table: A traditional row-and-column layout.:card: A grid layout using custom card components.
When using
:cardmode, you must provide acard_componentfunction.# Table Mode mode: :table # Card Mode mode: :card, card_component: &product_card/1Customize cell display with renderers or components
masterYou can transform how data appears in cells using either a
rendereror acomponent.Using
rendererrendereris a function that receives the cell value. It can optionally receive the entire record as a second argument.function/1: Receives only the cell value.function/2: Receives the cell value AND the entire record. Use this when you need context from other fields in the same row (e.g., for conditional logic or row actions).
Using
componentcomponentis a function component that receives an assigns map containing:valueand:recordkeys.Comparison:
Feature renderercomponentArguments Value (and optionally record) Assigns map ( @value,@record)Usage &my_func/1or&my_func/2&my_comp/1# Using function/1 - only gets the cell value status: %{ label: "Status", renderer: &format_status/1 } defp format_status(status) do assigns = %{status: status} ~H""" <span class={status_class(@status)}> <%= String.capitalize(@status) %> </\ </span> """ end # Using function/2 - gets cell value AND entire record priority: %{ label: "Priority", renderer: &format_priority_with_context/2 } defp format_priority_with_context(priority, record) do assigns = %{priority: priority, record: record} ~H""" <div class="flex items-center gap-2"> <span class={priority_class(@priority)}> <%= String.upcase(@priority) %> </\ </span> <%= if @record.is_urgent do %> <span class="text-red-500 text-xs">URGENT</\ <% end %> </\ </\ """ endDefine complex Ecto queries for LiveTable
masterWhen providing data to
LiveTable, your Ecto query should use named bindings (as: :name) for all joins. This allowsLiveTablefilters to reference joined data using thedynamicmacro.For calculated fields (like
avg_daily_usageordays_of_supply), usefragment/1within theselectblock to perform SQL-level aggregations. This ensures that sorting and filtering on these calculated values remain performant at the database level.defmodule YourApp.Inventory do import Ecto.Query def comprehensive_report do from p in YourApp.Product, join: s in YourApp.Supplier, on: p.supplier_id == s.id, as: :suppliers, join: w in YourApp.Warehouse, on: p.warehouse_id == w.id, as: :warehouses, left_join: im in YourApp.InventoryMovement, on: im.product_id == p.id and im.inserted_at >= ago(30, "day"), group_by: [p.id, s.id, w.id], select: %{ product_name: p.name, supplier_name: s.name, avg_daily_usage: fragment("CASE WHEN COUNT(?) > 0 THEN ... END", im.id, im, im) } end endPatterns for Complex Queries in LiveTable
masterWhen building advanced reporting features, follow these architectural patterns:
1. Data Provider Assignment
Assign the
:data_providerin themount/3callback before LiveTable processes URL parameters. Use the tuple format{Module, :function, [args]}to allow for dynamic parameter injection (e.g., passing a cohort year from the URL).2. Query Design
- Aliases: Use the
as:keyword in Ecto joins. This is critical for allowing filters to reference joined tables. - Calculations: Use
fragment/2for complex SQL logic (window functions, CASE statements, etc.) to keep the heavy lifting in the database. - Grouping: Always
group_byall non-aggregated fields in your select statement.
3. Field and Filter Mapping
- Associations: Use
assoc: {:alias_name, :field}in yourfields/0definition to map columns to joined data. - Field Keys: Ensure the keys in your
select:block in the Ecto query match the keys defined in yourfields/0exactly.
4. Custom Rendering
Use
renderer: &function/1to create rich UI. The function receives the specific record for that row, allowing you to implement conditional logic (e.g., changing colors based on a score) or complex HTML structures.- Aliases: Use the
Define a Provider Contract with Ecto Queries
masterLiveTable manages query execution. Your provider functions must return an
Ecto.Queryand must not callRepo.all/1internally.Key Requirements:
- Return Queries: Always return a query struct.
- Named Bindings: When performing joins that need to be searchable, sortable, or filterable, use named bindings (
as: :name) so LiveTable can reference them. - Field Matching: The keys defined in
fields/0must exactly match the keys selected in your query'sselectblock.
# Provider function returning a query with a named binding def list_product_rows do from p in Product, join: b in assoc(p, :brand), as: :brand, select: %{ id: p.id, name: p.name, brand_name: b.name } end # Corresponding fields definition def fields do [ name: %{label: "Product", searchable: true}, brand_name: %{label: "Brand", searchable: true, sortable: true, assoc: {:brand, :name}} ] end