# frozen_string_literal: true
module Katalyst
# A component for rendering a table from a collection, with a header row.
# ```erb
# <%= Katalyst::TableComponent.new(collection: @people) do |row, person| %>
# <%= row.text :name do |cell| %>
# <%= link_to cell.value, person %>
# <% end %>
# <%= row.text :email %>
# <% end %>
# ```
class TableComponent < ViewComponent::Base
include Katalyst::HtmlAttributes
include Tables::HasTableContent
# Load table extensions. This allows users to disable specific extensions
# if they want to implement alternatives, e.g. a different sorting UI.
Katalyst::Tables.config.component_extensions.each do |extension|
include extension.constantize
end
attr_reader :collection, :object_name
renders_one :caption, Katalyst::Tables::EmptyCaptionComponent
renders_one :header_row, Katalyst::Tables::HeaderRowComponent
renders_many :body_rows, Katalyst::Tables::BodyRowComponent
define_html_attribute_methods(:thead_attributes)
define_html_attribute_methods(:tbody_attributes)
# Construct a new table component. This entry point supports a large number
# of options for customizing the table. The most common options are:
# @param collection [Katalyst::Tables::Collection::Core] the collection to render
# @param header [Boolean] whether to render the header row (defaults to true, supports options)
# @param caption [Boolean,Hash] whether to render the caption (defaults to true, supports options)
# @param generate_ids [Boolean] whether to generate dom ids for the table and rows
#
# If no block is provided when the table is rendered then the table will look for a row partial:
# @param object_name [Symbol] the name of the object to use for partial rendering
# (defaults to collection.model_name.i18n_key)
# @param partial [String] the name of the partial to use for rendering each row
# (defaults to to_partial_path on the object)
# @param as [Symbol] the name of the local variable to use for rendering each row
# (defaults to collection.model_name.param_key)
#
# In addition to these options, standard HTML attributes can be passed which will be added to the table tag.
def initialize(collection:,
header: true,
caption: true,
generate_ids: false,
object_name: nil,
partial: nil,
as: nil,
**)
@collection = normalize_collection(collection)
# header: true means render the header row, header: false means no header row, if a hash, passes as options
@header_options = header
# caption: true means render the caption, caption: false means no caption, if a hash, passes as options
@caption_options = caption
@header_row_callbacks = []
@body_row_callbacks = []
@header_row_cell_callbacks = []
@body_row_cell_callbacks = []
super(generate_ids:, object_name:, partial:, as:, **)
end
def before_render
super
if @caption_options
options = (@caption_options.is_a?(Hash) ? @caption_options : {})
with_caption(self, **options)
end
if @header_options
options = @header_options.is_a?(Hash) ? @header_options : {}
with_header_row(**options) do |row|
@header_row_callbacks.each { |callback| callback.call(row, record) }
row_content(row, nil)
end
end
collection.each do |record|
with_body_row do |row|
@body_row_callbacks.each { |callback| callback.call(row, record) }
row_content(row, record)
end
end
end
def inspect
"#<#{self.class.name} collection: #{collection.inspect}>"
end
delegate :header?, :body?, to: :@current_row
def row
@current_row
end
def record
@current_record
end
# When rendering a row we pass the table to the row instead of the row itself. This lets the table define the
# column entry points so it's easy to define column extensions in subclasses. When a user wants to set html
# attributes on the row, they will call `row.html_attributes = { ... }`, so we need to proxy that call to the
# current row (if set).
def html_attributes=(attributes)
if row.present?
row.html_attributes = attributes
else
@html_attributes = HtmlAttributes.options_to_html_attributes(attributes)
end
end
# Generates a column from values rendered as text.
#
# @param column [Symbol] the column's name, called as a method on the record
# @param label [String|nil] the label to use for the column header
# @param heading [boolean] if true, data cells will use `th` tags
# @param ** [Hash] HTML attributes to be added to column cells
# @param & [Proc] optional block to wrap the cell content
#
# If a block is provided, it will be called with the cell component as an argument.
# @yieldparam cell [Katalyst::Tables::CellComponent] the cell component
#
# @return [void]
#
# @example Render a generic text column for any value that supports `to_s`
# <% row.text :name %> # label =>
Name | , data => John Doe |
def text(column, label: nil, heading: false, **, &)
with_cell(Tables::CellComponent.new(
collection:, row:, column:, record:, label:, heading:, **,
), &)
end
alias cell text
# Generates a column from boolean values rendered as "Yes" or "No".
#
# @param column [Symbol] the column's name, called as a method on the record
# @param label [String|nil] the label to use for the column header
# @param heading [boolean] if true, data cells will use `th` tags
# @param ** [Hash] HTML attributes to be added to column cells
# @param & [Proc] optional block to alter the cell content
#
# If a block is provided, it will be called with the boolean cell component as an argument.
# @yieldparam cell [Katalyst::Tables::Cells::BooleanComponent] the cell component
#
# @return [void]
#
# @example Render a boolean column indicating whether the record is active
# <% row.boolean :active %> # => Yes |
def boolean(column, label: nil, heading: false, **, &)
with_cell(Tables::Cells::BooleanComponent.new(
collection:, row:, column:, record:, label:, heading:, **,
), &)
end
# Generates a column from date values rendered using I18n.l.
# The default format is :default, can be configured or overridden.
#
# @param column [Symbol] the column's name, called as a method on the record
# @param label [String|nil] the label to use for the column header
# @param heading [boolean] if true, data cells will use `th` tags
# @param format [Symbol] the I18n date format to use when rendering
# @param relative [Boolean] if true, the date may be shown as a relative date (if within 5 days)
# @param ** [Hash] HTML attributes to be added to column cells
#
# If a block is provided, it will be called with the date cell component as an argument.
# @yieldparam cell [Katalyst::Tables::Cells::DateComponent] the cell component
#
# @return [void]
#
# @example Render a date column describing when the record was created
# <% row.date :created_at %> # => 29 Feb 2024 |
def date(column, label: nil, heading: false, format: Tables.config.date_format, relative: true, **, &)
with_cell(Tables::Cells::DateComponent.new(
collection:, row:, column:, record:, label:, heading:, format:, relative:, **,
), &)
end
# Generates a column from datetime values rendered using I18n.l.
# The default format is :default, can be configured or overridden.
#
# @param column [Symbol] the column's name, called as a method on the record
# @param label [String|nil] the label to use for the column header
# @param heading [boolean] if true, data cells will use `th` tags
# @param format [Symbol] the I18n datetime format to use when rendering
# @param relative [Boolean] if true, the datetime may be(if today) shown as a relative date/time
# @param ** [Hash] HTML attributes to be added to column cells
# @param & [Proc] optional block to alter the cell content
#
# If a block is provided, it will be called with the date time cell component as an argument.
# @yieldparam cell [Katalyst::Tables::Cells::DateTimeComponent] the cell component
#
# @return [void]
#
# @example Render a datetime column describing when the record was created
# <% row.datetime :created_at %> # => 29 Feb 2024, 5:00pm |
def datetime(column, label: nil, heading: false, format: Tables.config.datetime_format, relative: true, **, &)
with_cell(Tables::Cells::DateTimeComponent.new(
collection:, row:, column:, record:, label:, heading:, format:, relative:, **,
), &)
end
# Generates a column from numeric values formatted appropriately.
#
# @param column [Symbol] the column's name, called as a method on the record
# @param label [String|nil] the label to use for the column header
# @param heading [boolean] if true, data cells will use `th` tags
# @param ** [Hash] HTML attributes to be added to column cells
# @param & [Proc] optional block to alter the cell content
#
# If a block is provided, it will be called with the number cell component as an argument.
# @yieldparam cell [Katalyst::Tables::Cells::NumberComponent] the cell component
#
# @return [void]
#
# @example Render the number of comments on a post
# <% row.number :comment_count %> # => 0 |
def number(column, label: nil, heading: false, **, &)
with_cell(Tables::Cells::NumberComponent.new(
collection:, row:, column:, record:, label:, heading:, **,
), &)
end
# Generates a column from numeric values rendered using `number_to_currency`.
#
# @param column [Symbol] the column's name, called as a method on the record
# @param label [String|nil] the label to use for the column header
# @param heading [boolean] if true, data cells will use `th` tags
# @param options [Hash] options to be passed to `number_to_currency`
# @param ** [Hash] HTML attributes to be added to column cells
# @param & [Proc] optional block to alter the cell content
#
# If a block is provided, it will be called with the currency cell component as an argument.
# @yieldparam cell [Katalyst::Tables::Cells::CurrencyComponent] the cell component
#
# @return [void]
#
# @example Render a currency column for the price of a product
# <% row.currency :price %> # => $3.50 |
def currency(column, label: nil, heading: false, options: {}, **, &)
with_cell(Tables::Cells::CurrencyComponent.new(
collection:, row:, column:, record:, label:, heading:, options:, **,
), &)
end
# Generates a column containing HTML markup.
#
# @param column [Symbol] the column's name, called as a method on the record
# @param label [String|nil] the label to use for the column header
# @param heading [boolean] if true, data cells will use `th` tags
# @param ** [Hash] HTML attributes to be added to column cells
# @param & [Proc] optional block to alter the cell content
#
# If a block is provided, it will be called with the rich text cell component as an argument.
# @yieldparam cell [Katalyst::Tables::Cells::RichTextComponent] the cell component
#
# @return [void]
#
# @note This method assumes that the method returns HTML-safe content.
# If the content is not HTML-safe, it will be escaped.
#
# @example Render a description column containing HTML markup
# <% row.rich_text :description %> # => Emphasis |
def rich_text(column, label: nil, heading: false, options: {}, **, &)
with_cell(Tables::Cells::RichTextComponent.new(
collection:, row:, column:, record:, label:, heading:, options:, **,
), &)
end
private
# Extension point for subclasses and extensions to customize header row rendering.
def add_header_row_callback(&block)
@header_row_callbacks << block
end
# Extension point for subclasses and extensions to customize body row rendering.
def add_body_row_callback(&block)
@body_row_callbacks << block
end
# Extension point for subclasses and extensions to customize header row cell rendering.
def add_header_row_cell_callback(&block)
@header_row_cell_callbacks << block
end
# Extension point for subclasses and extensions to customize body row cell rendering.
def add_body_row_cell_callback(&block)
@body_row_cell_callbacks << block
end
# @internal proxy calls to row.with_cell and apply callbacks
def with_cell(cell, &)
if row.header?
@header_row_cell_callbacks.each { |callback| callback.call(cell) }
# note, block is silently dropped, it's not used for headers
@current_row.with_cell(cell)
else
@body_row_cell_callbacks.each { |callback| callback.call(cell) }
@current_row.with_cell(cell, &)
end
end
def normalize_collection(collection)
case collection
when Array
Tables::Collection::Array.new.apply(collection)
when ActiveRecord::Relation
Tables::Collection::Base.new.apply(collection)
else
collection
end
end
end
end