diff --git a/.gitignore b/.gitignore index 011574f2..3f5c8c1b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # Ignore bundler config. /.bundle +/vendor/bundle # Ignore all logfiles and tempfiles. /log/* @@ -46,3 +47,6 @@ yarn-error.log # Pnpm .pnpm-store + +# Local design artifacts (not checked in) +/docs/superpowers/ diff --git a/app/components/docs/visual_code_example.rb b/app/components/docs/visual_code_example.rb index 9f8f2757..84189039 100644 --- a/app/components/docs/visual_code_example.rb +++ b/app/components/docs/visual_code_example.rb @@ -78,8 +78,8 @@ def render_preview_tab(&block) end def iframe_preview - div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border", data: {controller: "iframe-theme"}) do - div(class: "absolute inset-0 hidden w-[1600px] bg-background md:block") do + div(class: "relative min-h-[500px] w-full overflow-hidden rounded-md border", data: {controller: "iframe-theme"}) do + div(class: "absolute inset-0 hidden w-full bg-background md:block") do iframe(src: @src, class: "size-full", data: {iframe_theme_target: "iframe"}) end end @@ -87,7 +87,7 @@ def iframe_preview def raw_preview div(class: "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative rounded-md border") do - div(class: "preview flex min-h-[350px] w-full justify-center p-10 items-center") do + div(class: "preview min-h-[350px] w-full p-6") do decoded_code = CGI.unescapeHTML(@display_code) @context.instance_eval(decoded_code) end diff --git a/app/components/ruby_ui/data_table/data_table.rb b/app/components/ruby_ui/data_table/data_table.rb new file mode 100644 index 00000000..8a64aed2 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module RubyUI + class DataTable < Base + register_element :turbo_frame, tag: "turbo-frame" + + def initialize(id:, **attrs) + @id = id + super(**attrs) + end + + def view_template(&block) + turbo_frame(id: @id, target: "_top") do + div(**attrs) do + yield if block + end + end + end + + private + + def default_attrs + { + class: "w-full space-y-4", + data: {controller: "ruby-ui--data-table"} + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_bulk_actions.rb b/app/components/ruby_ui/data_table/data_table_bulk_actions.rb new file mode 100644 index 00000000..d5ccb50b --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_bulk_actions.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableBulkActions < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "hidden items-center gap-2", + data: {"ruby-ui--data-table-target": "bulkActions"} + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_column_toggle.rb b/app/components/ruby_ui/data_table/data_table_column_toggle.rb new file mode 100644 index 00000000..9bf311f8 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_column_toggle.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableColumnToggle < Base + def initialize(columns:, **attrs) + @columns = columns + super(**attrs) + end + + def view_template + div(**attrs) do + render RubyUI::DropdownMenu.new do + render RubyUI::DropdownMenuTrigger.new do + render RubyUI::Button.new(variant: :outline, size: :sm) do + plain "Columns" + raw view_context.lucide_icon("chevron-down", class: "w-4 h-4 ml-1") + end + end + render RubyUI::DropdownMenuContent.new do + @columns.each do |col| + label(class: "flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent") do + input( + type: "checkbox", + checked: true, + class: "h-4 w-4 rounded border border-input accent-primary cursor-pointer", + data: { + column_key: col[:key].to_s, + action: "change->ruby-ui--data-table-column-visibility#toggle" + } + ) + span { plain col[:label] } + end + end + end + end + end + end + + private + + def default_attrs + { + class: "relative", + data: {controller: "ruby-ui--data-table-column-visibility"} + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_expand_toggle.rb b/app/components/ruby_ui/data_table/data_table_expand_toggle.rb new file mode 100644 index 00000000..445a05ba --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_expand_toggle.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableExpandToggle < Base + def initialize(controls:, expanded: false, label: "Toggle row details", **attrs) + @controls = controls + @expanded = expanded + @label = label + super(**attrs) + end + + def view_template + button( + type: "button", + aria_expanded: @expanded.to_s, + aria_controls: @controls, + aria_label: @label, + data: { + action: "click->ruby-ui--data-table#toggleRowDetail" + }, + **attrs + ) do + render_icon + end + end + + private + + def render_icon + raw view_context.lucide_icon("chevron-right", class: "h-4 w-4 transition-transform duration-150 group-aria-expanded:rotate-90") + end + + def default_attrs + { + class: "group inline-flex items-center justify-center h-8 w-8 rounded-md hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_form.rb b/app/components/ruby_ui/data_table/data_table_form.rb new file mode 100644 index 00000000..cb45e675 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_form.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableForm < Base + def initialize(action: "", method: "post", id: nil, **attrs) + @action = action + @method = method + @id = id + super(**attrs) + end + + def view_template(&block) + form_attrs = {action: @action, method: @method} + form_attrs[:id] = @id if @id + form(**form_attrs, **attrs) do + input(type: "hidden", name: "authenticity_token", value: csrf_token) + yield if block + end + end + + private + + def csrf_token + view_context.form_authenticity_token + end + + def default_attrs + {} + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_pagination.rb b/app/components/ruby_ui/data_table/data_table_pagination.rb new file mode 100644 index 00000000..ab5be1c0 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_pagination.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module RubyUI + class DataTablePagination < Base + def initialize(with: nil, pagy: nil, kaminari: nil, page: nil, per_page: nil, total_count: nil, page_param: "page", path: "", query: {}, window: 1, **attrs) + @adapter = resolve_adapter(with:, pagy:, kaminari:, page:, per_page:, total_count:) + @page_param = page_param + @path = path + @query = query.to_h.transform_keys(&:to_s) + @window = window + super(**attrs) + end + + def view_template + render RubyUI::Pagination.new(class: "mx-0 w-auto justify-end", **attrs) do + render RubyUI::PaginationContent.new do + prev_item + number_items + next_item + end + end + end + + private + + def resolve_adapter(with:, pagy:, kaminari:, page:, per_page:, total_count:) + return with if with + return RubyUI::DataTablePaginationAdapters::Pagy.new(pagy) if pagy + return RubyUI::DataTablePaginationAdapters::Kaminari.new(kaminari) if kaminari + if page && per_page && total_count + return RubyUI::DataTablePaginationAdapters::Manual.new(page:, per_page:, total_count:) + end + raise ArgumentError, "DataTablePagination requires one of: with:, pagy:, kaminari:, or page:+per_page:+total_count:" + end + + def current = @adapter.current_page + def total = @adapter.total_pages + + def page_href(p) + qs = @query.merge(@page_param => p.to_s).to_query + qs.empty? ? @path : "#{@path}?#{qs}" + end + + def prev_item + if current <= 1 + li do + span(class: "opacity-50 pointer-events-none px-3 h-9 inline-flex items-center text-sm") { plain "Previous" } + end + else + render RubyUI::PaginationItem.new(href: page_href(current - 1)) { plain "Previous" } + end + end + + def next_item + if current >= total + li do + span(class: "opacity-50 pointer-events-none px-3 h-9 inline-flex items-center text-sm") { plain "Next" } + end + else + render RubyUI::PaginationItem.new(href: page_href(current + 1)) { plain "Next" } + end + end + + def number_items + windowed_pages.each do |p| + if p == :gap + render RubyUI::PaginationEllipsis.new + else + render RubyUI::PaginationItem.new(href: page_href(p), active: p == current) { plain p.to_s } + end + end + end + + def windowed_pages + return (1..total).to_a if total <= 7 + pages = [1] + pages << :gap if current - @window > 2 + ((current - @window)..(current + @window)).each { |p| pages << p if p > 1 && p < total } + pages << :gap if current + @window < total - 1 + pages << total + pages + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_pagination_bar.rb b/app/components/ruby_ui/data_table/data_table_pagination_bar.rb new file mode 100644 index 00000000..c980890e --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_pagination_bar.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module RubyUI + class DataTablePaginationBar < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + {class: "flex items-center justify-between gap-4 py-2"} + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_per_page_select.rb b/app/components/ruby_ui/data_table/data_table_per_page_select.rb new file mode 100644 index 00000000..d3c88f8b --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_per_page_select.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module RubyUI + class DataTablePerPageSelect < Base + def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [5, 10, 25, 50], **attrs) + @path = path + @name = name + @value = value + @frame_id = frame_id + @options = options + super(**attrs) + end + + def view_template + form_attrs = {action: @path, method: "get"} + form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id + + form(**attrs.merge(form_attrs)) do + render RubyUI::NativeSelect.new(name: @name, onchange: safe("this.form.requestSubmit()")) do + @options.each do |opt| + option_attrs = {value: opt.to_s} + option_attrs[:selected] = true if opt.to_s == @value.to_s + option(**option_attrs) { plain opt.to_s } + end + end + end + end + + private + + def default_attrs + {} + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_row_checkbox.rb b/app/components/ruby_ui/data_table/data_table_row_checkbox.rb new file mode 100644 index 00000000..0eba666a --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_row_checkbox.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableRowCheckbox < Base + def initialize(value:, name: "ids[]", label: nil, **attrs) + @value = value + @name = name + @label = label + super(**attrs) + end + + def view_template + render RubyUI::Checkbox.new(**attrs) + end + + private + + def default_attrs + { + name: @name, + value: @value, + aria_label: @label || "Select row #{@value}", + data: { + "ruby-ui--data-table-target": "rowCheckbox", + action: "change->ruby-ui--data-table#toggleRow" + } + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_search.rb b/app/components/ruby_ui/data_table/data_table_search.rb new file mode 100644 index 00000000..672aa053 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_search.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSearch < Base + def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", debounce: 300, preserved_params: {}, **attrs) + @path = path + @name = name + @value = value + @frame_id = frame_id + @placeholder = placeholder + @debounce = debounce + @preserved_params = preserved_params + super(**attrs) + end + + def view_template + form_attrs = {method: "get", action: @path} + form_attrs[:data] = form_data + + form(**attrs.merge(form_attrs)) do + render RubyUI::Input.new( + type: :search, + name: @name, + value: @value, + placeholder: @placeholder, + autocomplete: "off" + ) + @preserved_params.each do |k, v| + next if v.blank? + next if k.to_s == @name + input(type: "hidden", name: k.to_s, value: v.to_s) + end + end + end + + private + + def debounce_enabled? + @debounce && @debounce.to_i > 0 + end + + def form_data + base = {} + base[:turbo_frame] = @frame_id if @frame_id + if debounce_enabled? + base[:controller] = "ruby-ui--data-table-search" + base[:"ruby-ui--data-table-search-delay-value"] = @debounce.to_i + base[:action] = "input->ruby-ui--data-table-search#submit" + end + base + end + + def default_attrs + {class: "max-w-sm flex-1"} + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb b/app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb new file mode 100644 index 00000000..d1478f9e --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSelectAllCheckbox < Base + def view_template + render RubyUI::Checkbox.new(**attrs) + end + + private + + def default_attrs + { + aria_label: "Select all", + data: { + "ruby-ui--data-table-target": "selectAll", + action: "change->ruby-ui--data-table#toggleAll" + } + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_selection_summary.rb b/app/components/ruby_ui/data_table/data_table_selection_summary.rb new file mode 100644 index 00000000..455e5f1c --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_selection_summary.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSelectionSummary < Base + def initialize(total_on_page: 0, **attrs) + @total_on_page = total_on_page + super(**attrs) + end + + def view_template + div(**attrs) do + plain "0 of #{@total_on_page} row(s) selected." + end + end + + private + + def default_attrs + { + class: "text-sm text-muted-foreground", + data: {"ruby-ui--data-table-target": "selectionSummary"} + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_sort_head.rb b/app/components/ruby_ui/data_table/data_table_sort_head.rb new file mode 100644 index 00000000..29cedd33 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_sort_head.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSortHead < Base + def initialize(column_key:, label:, sort: nil, direction: nil, sort_param: "sort", direction_param: "direction", page_param: "page", path: "", query: {}, **attrs) + @column_key = column_key + @label = label + @sort = sort + @direction = direction + @sort_param = sort_param + @direction_param = direction_param + @page_param = page_param + @path = path + @query = query.to_h.transform_keys(&:to_s) + super(**attrs) + end + + def view_template + render RubyUI::TableHead.new(class: "text-foreground whitespace-nowrap", **attrs) do + a(href: sort_href, class: "inline-flex items-center gap-1 text-inherit no-underline hover:text-foreground transition-colors") do + plain @label + sort_icon + end + end + end + + private + + def current_direction + (@sort.to_s == @column_key.to_s) ? @direction : nil + end + + def next_params + next_dir = {nil => "asc", "asc" => "desc", "desc" => nil}[current_direction] + base = @query.except(@sort_param, @direction_param, @page_param) + next_dir ? base.merge(@sort_param => @column_key.to_s, @direction_param => next_dir) : base + end + + def sort_href + qs = next_params.to_query + qs.empty? ? @path : "#{@path}?#{qs}" + end + + def sort_icon + icon_name = case current_direction + when "asc" then "chevron-up" + when "desc" then "chevron-down" + else "chevrons-up-down" + end + icon_class = current_direction ? "inline-block w-3 h-3" : "inline-block w-3 h-3 opacity-30" + + raw view_context.lucide_icon(icon_name, class: icon_class) + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_toolbar.rb b/app/components/ruby_ui/data_table/data_table_toolbar.rb new file mode 100644 index 00000000..e94867a2 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_toolbar.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableToolbar < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + {class: "flex items-center justify-between gap-2"} + end + end +end diff --git a/app/components/ruby_ui/data_table_pagination_adapters/kaminari.rb b/app/components/ruby_ui/data_table_pagination_adapters/kaminari.rb new file mode 100644 index 00000000..d9597eec --- /dev/null +++ b/app/components/ruby_ui/data_table_pagination_adapters/kaminari.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module RubyUI + module DataTablePaginationAdapters + class Kaminari + def initialize(collection) + @collection = collection + end + + def current_page = @collection.current_page + def total_pages = @collection.total_pages + def total_count = @collection.total_count + def per_page = @collection.limit_value + end + end +end diff --git a/app/components/ruby_ui/data_table_pagination_adapters/manual.rb b/app/components/ruby_ui/data_table_pagination_adapters/manual.rb new file mode 100644 index 00000000..b038ff1c --- /dev/null +++ b/app/components/ruby_ui/data_table_pagination_adapters/manual.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module RubyUI + module DataTablePaginationAdapters + class Manual + attr_reader :current_page, :per_page, :total_count + + def initialize(page:, per_page:, total_count:) + @current_page = page.to_i + @per_page = [per_page.to_i, 1].max + @total_count = total_count.to_i + end + + def total_pages + [(@total_count.to_f / @per_page).ceil, 1].max + end + end + end +end diff --git a/app/components/ruby_ui/data_table_pagination_adapters/pagy.rb b/app/components/ruby_ui/data_table_pagination_adapters/pagy.rb new file mode 100644 index 00000000..16a418db --- /dev/null +++ b/app/components/ruby_ui/data_table_pagination_adapters/pagy.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module RubyUI + module DataTablePaginationAdapters + class Pagy + def initialize(pagy) + @pagy = pagy + end + + def current_page = @pagy.page + def total_pages = @pagy.pages + def total_count = @pagy.count + def per_page = @pagy.items + end + end +end diff --git a/app/components/shared/components_list.rb b/app/components/shared/components_list.rb index 49c82490..9f2775c1 100644 --- a/app/components/shared/components_list.rb +++ b/app/components/shared/components_list.rb @@ -25,6 +25,7 @@ def components {name: "Combobox", path: docs_combobox_path}, {name: "Command", path: docs_command_path}, {name: "Context Menu", path: docs_context_menu_path}, + {name: "Data Table", path: docs_data_table_path}, {name: "Date Picker", path: docs_date_picker_path}, {name: "Dialog / Modal", path: docs_dialog_path}, {name: "Dropdown Menu", path: docs_dropdown_menu_path}, diff --git a/app/controllers/docs/data_table_demo_controller.rb b/app/controllers/docs/data_table_demo_controller.rb new file mode 100644 index 00000000..92035286 --- /dev/null +++ b/app/controllers/docs/data_table_demo_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Docs + class DataTableDemoController < ApplicationController + layout -> { Views::Layouts::ExamplesLayout } + + def index + employees = DataTableDemoData::EMPLOYEES.dup + + if params[:search].present? + q = params[:search].downcase + employees = employees.select { |e| e.name.downcase.include?(q) || e.email.downcase.include?(q) } + end + + if params[:sort].present? + col = params[:sort].to_sym + if employees.first&.respond_to?(col) + employees = employees.sort_by do |e| + v = e.send(col) + v.is_a?(Numeric) ? v : v.to_s.downcase + end + employees = employees.reverse if params[:direction] == "desc" + end + end + + @total_count = employees.size + @per_page = (params[:per_page] || 5).to_i.clamp(1, 100) + @total_pages = [(@total_count.to_f / @per_page).ceil, 1].max + @page = (params[:page] || 1).to_i.clamp(1, @total_pages) + + offset = (@page - 1) * @per_page + @employees = employees.slice(offset, @per_page) || [] + + render Views::Docs::DataTableDemo::Index.new( + employees: @employees, + total_count: @total_count, + page: @page, + per_page: @per_page, + sort: params[:sort], + direction: params[:direction], + search: params[:search] + ) + end + + def bulk_delete + ids = Array(params[:ids]).map(&:to_s) + flash[:notice] = "Would delete: #{ids.join(", ")}" + redirect_to docs_data_table_demo_path + end + + def bulk_export + ids = Array(params[:ids]).map(&:to_s) + flash[:notice] = "Would export: #{ids.join(", ")}" + redirect_to docs_data_table_demo_path + end + end +end diff --git a/app/controllers/docs/data_table_demo_data.rb b/app/controllers/docs/data_table_demo_data.rb new file mode 100644 index 00000000..a826b3e1 --- /dev/null +++ b/app/controllers/docs/data_table_demo_data.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Docs + module DataTableDemoData + EMPLOYEES = [ + {id: 1, name: "Alice Johnson", email: "alice.johnson@example.com", department: "Engineering", status: "Active", salary: 95_000}, + {id: 2, name: "Bob Smith", email: "bob.smith@example.com", department: "Design", status: "Active", salary: 82_000}, + {id: 3, name: "Carol White", email: "carol.white@example.com", department: "Product", status: "On Leave", salary: 88_000}, + {id: 4, name: "David Brown", email: "david.brown@example.com", department: "Engineering", status: "Active", salary: 102_000}, + {id: 5, name: "Eve Davis", email: "eve.davis@example.com", department: "Marketing", status: "Inactive", salary: 74_000}, + {id: 6, name: "Frank Miller", email: "frank.miller@example.com", department: "Engineering", status: "Active", salary: 98_000}, + {id: 7, name: "Grace Lee", email: "grace.lee@example.com", department: "HR", status: "Active", salary: 60_000}, + {id: 8, name: "Henry Wilson", email: "henry.wilson@example.com", department: "Finance", status: "Active", salary: 85_000}, + {id: 9, name: "Iris Martinez", email: "iris.martinez@example.com", department: "Design", status: "Inactive", salary: 79_000}, + {id: 10, name: "Jack Taylor", email: "jack.taylor@example.com", department: "Engineering", status: "Active", salary: 110_000}, + {id: 11, name: "Karen Anderson", email: "karen.anderson@example.com", department: "Marketing", status: "Active", salary: 76_000}, + {id: 12, name: "Liam Thomas", email: "liam.thomas@example.com", department: "Product", status: "Active", salary: 92_000}, + {id: 13, name: "Mia Jackson", email: "mia.jackson@example.com", department: "Engineering", status: "On Leave", salary: 96_000}, + {id: 14, name: "Noah Harris", email: "noah.harris@example.com", department: "Finance", status: "Active", salary: 89_000}, + {id: 15, name: "Olivia Clark", email: "olivia.clark@example.com", department: "HR", status: "Active", salary: 68_000}, + {id: 16, name: "Paul Lewis", email: "paul.lewis@example.com", department: "Design", status: "Active", salary: 84_000}, + {id: 17, name: "Quinn Robinson", email: "quinn.robinson@example.com", department: "Engineering", status: "Active", salary: 105_000}, + {id: 18, name: "Rachel Walker", email: "rachel.walker@example.com", department: "Product", status: "Inactive", salary: 87_000}, + {id: 19, name: "Sam Young", email: "sam.young@example.com", department: "Marketing", status: "Active", salary: 72_000}, + {id: 20, name: "Tina Hall", email: "tina.hall@example.com", department: "Finance", status: "Active", salary: 91_000}, + {id: 21, name: "Uma Allen", email: "uma.allen@example.com", department: "Engineering", status: "Active", salary: 99_000}, + {id: 22, name: "Victor Scott", email: "victor.scott@example.com", department: "Design", status: "On Leave", salary: 81_000}, + {id: 23, name: "Wendy Green", email: "wendy.green@example.com", department: "HR", status: "Active", salary: 70_000}, + {id: 24, name: "Xander Baker", email: "xander.baker@example.com", department: "Engineering", status: "Active", salary: 108_000}, + {id: 25, name: "Yara Adams", email: "yara.adams@example.com", department: "Product", status: "Active", salary: 93_000}, + {id: 26, name: "Zoe Nelson", email: "zoe.nelson@example.com", department: "Marketing", status: "Inactive", salary: 73_000}, + {id: 27, name: "Aaron Carter", email: "aaron.carter@example.com", department: "Finance", status: "Active", salary: 86_000}, + {id: 28, name: "Bella Mitchell", email: "bella.mitchell@example.com", department: "Engineering", status: "Active", salary: 101_000}, + {id: 29, name: "Carlos Perez", email: "carlos.perez@example.com", department: "Design", status: "Active", salary: 83_000}, + {id: 30, name: "Diana Roberts", email: "diana.roberts@example.com", department: "Product", status: "Active", salary: 90_000}, + {id: 31, name: "Ethan Turner", email: "ethan.turner@example.com", department: "Engineering", status: "Active", salary: 97_000}, + {id: 32, name: "Fiona Phillips", email: "fiona.phillips@example.com", department: "HR", status: "Inactive", salary: 69_000}, + {id: 33, name: "George Campbell", email: "george.campbell@example.com", department: "Finance", status: "Active", salary: 94_000}, + {id: 34, name: "Hannah Parker", email: "hannah.parker@example.com", department: "Marketing", status: "Active", salary: 77_000}, + {id: 35, name: "Ivan Evans", email: "ivan.evans@example.com", department: "Engineering", status: "On Leave", salary: 103_000}, + {id: 36, name: "Julia Edwards", email: "julia.edwards@example.com", department: "Design", status: "Active", salary: 80_000}, + {id: 37, name: "Kevin Collins", email: "kevin.collins@example.com", department: "Product", status: "Active", salary: 91_000}, + {id: 38, name: "Laura Stewart", email: "laura.stewart@example.com", department: "Engineering", status: "Active", salary: 106_000}, + {id: 39, name: "Marcus Sanchez", email: "marcus.sanchez@example.com", department: "Finance", status: "Active", salary: 88_000}, + {id: 40, name: "Nina Morris", email: "nina.morris@example.com", department: "HR", status: "Active", salary: 72_000}, + {id: 41, name: "Oscar Rogers", email: "oscar.rogers@example.com", department: "Marketing", status: "Inactive", salary: 75_000}, + {id: 42, name: "Penny Reed", email: "penny.reed@example.com", department: "Design", status: "Active", salary: 82_000}, + {id: 43, name: "Quincy Cook", email: "quincy.cook@example.com", department: "Engineering", status: "Active", salary: 100_000}, + {id: 44, name: "Rose Morgan", email: "rose.morgan@example.com", department: "Product", status: "Active", salary: 89_000}, + {id: 45, name: "Steve Bell", email: "steve.bell@example.com", department: "Finance", status: "On Leave", salary: 87_000}, + {id: 46, name: "Tara Murphy", email: "tara.murphy@example.com", department: "Engineering", status: "Active", salary: 104_000}, + {id: 47, name: "Umar Bailey", email: "umar.bailey@example.com", department: "HR", status: "Active", salary: 70_000}, + {id: 48, name: "Vera Rivera", email: "vera.rivera@example.com", department: "Marketing", status: "Active", salary: 78_000}, + {id: 49, name: "William Cooper", email: "william.cooper@example.com", department: "Design", status: "Inactive", salary: 81_000}, + {id: 50, name: "Xena Richardson", email: "xena.richardson@example.com", department: "Engineering", status: "Active", salary: 107_000}, + {id: 51, name: "Yasmine Cox", email: "yasmine.cox@example.com", department: "Product", status: "Active", salary: 92_000}, + {id: 52, name: "Zachary Howard", email: "zachary.howard@example.com", department: "Finance", status: "Active", salary: 85_000}, + {id: 53, name: "Amber Ward", email: "amber.ward@example.com", department: "Engineering", status: "Active", salary: 96_000}, + {id: 54, name: "Blake Torres", email: "blake.torres@example.com", department: "HR", status: "On Leave", salary: 71_000}, + {id: 55, name: "Chloe Peterson", email: "chloe.peterson@example.com", department: "Marketing", status: "Active", salary: 74_000}, + {id: 56, name: "Derek Gray", email: "derek.gray@example.com", department: "Design", status: "Active", salary: 83_000}, + {id: 57, name: "Elena Ramirez", email: "elena.ramirez@example.com", department: "Engineering", status: "Active", salary: 101_000}, + {id: 58, name: "Felix James", email: "felix.james@example.com", department: "Finance", status: "Inactive", salary: 88_000}, + {id: 59, name: "Gina Watson", email: "gina.watson@example.com", department: "Product", status: "Active", salary: 90_000}, + {id: 60, name: "Hugo Brooks", email: "hugo.brooks@example.com", department: "Engineering", status: "Active", salary: 109_000}, + {id: 61, name: "Irene Kelly", email: "irene.kelly@example.com", department: "HR", status: "Active", salary: 68_000}, + {id: 62, name: "Jonas Sanders", email: "jonas.sanders@example.com", department: "Marketing", status: "Active", salary: 76_000}, + {id: 63, name: "Kira Price", email: "kira.price@example.com", department: "Design", status: "On Leave", salary: 80_000}, + {id: 64, name: "Leo Bennett", email: "leo.bennett@example.com", department: "Engineering", status: "Active", salary: 98_000}, + {id: 65, name: "Maya Wood", email: "maya.wood@example.com", department: "Finance", status: "Active", salary: 91_000}, + {id: 66, name: "Nate Barnes", email: "nate.barnes@example.com", department: "Product", status: "Active", salary: 93_000}, + {id: 67, name: "Odessa Ross", email: "odessa.ross@example.com", department: "Engineering", status: "Inactive", salary: 97_000}, + {id: 68, name: "Pierce Henderson", email: "pierce.henderson@example.com", department: "HR", status: "Active", salary: 73_000}, + {id: 69, name: "Quinn Coleman", email: "quinn.coleman@example.com", department: "Marketing", status: "Active", salary: 77_000}, + {id: 70, name: "Ruby Jenkins", email: "ruby.jenkins@example.com", department: "Design", status: "Active", salary: 84_000}, + {id: 71, name: "Seth Perry", email: "seth.perry@example.com", department: "Engineering", status: "Active", salary: 103_000}, + {id: 72, name: "Tatum Powell", email: "tatum.powell@example.com", department: "Finance", status: "On Leave", salary: 86_000}, + {id: 73, name: "Uma Long", email: "uma.long@example.com", department: "Product", status: "Active", salary: 89_000}, + {id: 74, name: "Vince Patterson", email: "vince.patterson@example.com", department: "Engineering", status: "Active", salary: 105_000}, + {id: 75, name: "Willa Hughes", email: "willa.hughes@example.com", department: "HR", status: "Active", salary: 69_000}, + {id: 76, name: "Xander Flores", email: "xander.flores@example.com", department: "Marketing", status: "Inactive", salary: 75_000}, + {id: 77, name: "Yolanda Washington", email: "yolanda.washington@example.com", department: "Design", status: "Active", salary: 82_000}, + {id: 78, name: "Zack Butler", email: "zack.butler@example.com", department: "Engineering", status: "Active", salary: 100_000}, + {id: 79, name: "Alicia Simmons", email: "alicia.simmons@example.com", department: "Finance", status: "Active", salary: 87_000}, + {id: 80, name: "Brett Foster", email: "brett.foster@example.com", department: "Product", status: "Active", salary: 92_000}, + {id: 81, name: "Cassie Gonzales", email: "cassie.gonzales@example.com", department: "Engineering", status: "On Leave", salary: 99_000}, + {id: 82, name: "Drew Bryant", email: "drew.bryant@example.com", department: "HR", status: "Active", salary: 71_000}, + {id: 83, name: "Elsa Alexander", email: "elsa.alexander@example.com", department: "Marketing", status: "Active", salary: 78_000}, + {id: 84, name: "Floyd Russell", email: "floyd.russell@example.com", department: "Design", status: "Active", salary: 81_000}, + {id: 85, name: "Greta Griffin", email: "greta.griffin@example.com", department: "Engineering", status: "Active", salary: 107_000}, + {id: 86, name: "Hector Diaz", email: "hector.diaz@example.com", department: "Finance", status: "Inactive", salary: 85_000}, + {id: 87, name: "Isla Hayes", email: "isla.hayes@example.com", department: "Product", status: "Active", salary: 91_000}, + {id: 88, name: "Jared Myers", email: "jared.myers@example.com", department: "Engineering", status: "Active", salary: 102_000}, + {id: 89, name: "Kara Ford", email: "kara.ford@example.com", department: "HR", status: "Active", salary: 70_000}, + {id: 90, name: "Lionel Hamilton", email: "lionel.hamilton@example.com", department: "Marketing", status: "Active", salary: 76_000}, + {id: 91, name: "Mabel Graham", email: "mabel.graham@example.com", department: "Design", status: "On Leave", salary: 83_000}, + {id: 92, name: "Nolan Sullivan", email: "nolan.sullivan@example.com", department: "Engineering", status: "Active", salary: 106_000}, + {id: 93, name: "Opal Wallace", email: "opal.wallace@example.com", department: "Finance", status: "Active", salary: 88_000}, + {id: 94, name: "Preston Woods", email: "preston.woods@example.com", department: "Product", status: "Active", salary: 90_000}, + {id: 95, name: "Queenie Cole", email: "queenie.cole@example.com", department: "Engineering", status: "Inactive", salary: 95_000}, + {id: 96, name: "Regan West", email: "regan.west@example.com", department: "HR", status: "Active", salary: 72_000}, + {id: 97, name: "Spencer Jordan", email: "spencer.jordan@example.com", department: "Marketing", status: "Active", salary: 77_000}, + {id: 98, name: "Tess Owens", email: "tess.owens@example.com", department: "Design", status: "Active", salary: 80_000}, + {id: 99, name: "Uriah Reynolds", email: "uriah.reynolds@example.com", department: "Engineering", status: "Active", salary: 104_000}, + {id: 100, name: "Violet Fisher", email: "violet.fisher@example.com", department: "Finance", status: "Active", salary: 86_000} + ].map { |e| Data.define(*e.keys).new(**e) }.freeze + end +end diff --git a/app/controllers/docs_controller.rb b/app/controllers/docs_controller.rb index 071964b0..f60ca123 100644 --- a/app/controllers/docs_controller.rb +++ b/app/controllers/docs_controller.rb @@ -122,6 +122,10 @@ def context_menu render Views::Docs::ContextMenu.new end + def data_table + render Views::Docs::DataTable.new + end + def date_picker render Views::Docs::DatePicker.new end diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index e92a12d6..0c2e8e14 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -43,6 +43,16 @@ application.register("ruby-ui--command", RubyUi__CommandController) import RubyUi__ContextMenuController from "./ruby_ui/context_menu_controller" application.register("ruby-ui--context-menu", RubyUi__ContextMenuController) +import RubyUi__DataTableController from "./ruby_ui/data_table_controller" +application.register("ruby-ui--data-table", RubyUi__DataTableController) + +import RubyUi__DataTableColumnVisibilityController from "./ruby_ui/data_table_column_visibility_controller" +application.register("ruby-ui--data-table-column-visibility", RubyUi__DataTableColumnVisibilityController) + + +import RubyUi__DataTableSearchController from "./ruby_ui/data_table_search_controller" +application.register("ruby-ui--data-table-search", RubyUi__DataTableSearchController) + import RubyUi__DialogController from "./ruby_ui/dialog_controller" application.register("ruby-ui--dialog", RubyUi__DialogController) diff --git a/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js b/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js new file mode 100644 index 00000000..d3cb0584 --- /dev/null +++ b/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js @@ -0,0 +1,14 @@ +// app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + toggle(event) { + const key = event.target.dataset.columnKey; + const visible = event.target.checked; + const root = this.element.closest('[data-controller~="ruby-ui--data-table"]'); + if (!root) return; + root + .querySelectorAll(`[data-column="${key}"]`) + .forEach((el) => el.classList.toggle("hidden", !visible)); + } +} diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js new file mode 100644 index 00000000..1ffb8fb2 --- /dev/null +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -0,0 +1,57 @@ +// app/javascript/controllers/ruby_ui/data_table_controller.js +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = [ + "selectAll", + "rowCheckbox", + "selectionSummary", + "selectionBar", + "bulkActions", + ]; + + connect() { + this.updateState(); + } + + toggleAll(event) { + const checked = event.target.checked; + this.rowCheckboxTargets.forEach((cb) => { + cb.checked = checked; + }); + this.updateState(); + } + + toggleRow() { + this.updateState(); + } + + toggleRowDetail(event) { + const button = event.currentTarget; + const id = button.getAttribute("aria-controls"); + if (!id) return; + const target = document.getElementById(id); + if (!target) return; + const expanded = button.getAttribute("aria-expanded") === "true"; + button.setAttribute("aria-expanded", String(!expanded)); + target.classList.toggle("hidden", expanded); + } + + updateState() { + const total = this.rowCheckboxTargets.length; + const selected = this.rowCheckboxTargets.filter((cb) => cb.checked).length; + + if (this.hasSelectAllTarget) { + this.selectAllTarget.checked = total > 0 && selected === total; + this.selectAllTarget.indeterminate = selected > 0 && selected < total; + } + + if (this.hasSelectionSummaryTarget) { + this.selectionSummaryTarget.textContent = `${selected} of ${total} row(s) selected.`; + } + + if (this.hasBulkActionsTarget) { + this.bulkActionsTarget.classList.toggle("hidden", selected === 0); + } + } +} diff --git a/app/javascript/controllers/ruby_ui/data_table_search_controller.js b/app/javascript/controllers/ruby_ui/data_table_search_controller.js new file mode 100644 index 00000000..0dc4101c --- /dev/null +++ b/app/javascript/controllers/ruby_ui/data_table_search_controller.js @@ -0,0 +1,62 @@ +import { Controller } from "@hotwired/stimulus"; + +// Module-level map survives controller disconnect/connect across Turbo Frame swaps. +// Keyed by the search form's action URL. +const PENDING_FOCUS = new Map(); + +export default class extends Controller { + static values = { delay: { type: Number, default: 300 } }; + + connect() { + this.timer = null; + this.beforeFrameRender = this.captureBeforeRender.bind(this); + document.addEventListener("turbo:before-frame-render", this.beforeFrameRender); + // New instance after a Turbo Frame swap — check for captured state. + this.restoreIfPending(); + } + + disconnect() { + clearTimeout(this.timer); + document.removeEventListener("turbo:before-frame-render", this.beforeFrameRender); + } + + submit(event) { + if (event && event.type !== "input") return; + clearTimeout(this.timer); + if (this.delayValue <= 0) return; + this.timer = setTimeout(() => this.element.requestSubmit(), this.delayValue); + } + + captureBeforeRender() { + const input = this.input(); + if (!input || document.activeElement !== input) return; + PENDING_FOCUS.set(this.key(), { + selectionStart: input.selectionStart, + selectionEnd: input.selectionEnd + }); + } + + restoreIfPending() { + const state = PENDING_FOCUS.get(this.key()); + if (!state) return; + PENDING_FOCUS.delete(this.key()); + const input = this.input(); + if (!input) return; + input.focus(); + const len = input.value.length; + try { + input.setSelectionRange( + Math.min(state.selectionStart ?? len, len), + Math.min(state.selectionEnd ?? len, len) + ); + } catch (e) {} + } + + input() { + return this.element.querySelector('input[type="search"]'); + } + + key() { + return this.element.action || "_"; + } +} diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb new file mode 100644 index 00000000..d9b1a82e --- /dev/null +++ b/app/views/docs/data_table.rb @@ -0,0 +1,373 @@ +# app/views/docs/data_table.rb +# frozen_string_literal: true + +class Views::Docs::DataTable < Views::Base + # Stub data used by code-snippet previews (examples 2-6) + Row = Struct.new(:id, :name, :email, :salary, :status, keyword_init: true) + + def view_template + # Stubs so instance_eval'd preview snippets don't raise NameError + @rows = [ + Row.new(id: 1, name: "Alice", email: "alice@example.com", salary: 90_000, status: "Active"), + Row.new(id: 2, name: "Bob", email: "bob@example.com", salary: 75_000, status: "Inactive") + ] + @page = 1 + @per_page = 10 + @total = 2 + + div(class: "mx-auto w-full py-10 space-y-10") do + render Docs::Header.new( + title: "Data Table", + description: "A Hotwire-first data table. Every interaction (sort, search, pagination) is a Rails request answered with HTML, swapped via Turbo Frame. Row selection uses form-first submission." + ) + + # ── Example 1: Complete demo (primary) ───────────────────────────────── + Heading(level: 2) { "Complete demo" } + p(class: "-mt-6") { "Full feature set — search, sort, numbered pagination, per-page, select-all, row checkboxes, bulk actions, row actions dropdown, column visibility, badge cells." } + + render Docs::VisualCodeExample.new(title: "Complete demo", src: "/docs/data_table_demo", context: self) do + <<~RUBY + FORM_ID = "employees_form" + + DataTable(id: "employees_list") do + DataTableToolbar do + DataTableSearch(path: docs_data_table_demo_path, frame_id: "employees_list", value: @search) + div(class: "flex items-center gap-2") do + DataTableColumnToggle(columns: [ + {key: :email, label: "Email"}, + {key: :department, label: "Department"} + ]) + DataTablePerPageSelect(path: docs_data_table_demo_path, value: @per_page) + DataTableBulkActions do + Button(type: "submit", form: FORM_ID, formaction: bulk_delete_path, formmethod: "post", variant: :destructive, size: :sm) { "Delete" } + Button(type: "submit", form: FORM_ID, formaction: bulk_export_path, formmethod: "post", variant: :outline, size: :sm) { "Export" } + end + end + end + + DataTableForm(id: FORM_ID, action: "") do + div(class: "rounded-md border") do + Table do + TableHeader do + TableRow do + TableHead(class: "w-10") { DataTableSelectAllCheckbox() } + DataTableSortHead(column_key: :name, label: "Name", sort: @sort, direction: @direction, path: docs_data_table_demo_path) + DataTableSortHead(column_key: :salary, label: "Salary", sort: @sort, direction: @direction, path: docs_data_table_demo_path) + end + end + TableBody do + @employees.each do |e| + TableRow do + TableCell { DataTableRowCheckbox(value: e.id) } + TableCell { e.name } + TableCell { e.salary } + end + end + end + end + end + end + + DataTablePaginationBar do + DataTableSelectionSummary(total_on_page: @employees.size) + DataTablePagination(page: @page, per_page: @per_page, total_count: @total_count, path: docs_data_table_demo_path) + end + end + RUBY + end + + # ── Example 2: Server-driven (search + sort + pagination) ───────────── + Heading(level: 2) { "Server-driven" } + p(class: "-mt-6") { "Turbo Frame GET on each sort/search/page. No client-only state." } + + render Docs::VisualCodeExample.new(title: "Server-driven", context: self) do + <<~RUBY + DataTable(id: "server") do + DataTableToolbar do + # Pass preserved_params so sort/direction survive a new search. + DataTableSearch(path: my_path, preserved_params: {"sort" => @sort, "direction" => @direction}.compact_blank) + end + + Table do + TableHeader do + TableRow do + DataTableSortHead(column_key: :name, label: "Name", path: my_path) + end + end + TableBody do + @rows.each { |r| TableRow { TableCell { r.name } } } + end + end + + DataTablePagination(page: @page, per_page: @per_page, total_count: @total, path: my_path) + end + RUBY + end + + # ── Example 3: Selection + bulk actions ─────────────────────────────── + Heading(level: 2) { "Selection + bulk actions" } + p(class: "-mt-6") { "DataTableBulkActions is a plain slot — put any Phlex content inside. Row checkboxes are elements inside DataTableForm. Bulk action buttons submit that form with the selected IDs via HTML5 form-association attributes." } + + render Docs::VisualCodeExample.new(title: "Selection + bulk actions", context: self) do + <<~RUBY + DataTable(id: "selection") do + DataTableToolbar do + div # empty left slot — or place filters here + DataTableBulkActions do + Button(type: "submit", form: "selection_form", + formaction: bulk_delete_path, formmethod: "post", + data: {turbo_confirm: "Delete selected?"}, + variant: :destructive, size: :sm) { "Delete" } + Button(type: "submit", form: "selection_form", + formaction: bulk_export_path, formmethod: "post", + variant: :outline, size: :sm) { "Export" } + end + end + + DataTableForm(id: "selection_form", action: "") do + Table do + TableHeader do + TableRow do + TableHead { DataTableSelectAllCheckbox() } + TableHead { "Name" } + end + end + TableBody do + @rows.each do |r| + TableRow do + TableCell { DataTableRowCheckbox(value: r.id) } + TableCell { r.name } + end + end + end + end + end + + DataTableSelectionSummary(total_on_page: @rows.size) + end + RUBY + end + + Heading(level: 3) { "Bulk action button attributes" } + p { "Because the submit buttons live inside DataTableToolbar (outside DataTableForm), you must use HTML5 form-association attributes to wire them up. Server receives params[:ids] as an array." } + + Table do + TableHeader do + TableRow do + TableHead { "Attribute" } + TableHead { "Required" } + TableHead { "Purpose" } + end + end + TableBody do + [ + ['type: "submit"', "yes", "native submit button"], + ["form: FORM_ID", "yes (button is outside DataTableForm)", "HTML5 form-association — lets the button submit a form located elsewhere in the DOM"], + ["formaction: \"/path\"", "yes", "target URL, overrides the form's action"], + ["formmethod: \"post\"", "yes", "HTTP verb, overrides the form's method"], + ["formnovalidate: true", "optional", "skip HTML5 validation"], + ["data: {turbo_confirm: \"Are you sure?\"}", "optional", "Rails/Turbo confirmation dialog before submit"] + ].each do |attr, required, purpose| + TableRow do + TableCell { code(class: "font-mono text-xs") { plain attr } } + TableCell { plain required } + TableCell { plain purpose } + end + end + end + end + + p { "For simpler bulk actions that include CSRF and Turbo confirms out of the box, you can use Rails' #{code(class: "font-mono text-xs") { "button_to" }} helper — e.g. #{code(class: "font-mono text-xs") { 'button_to "Delete", path, method: :delete, form: {data: {turbo_confirm: "..."}}' }} — the button will carry a nested form that submits to the given path." } + + Heading(level: 3) { "Rails controller example" } + p { "Your endpoint receives the selected IDs as params[:ids] (an array of strings):" } + + Codeblock(<<~RUBY, syntax: :ruby) + class EmployeesController < ApplicationController + def bulk_delete + ids = Array(params[:ids]).map(&:to_i) + Employee.where(id: ids).destroy_all + redirect_to employees_path, notice: "Deleted \#{ids.size} employees" + end + + def bulk_export + ids = Array(params[:ids]).map(&:to_i) + employees = Employee.where(id: ids) + send_data employees.to_csv, filename: "employees.csv" + end + end + RUBY + + # ── Example 4: Column visibility ────────────────────────────────────── + Heading(level: 2) { "Column visibility" } + p(class: "-mt-6") { "Client-side toggle. Hidden columns get `hidden` class via data-column attribute matching." } + p { "Column visibility is client-side and resets on every Turbo Frame swap (sort/search/page re-renders). If you need it to persist, encode it in a URL param (e.g. `?columns=name,status`) or store in localStorage." } + + render Docs::VisualCodeExample.new(title: "Column visibility", context: self) do + <<~RUBY + DataTable(id: "columns") do + DataTableToolbar do + DataTableColumnToggle(columns: [ + {key: :email, label: "Email"}, + {key: :salary, label: "Salary"} + ]) + end + + Table do + TableHeader do + TableRow do + TableHead { "Name" } + TableHead(data: {column: "email"}) { "Email" } + TableHead(data: {column: "salary"}) { "Salary" } + end + end + TableBody do + @rows.each do |r| + TableRow do + TableCell { r.name } + TableCell(data: {column: "email"}) { r.email } + TableCell(data: {column: "salary"}) { r.salary } + end + end + end + end + end + RUBY + end + + # ── Example 5: Custom cell renderers ────────────────────────────────── + Heading(level: 2) { "Custom cell renderers" } + p(class: "-mt-6") { "Plain Ruby helpers for badge/date/currency — the gem does not ship renderers." } + + render Docs::VisualCodeExample.new(title: "Custom cell renderers", context: self) do + <<~RUBY + def status_badge(status) + variant = {"Active" => :success, "Inactive" => :destructive}.fetch(status, :outline) + Badge(variant: variant, size: :sm) { plain status } + end + + DataTable(id: "renderers") do + Table do + TableHeader do + TableRow do + TableHead { "Name" } + TableHead { "Status" } + TableHead(class: "text-right") { "Salary" } + end + end + TableBody do + @rows.each do |r| + TableRow do + TableCell { r.name } + TableCell { status_badge(r.status) } + TableCell(class: "text-right") { plain view_context.number_to_currency(r.salary, precision: 0) } + end + end + end + end + end + RUBY + end + + # ── Example 6: Expandable rows ──────────────────────────────────────── + Heading(level: 2) { "Expandable rows" } + p(class: "-mt-6") { "Toggle a detail region below each row. Accessible: aria-expanded, aria-controls, keyboard-focusable button, region role on the expanded content." } + + render Docs::VisualCodeExample.new(title: "Expandable rows", context: self) do + <<~RUBY + DataTable(id: "expand_demo") do + Table do + TableHeader do + TableRow do + TableHead(class: "w-10") { } + TableHead { "Name" } + TableHead { "Role" } + end + end + TableBody do + @rows.each do |r| + detail_id = "row-\#{r.id}-detail" + TableRow do + TableCell { DataTableExpandToggle(controls: detail_id, label: "Toggle details for \#{r.name}") } + TableCell { r.name } + TableCell { r.email } + end + TableRow(id: detail_id, class: "hidden", role: "region") do + TableCell(colspan: 3, class: "bg-muted/40") do + div(class: "p-4 space-y-1") do + p { "Salary: $\#{r.salary}" } + p { "Status: \#{r.status}" } + end + end + end + end + end + end + end + RUBY + end + + # ── Pagination adapters ─────────────────────────────────────────────── + Heading(level: 2) { "Pagination adapters" } + p { "DataTablePagination accepts a pagination source via one of four keyword forms. Each resolves to an internal adapter exposing current_page, total_pages, total_count, and per_page." } + + Heading(level: 3) { "Manual" } + p { "No gem required. Pass page/per_page/total_count directly." } + Codeblock(<<~RUBY, syntax: :ruby) + DataTablePagination( + page: @page, + per_page: @per_page, + total_count: @total_count, + path: employees_path + ) + RUBY + + Heading(level: 3) { "Pagy" } + p { "If you use Pagy, pass the pagy object directly." } + Codeblock(<<~RUBY, syntax: :ruby) + @pagy, @employees = pagy(Employee.all) + + DataTablePagination(pagy: @pagy, path: employees_path) + RUBY + + Heading(level: 3) { "Kaminari" } + p { "If you use Kaminari, pass the paginated collection." } + Codeblock(<<~RUBY, syntax: :ruby) + @employees = Employee.page(params[:page]).per(25) + + DataTablePagination(kaminari: @employees, path: employees_path) + RUBY + + Heading(level: 3) { "Custom adapter" } + p { "Any object responding to current_page, total_pages, total_count and per_page works via the with: keyword. Useful when wrapping a different gem or custom pagination logic." } + Codeblock(<<~RUBY, syntax: :ruby) + class MyAdapter + def initialize(result) + @result = result + end + + def current_page = @result.page + def total_pages = @result.total_pages + def total_count = @result.count + def per_page = @result.limit + end + + DataTablePagination(with: MyAdapter.new(@result), path: employees_path) + RUBY + end + end + + private + + def my_path + "#" + end + + def bulk_delete_path + "#" + end + + def bulk_export_path + "#" + end +end diff --git a/app/views/docs/data_table_demo/index.rb b/app/views/docs/data_table_demo/index.rb new file mode 100644 index 00000000..19c8ee40 --- /dev/null +++ b/app/views/docs/data_table_demo/index.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +class Views::Docs::DataTableDemo::Index < Views::Base + FRAME_ID = "employees_list" + FORM_ID = "employees_form" + + TOGGLABLE_COLUMNS = [ + {key: :email, label: "Email"}, + {key: :department, label: "Department"}, + {key: :status, label: "Status"}, + {key: :salary, label: "Salary"} + ].freeze + + BADGE_VARIANTS = { + "Active" => :success, + "Inactive" => :destructive, + "On Leave" => :warning + }.freeze + + def initialize(employees:, total_count:, page:, per_page:, sort:, direction:, search:) + @employees = employees + @total_count = total_count + @page = page + @per_page = per_page + @sort = sort + @direction = direction + @search = search + end + + def view_template + div(class: "p-6") { render_table } + end + + private + + def render_table + DataTable(id: FRAME_ID) do + DataTableToolbar do + DataTableSearch( + path: docs_data_table_demo_path, + frame_id: FRAME_ID, + value: @search, + placeholder: "Filter emails...", + preserved_params: preserved_query.except("search") + ) + div(class: "flex items-center gap-2") do + DataTableColumnToggle(columns: TOGGLABLE_COLUMNS) + DataTablePerPageSelect( + path: docs_data_table_demo_path, + frame_id: FRAME_ID, + value: @per_page + ) + DataTableBulkActions do + Button(type: "submit", form: FORM_ID, formaction: docs_data_table_demo_bulk_delete_path, formmethod: "post", variant: :destructive, size: :sm) { "Delete" } + Button(type: "submit", form: FORM_ID, formaction: docs_data_table_demo_bulk_export_path, formmethod: "post", variant: :outline, size: :sm) { "Export" } + end + end + end + + DataTableForm(id: FORM_ID, action: "") do + div(class: "rounded-md border") do + Table do + TableHeader do + TableRow do + TableHead(class: "w-10") { DataTableSelectAllCheckbox() } + DataTableSortHead(column_key: :name, label: "Name", sort: @sort, direction: @direction, path: docs_data_table_demo_path, query: preserved_query) + DataTableSortHead(column_key: :email, label: "Email", sort: @sort, direction: @direction, path: docs_data_table_demo_path, query: preserved_query, data: {column: "email"}) + DataTableSortHead(column_key: :department, label: "Department", sort: @sort, direction: @direction, path: docs_data_table_demo_path, query: preserved_query, data: {column: "department"}) + TableHead(data: {column: "status"}) { plain "Status" } + DataTableSortHead(column_key: :salary, label: "Salary", sort: @sort, direction: @direction, path: docs_data_table_demo_path, query: preserved_query, class: "text-right [&>a]:justify-end", data: {column: "salary"}) + TableHead(class: "w-12") + end + end + + TableBody do + if @employees.empty? + TableRow do + TableCell(colspan: 7, class: "h-24 text-center text-muted-foreground") { plain "No results." } + end + else + @employees.each do |e| + TableRow do + TableCell(class: "w-10") { DataTableRowCheckbox(value: e.id, label: "Select row for #{e.name}") } + TableCell(class: "font-medium") { plain e.name } + TableCell(data: {column: "email"}) { plain e.email } + TableCell(data: {column: "department"}) { plain e.department } + TableCell(data: {column: "status"}) do + Badge(variant: BADGE_VARIANTS.fetch(e.status, :outline), size: :sm) { plain e.status } + end + TableCell(class: "text-right", data: {column: "salary"}) { plain view_context.number_to_currency(e.salary, precision: 0, unit: "$") } + TableCell(class: "w-12 text-right") { row_actions(e) } + end + end + end + end + end + end + end + + DataTablePaginationBar do + DataTableSelectionSummary(total_on_page: @employees.size) + DataTablePagination( + page: @page, + per_page: @per_page, + total_count: @total_count, + path: docs_data_table_demo_path, + query: preserved_query + ) + end + end + end + + def row_actions(employee) + DropdownMenu do + DropdownMenuTrigger do + Button(type: "button", variant: :ghost, size: :icon, aria_label: "Open menu") do + raw view_context.lucide_icon("ellipsis-vertical", class: "h-4 w-4") + end + end + DropdownMenuContent do + DropdownMenuLabel { plain "Actions" } + DropdownMenuItem(href: "#") { plain "Copy employee ID" } + DropdownMenuSeparator() + DropdownMenuItem(href: "#") { plain "View details" } + DropdownMenuItem(href: "#") { plain "View payments" } + end + end + end + + def preserved_query + { + "search" => @search, + "sort" => @sort, + "direction" => @direction, + "per_page" => @per_page.to_s + }.compact_blank + end +end diff --git a/app/views/layouts/docs_layout.rb b/app/views/layouts/docs_layout.rb index 85fe4adc..70c0ffb0 100644 --- a/app/views/layouts/docs_layout.rb +++ b/app/views/layouts/docs_layout.rb @@ -17,7 +17,7 @@ def view_template(&block) div(class: "border-b") do div(class: "container px-4 flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10") do render Shared::Sidebar.new - main(class: "relative py-6 lg:gap-10 lg:py-8 xl:grid xl:grid-cols-[1fr_300px]", &block) + main(class: "relative py-6 lg:py-8 w-full min-w-0", &block) end end end diff --git a/config/initializers/ruby_ui.rb b/config/initializers/ruby_ui.rb index a5b0a4d0..eb98b4e8 100644 --- a/config/initializers/ruby_ui.rb +++ b/config/initializers/ruby_ui.rb @@ -15,4 +15,9 @@ module RubyUI ) # Allow using RubyUI::ComponentName instead RubyUI::ComponentName::ComponentName -Rails.autoloaders.main.collapse(Rails.root.join("app/components/ruby_ui/*")) +# data_table_pagination_adapters/ is intentionally excluded from collapse so that +# RubyUI::DataTablePaginationAdapters is a proper module (adapter namespace). +collapse_dirs = Dir.glob(Rails.root.join("app/components/ruby_ui/*")).reject do |path| + path.end_with?("data_table_pagination_adapters") +end +Rails.autoloaders.main.collapse(collapse_dirs) unless collapse_dirs.empty? diff --git a/config/routes.rb b/config/routes.rb index bee40040..32060507 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -64,6 +64,12 @@ get "theme_toggle", to: "docs#theme_toggle", as: :docs_theme_toggle get "tooltip", to: "docs#tooltip", as: :docs_tooltip get "typography", to: "docs#typography", as: :docs_typography + + # DATA TABLE + get "data_table", to: "docs#data_table", as: :docs_data_table + get "data_table_demo", to: "docs/data_table_demo#index", as: :docs_data_table_demo + post "data_table_demo/bulk_delete", to: "docs/data_table_demo#bulk_delete", as: :docs_data_table_demo_bulk_delete + post "data_table_demo/bulk_export", to: "docs/data_table_demo#bulk_export", as: :docs_data_table_demo_bulk_export end match "/404", to: "errors#not_found", via: :all diff --git a/test/components/ruby_ui/data_table/data_table_bulk_actions_test.rb b/test/components/ruby_ui/data_table/data_table_bulk_actions_test.rb new file mode 100644 index 00000000..33124e25 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_bulk_actions_test.rb @@ -0,0 +1,10 @@ +require "test_helper" + +class RubyUI::DataTableBulkActionsTest < ActiveSupport::TestCase + test "starts hidden with bulkActions target + renders children" do + out = RubyUI::DataTableBulkActions.new.call { "BUTTONS" } + assert_match(/class="[^"]*hidden[^"]*"/, out) + assert_match(/data-ruby-ui--data-table-target="bulkActions"/, out) + assert_match(/BUTTONS/, out) + end +end diff --git a/test/components/ruby_ui/data_table/data_table_column_toggle_test.rb b/test/components/ruby_ui/data_table/data_table_column_toggle_test.rb new file mode 100644 index 00000000..4dc433a9 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_column_toggle_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class RubyUI::DataTableColumnToggleTest < ActiveSupport::TestCase + test "renders dropdown with checkbox per column" do + out = render_component(RubyUI::DataTableColumnToggle.new(columns: [ + {key: :email, label: "Email"}, + {key: :salary, label: "Salary"} + ])) + assert_match(/Columns/, out) + assert_match(/data-controller="[^"]*ruby-ui--data-table-column-visibility/, out) + assert_match(/data-column-key="email"/, out) + assert_match(/data-column-key="salary"/, out) + assert_match(/Email/, out) + assert_match(/Salary/, out) + end +end diff --git a/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb b/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb new file mode 100644 index 00000000..f6cedb30 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class RubyUI::DataTableExpandToggleTest < ActiveSupport::TestCase + test "renders a button with aria attributes and delegated action" do + out = render_component(RubyUI::DataTableExpandToggle.new(controls: "emp-1-detail")) + assert_match(/]*type="button"/, out) + assert_match(/aria-expanded="false"/, out) + assert_match(/aria-controls="emp-1-detail"/, out) + assert_match(/aria-label="Toggle row details"/, out) + assert_match(/data-action="[^"]*click->ruby-ui--data-table#toggleRowDetail/, out) + assert_no_match(/data-controller="ruby-ui--data-table-row-expand"/, out) + end + + test "accepts custom label + initial expanded state" do + out = render_component(RubyUI::DataTableExpandToggle.new(controls: "x", expanded: true, label: "Toggle")) + assert_match(/aria-expanded="true"/, out) + assert_match(/aria-label="Toggle"/, out) + end +end diff --git a/test/components/ruby_ui/data_table/data_table_form_test.rb b/test/components/ruby_ui/data_table/data_table_form_test.rb new file mode 100644 index 00000000..8c9ab44c --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_form_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class RubyUI::DataTableFormTest < ActiveSupport::TestCase + test "renders form with method=post and action" do + out = render_component(RubyUI::DataTableForm.new(action: "/x")) + assert_match(/]*action="\/x"[^>]*method="post"|]*method="post"[^>]*action="\/x"/, out) + end + + test "renders hidden authenticity_token" do + out = render_component(RubyUI::DataTableForm.new) + assert_match(/]*type="hidden"[^>]*name="authenticity_token"[^>]*value="[^"]+"/, out) + end + + test "yields children" do + out = render_component(RubyUI::DataTableForm.new) { "INNER" } + assert_match(/INNER/, out) + end + + test "renders form with id attribute when given" do + out = render_component(RubyUI::DataTableForm.new(id: "my_form")) + assert_match(/]*id="my_form"/, out) + end + + test "renders form with method=get when given" do + out = render_component(RubyUI::DataTableForm.new(method: "get")) + assert_match(/]*method="get"/, out) + end + + test "renders form with method=delete when given" do + out = render_component(RubyUI::DataTableForm.new(method: "delete")) + assert_match(/]*method="delete"/, out) + end +end diff --git a/test/components/ruby_ui/data_table/data_table_pagination_bar_test.rb b/test/components/ruby_ui/data_table/data_table_pagination_bar_test.rb new file mode 100644 index 00000000..769ea1ef --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_pagination_bar_test.rb @@ -0,0 +1,10 @@ +require "test_helper" + +class RubyUI::DataTablePaginationBarTest < ActiveSupport::TestCase + test "renders flex justify-between layout + children" do + out = RubyUI::DataTablePaginationBar.new.call { "INNER" } + assert_match(/class="[^"]*flex[^"]*"/, out) + assert_match(/class="[^"]*justify-between[^"]*"/, out) + assert_match(/INNER/, out) + end +end diff --git a/test/components/ruby_ui/data_table/data_table_pagination_test.rb b/test/components/ruby_ui/data_table/data_table_pagination_test.rb new file mode 100644 index 00000000..a48221d2 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_pagination_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class RubyUI::DataTablePaginationTest < ActiveSupport::TestCase + test "accepts manual keyword shortcut" do + out = RubyUI::DataTablePagination.new(page: 2, per_page: 10, total_count: 25, path: "/x", query: {}).call + assert_match(/href="\/x\?page=1"/, out) # Previous + assert_match(/href="\/x\?page=3"/, out) # next + end + + test "accepts pagy keyword shortcut (duck-typed double)" do + pagy_double = Data.define(:page, :pages, :count, :items).new(page: 1, pages: 2, count: 15, items: 10) + out = RubyUI::DataTablePagination.new(pagy: pagy_double, path: "/x", query: {}).call + assert_match(/href="\/x\?page=2"/, out) + end + + test "with: accepts custom adapter" do + custom = Data.define(:current_page, :total_pages, :total_count, :per_page).new(1, 3, 20, 10) + out = RubyUI::DataTablePagination.new(with: custom, path: "/x", query: {}).call + assert_match(/href="\/x\?page=2"/, out) + end + + test "renames page param" do + out = RubyUI::DataTablePagination.new(page: 1, per_page: 10, total_count: 30, path: "/x", query: {}, page_param: "p").call + assert_match(/p=2/, out) + end + + test "raises when no adapter and no manual args" do + assert_raises(ArgumentError) { RubyUI::DataTablePagination.new(path: "/x", query: {}) } + end + + test "window: kwarg widens the numbered page range" do + # 20 pages, on page 10, window:1 → [1, :gap, 9, 10, 11, :gap, 20] + # window:2 → [1, :gap, 8, 9, 10, 11, 12, :gap, 20] + out_narrow = RubyUI::DataTablePagination.new(page: 10, per_page: 1, total_count: 20, path: "/x", query: {}, window: 1).call + out_wide = RubyUI::DataTablePagination.new(page: 10, per_page: 1, total_count: 20, path: "/x", query: {}, window: 2).call + assert_no_match(/page=8/, out_narrow) + assert_match(/page=8/, out_wide) + end +end diff --git a/test/components/ruby_ui/data_table/data_table_per_page_select_test.rb b/test/components/ruby_ui/data_table/data_table_per_page_select_test.rb new file mode 100644 index 00000000..53cd022a --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_per_page_select_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class RubyUI::DataTablePerPageSelectTest < ActiveSupport::TestCase + test "renders GET form with select and options" do + out = RubyUI::DataTablePerPageSelect.new(path: "/x", value: 25, options: [5, 10, 25, 50]).call + assert_match(/]*(method="get"[^>]*action="\/x"|action="\/x"[^>]*method="get")/, out) + assert_match(/name="per_page"/, out) + assert_match(/value="25"[^>]*selected|selected[^>]*value="25"/, out) + assert_match(/onchange="this\.form\.requestSubmit\(\)"/, out) + end + + test "renames param via name:" do + out = RubyUI::DataTablePerPageSelect.new(path: "/x", name: "size").call + assert_match(/name="size"/, out) + end + + test "includes given options" do + out = RubyUI::DataTablePerPageSelect.new(path: "/x", options: [5, 10, 25]).call + assert_match(/]*value="5"/, out) + assert_match(/]*value="10"/, out) + assert_match(/]*value="25"/, out) + end +end diff --git a/test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb b/test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb new file mode 100644 index 00000000..9d0f30d4 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class RubyUI::DataTableRowCheckboxTest < ActiveSupport::TestCase + test "renders " do + out = RubyUI::DataTableRowCheckbox.new(value: 42).call + assert_match(/]*type="checkbox"/, out) + assert_match(/name="ids\[\]"/, out) + assert_match(/value="42"/, out) + end + + test "accepts custom name" do + out = RubyUI::DataTableRowCheckbox.new(value: 1, name: "selected[]").call + assert_match(/name="selected\[\]"/, out) + end + + test "carries Stimulus target + action" do + out = RubyUI::DataTableRowCheckbox.new(value: 1).call + assert_match(/data-ruby-ui--data-table-target="rowCheckbox"/, out) + assert_match(/data-action="[^"]*change->ruby-ui--data-table#toggleRow/, out) + end + + test "ARIA label contains the value" do + out = RubyUI::DataTableRowCheckbox.new(value: 7).call + assert_match(/aria-label="Select row 7"/, out) + end + + test "custom aria-label via label: kwarg" do + out = render_component(RubyUI::DataTableRowCheckbox.new(value: 1, label: "Select Alice Johnson")) + assert_match(/aria-label="Select Alice Johnson"/, out) + end +end diff --git a/test/components/ruby_ui/data_table/data_table_search_test.rb b/test/components/ruby_ui/data_table/data_table_search_test.rb new file mode 100644 index 00000000..7474e7a6 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_search_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableSearchTest < ActiveSupport::TestCase + test "renders GET form with search input" do + out = RubyUI::DataTableSearch.new(path: "/x", value: "alice", name: "search").call + assert_match(/]*method="get"[^>]*action="\/x"/, out) + assert_match(/name="search"/, out) + assert_match(/value="alice"/, out) + end + + test "renames param via name:" do + out = RubyUI::DataTableSearch.new(path: "/x", name: "q").call + assert_match(/name="q"/, out) + end + + test "emits data-turbo-frame when frame_id given" do + out = RubyUI::DataTableSearch.new(path: "/x", frame_id: "employees").call + assert_match(/data-turbo-frame="employees"/, out) + end + + test "emits debounce controller + delay value + action by default" do + out = RubyUI::DataTableSearch.new(path: "/x").call + assert_match(/data-controller="ruby-ui--data-table-search"/, out) + assert_match(/data-ruby-ui--data-table-search-delay-value="300"/, out) + assert_match(/data-action="input->ruby-ui--data-table-search#submit"/, out) + end + + test "debounce: 500 sets custom delay" do + out = RubyUI::DataTableSearch.new(path: "/x", debounce: 500).call + assert_match(/data-ruby-ui--data-table-search-delay-value="500"/, out) + end + + test "debounce: false disables auto-submit" do + out = RubyUI::DataTableSearch.new(path: "/x", debounce: false).call + assert_no_match(/data-controller="ruby-ui--data-table-search"/, out) + assert_no_match(/data-ruby-ui--data-table-search-delay-value/, out) + end + + test "debounce: 0 disables auto-submit" do + out = RubyUI::DataTableSearch.new(path: "/x", debounce: 0).call + assert_no_match(/data-controller="ruby-ui--data-table-search"/, out) + end + + test "preserved_params emits hidden inputs for each key" do + out = RubyUI::DataTableSearch.new(path: "/x", name: "search", preserved_params: {"sort" => "name", "direction" => "asc", "per_page" => "10"}).call + assert_match(/]*type="hidden"[^>]*name="sort"[^>]*value="name"/, out) + assert_match(/]*type="hidden"[^>]*name="direction"[^>]*value="asc"/, out) + assert_match(/]*type="hidden"[^>]*name="per_page"[^>]*value="10"/, out) + end + + test "preserved_params skips blank values" do + out = RubyUI::DataTableSearch.new(path: "/x", preserved_params: {"sort" => "", "direction" => nil}).call + assert_no_match(/name="sort"/, out) + assert_no_match(/name="direction"/, out) + end + + test "preserved_params skips the search param itself" do + out = RubyUI::DataTableSearch.new(path: "/x", name: "q", preserved_params: {"q" => "alice", "sort" => "name"}).call + assert_no_match(/]*type="hidden"[^>]*name="q"/, out) + assert_match(/name="sort"/, out) + end +end diff --git a/test/components/ruby_ui/data_table/data_table_select_all_checkbox_test.rb b/test/components/ruby_ui/data_table/data_table_select_all_checkbox_test.rb new file mode 100644 index 00000000..97ce1260 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_select_all_checkbox_test.rb @@ -0,0 +1,11 @@ +require "test_helper" + +class RubyUI::DataTableSelectAllCheckboxTest < ActiveSupport::TestCase + test "carries selectAll target + toggleAll action + aria-label" do + out = RubyUI::DataTableSelectAllCheckbox.new.call + assert_match(/]*type="checkbox"/, out) + assert_match(/data-ruby-ui--data-table-target="selectAll"/, out) + assert_match(/data-action="[^"]*change->ruby-ui--data-table#toggleAll/, out) + assert_match(/aria-label="Select all"/, out) + end +end diff --git a/test/components/ruby_ui/data_table/data_table_selection_summary_test.rb b/test/components/ruby_ui/data_table/data_table_selection_summary_test.rb new file mode 100644 index 00000000..38d22ac4 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_selection_summary_test.rb @@ -0,0 +1,9 @@ +require "test_helper" + +class RubyUI::DataTableSelectionSummaryTest < ActiveSupport::TestCase + test "renders '0 of N row(s) selected.' with target" do + out = RubyUI::DataTableSelectionSummary.new(total_on_page: 10).call + assert_match(/0 of 10 row\(s\) selected\./, out) + assert_match(/data-ruby-ui--data-table-target="selectionSummary"/, out) + end +end diff --git a/test/components/ruby_ui/data_table/data_table_sort_head_test.rb b/test/components/ruby_ui/data_table/data_table_sort_head_test.rb new file mode 100644 index 00000000..aa27a128 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_sort_head_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableSortHeadTest < ActiveSupport::TestCase + test "renders a with a sort link cycling nil -> asc" do + out = render_component(RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", path: "/x", query: {})) + assert_match(/ next href is desc" do + out = render_component(RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort: "name", direction: "asc", path: "/x", query: {})) + assert_match(/direction=desc/, out) + end + + test "current desc -> next href clears sort (no params)" do + out = render_component(RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort: "name", direction: "desc", path: "/x", query: {})) + assert_match(/href="\/x"/, out) + end + + test "preserves other query params" do + out = render_component(RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", path: "/x", query: {"search" => "alice"})) + assert_match(/search=alice/, out) + end + + test "renames sort/direction params" do + out = render_component(RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort_param: "sort_by", direction_param: "sort_dir", path: "/x", query: {})) + assert_match(/sort_by=name/, out) + assert_match(/sort_dir=asc/, out) + end + + test "custom page_param is dropped from next href when sorting" do + out = render_component(RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", page_param: "p", path: "/x", query: {"p" => "3", "search" => "bob"})) + assert_no_match(/[?&]p=/, out) + assert_match(/search=bob/, out) + end +end diff --git a/test/components/ruby_ui/data_table/data_table_test.rb b/test/components/ruby_ui/data_table/data_table_test.rb new file mode 100644 index 00000000..339754b5 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableTest < ActiveSupport::TestCase + test "renders a turbo-frame with given id" do + output = RubyUI::DataTable.new(id: "employees").call + assert_match %r{]*id="employees"[^>]*target="_top"}, output + end + + test "sets data-controller on inner div" do + output = RubyUI::DataTable.new(id: "x").call + assert_match(/data-controller="ruby-ui--data-table"/, output) + end + + test "does NOT render a form wrapper" do + output = RubyUI::DataTable.new(id: "x").call + assert_no_match(/
]*class="[^"]*flex[^"]*"/, out) + assert_match(/INNER/, out) + end +end diff --git a/test/components/ruby_ui/data_table_pagination_adapters/kaminari_test.rb b/test/components/ruby_ui/data_table_pagination_adapters/kaminari_test.rb new file mode 100644 index 00000000..4cc119d4 --- /dev/null +++ b/test/components/ruby_ui/data_table_pagination_adapters/kaminari_test.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTablePaginationAdapters::KaminariTest < ActiveSupport::TestCase + CollectionDouble = Data.define(:current_page, :total_pages, :total_count, :limit_value) + + test "reads current_page, total_pages, total_count, limit_value" do + coll = CollectionDouble.new(current_page: 3, total_pages: 7, total_count: 61, limit_value: 10) + adapter = RubyUI::DataTablePaginationAdapters::Kaminari.new(coll) + assert_equal 3, adapter.current_page + assert_equal 7, adapter.total_pages + assert_equal 61, adapter.total_count + assert_equal 10, adapter.per_page + end +end diff --git a/test/components/ruby_ui/data_table_pagination_adapters/manual_test.rb b/test/components/ruby_ui/data_table_pagination_adapters/manual_test.rb new file mode 100644 index 00000000..8d7f085f --- /dev/null +++ b/test/components/ruby_ui/data_table_pagination_adapters/manual_test.rb @@ -0,0 +1,22 @@ +require "test_helper" + +class RubyUI::DataTablePaginationAdapters::ManualTest < ActiveSupport::TestCase + test "computes total_pages from total_count and per_page" do + adapter = RubyUI::DataTablePaginationAdapters::Manual.new(page: 2, per_page: 10, total_count: 25) + assert_equal 2, adapter.current_page + assert_equal 10, adapter.per_page + assert_equal 25, adapter.total_count + assert_equal 3, adapter.total_pages + end + + test "total_pages is at least 1 for empty total" do + adapter = RubyUI::DataTablePaginationAdapters::Manual.new(page: 1, per_page: 10, total_count: 0) + assert_equal 1, adapter.total_pages + end + + test "coerces integer inputs" do + adapter = RubyUI::DataTablePaginationAdapters::Manual.new(page: "3", per_page: "5", total_count: "12") + assert_equal 3, adapter.current_page + assert_equal 3, adapter.total_pages + end +end diff --git a/test/components/ruby_ui/data_table_pagination_adapters/pagy_test.rb b/test/components/ruby_ui/data_table_pagination_adapters/pagy_test.rb new file mode 100644 index 00000000..64c03cc1 --- /dev/null +++ b/test/components/ruby_ui/data_table_pagination_adapters/pagy_test.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTablePaginationAdapters::PagyTest < ActiveSupport::TestCase + PagyDouble = Data.define(:page, :pages, :count, :items) + + test "reads page, pages, count, items" do + pagy = PagyDouble.new(page: 2, pages: 5, count: 47, items: 10) + adapter = RubyUI::DataTablePaginationAdapters::Pagy.new(pagy) + assert_equal 2, adapter.current_page + assert_equal 5, adapter.total_pages + assert_equal 47, adapter.total_count + assert_equal 10, adapter.per_page + end +end diff --git a/test/controllers/components_controller_test.rb b/test/controllers/components_controller_test.rb index 148c5811..7d40cf16 100644 --- a/test/controllers/components_controller_test.rb +++ b/test/controllers/components_controller_test.rb @@ -5,7 +5,7 @@ def self.all_docs_routes scope_prefix = "/docs" Rails.application.routes.routes.select do |route| - route.path.spec.to_s.start_with?(scope_prefix) + route.path.spec.to_s.start_with?(scope_prefix) && route.verb == "GET" end.map do |route| { method: route.verb, diff --git a/test/controllers/docs/data_table_demo_controller_test.rb b/test/controllers/docs/data_table_demo_controller_test.rb new file mode 100644 index 00000000..cf01c83e --- /dev/null +++ b/test/controllers/docs/data_table_demo_controller_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "test_helper" + +class Docs::DataTableDemoControllerTest < ActionDispatch::IntegrationTest + test "GET index returns 200" do + get docs_data_table_demo_url + assert_response :success + end + + test "GET index with ?search= filters employees" do + get docs_data_table_demo_url(search: "alice") + assert_response :success + assert_match(/Alice Johnson/, response.body) + assert_no_match(/Bob Smith/, response.body) + end + + test "GET index with ?sort=name&direction=desc sorts" do + get docs_data_table_demo_url(sort: "name", direction: "desc", per_page: 100) + alice_at = response.body.index("Alice Johnson") + violet_at = response.body.index("Violet Fisher") + assert violet_at < alice_at, "Violet should appear before Alice when sorted desc" + end + + test "GET index with ?sort=salary sorts numerically" do + get docs_data_table_demo_url(sort: "salary", direction: "asc", per_page: 5) + assert_match(/Grace Lee/, response.body) + end + + test "GET index paginates" do + get docs_data_table_demo_url(page: 2, per_page: 5) + assert_response :success + end + + test "POST bulk_delete with ids[] redirects + flashes" do + post docs_data_table_demo_bulk_delete_url, params: {ids: ["1", "2"]} + assert_redirected_to docs_data_table_demo_path + follow_redirect! + assert_match(/Would delete: 1, 2/, response.body) + end + + test "POST bulk_export with ids[] redirects + flashes" do + post docs_data_table_demo_bulk_export_url, params: {ids: ["3"]} + assert_redirected_to docs_data_table_demo_path + end + + test "GET index renders row checkboxes with ids[] name" do + get docs_data_table_demo_url(per_page: 5) + assert_match(/name="ids\[\]"[^>]*value="1"|value="1"[^>]*name="ids\[\]"/, response.body) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 1f519045..efde3596 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -9,7 +9,15 @@ class ActiveSupport::TestCase # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all - # Add more helper methods to be used by all tests here... + # Renders a Phlex component with a Rails view context so helpers like + # `view_context.lucide_icon` and `view_context.form_authenticity_token` + # are available. Components that call these must use this helper in tests. + def render_component(component, &block) + controller = ApplicationController.new + controller.request = ActionDispatch::TestRequest.create + vc = controller.view_context + component.call(context: {rails_view_context: vc}, &block) + end end class ActionDispatch::IntegrationTest