diff --git a/lib/generators/ruby_ui/dependencies.yml b/lib/generators/ruby_ui/dependencies.yml index 31ee03a5..1622b83b 100644 --- a/lib/generators/ruby_ui/dependencies.yml +++ b/lib/generators/ruby_ui/dependencies.yml @@ -2,6 +2,16 @@ accordion: js_packages: - "motion" +data_table: + components: + - "Table" + - "Checkbox" + - "NativeSelect" + - "Pagination" + - "DropdownMenu" + - "Input" + - "Button" + alert_dialog: components: - "Button" diff --git a/lib/ruby_ui/data_table/data_table.rb b/lib/ruby_ui/data_table/data_table.rb new file mode 100644 index 00000000..8a64aed2 --- /dev/null +++ b/lib/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/lib/ruby_ui/data_table/data_table_bulk_actions.rb b/lib/ruby_ui/data_table/data_table_bulk_actions.rb new file mode 100644 index 00000000..d5ccb50b --- /dev/null +++ b/lib/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/lib/ruby_ui/data_table/data_table_column_toggle.rb b/lib/ruby_ui/data_table/data_table_column_toggle.rb new file mode 100644 index 00000000..ad20b217 --- /dev/null +++ b/lib/ruby_ui/data_table/data_table_column_toggle.rb @@ -0,0 +1,62 @@ +# 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" + # inline chevron-down SVG (lucide 24px, 1px stroke) + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "16", + height: "16", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "w-4 h-4 ml-1" + ) do |s| + s.polyline(points: "6 9 12 15 18 9") + end + 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/lib/ruby_ui/data_table/data_table_column_visibility_controller.js b/lib/ruby_ui/data_table/data_table_column_visibility_controller.js new file mode 100644 index 00000000..d3cb0584 --- /dev/null +++ b/lib/ruby_ui/data_table/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/lib/ruby_ui/data_table/data_table_controller.js b/lib/ruby_ui/data_table/data_table_controller.js new file mode 100644 index 00000000..1ffb8fb2 --- /dev/null +++ b/lib/ruby_ui/data_table/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/lib/ruby_ui/data_table/data_table_docs.rb b/lib/ruby_ui/data_table/data_table_docs.rb new file mode 100644 index 00000000..7864c2b9 --- /dev/null +++ b/lib/ruby_ui/data_table/data_table_docs.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +class Views::Docs::DataTable < Views::Base + Row = Struct.new(:id, :name, :email, :salary, :status, keyword_init: true) + + SAMPLE_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"), + Row.new(id: 3, name: "Carol", email: "carol@example.com", salary: 85_000, status: "Active") + ].freeze + + def view_template + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do + component = "DataTable" + render Docs::Header.new( + title: component, + 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." + ) + + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Server-driven table", context: self) do + @@code = <<~RUBY + DataTable(id: "employees") do + DataTableToolbar do + DataTableSearch(path: employees_path, value: @search) + DataTablePerPageSelect(path: employees_path, value: @per_page) + end + + div(class: "rounded-md border") do + Table do + TableHeader do + TableRow do + TableHead { "Name" } + DataTableSortHead(column_key: :email, label: "Email", + sort: @sort, direction: @direction, + path: employees_path) + TableHead(class: "text-right") { "Salary" } + end + end + TableBody do + @rows.each do |r| + TableRow do + TableCell { r.name } + TableCell { r.email } + TableCell(class: "text-right") { r.salary } + end + end + end + end + end + + DataTablePaginationBar do + DataTableSelectionSummary(total_on_page: @rows.size) + DataTablePagination(page: @page, per_page: @per_page, + total_count: @total_count, path: employees_path) + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Selection + bulk actions", context: self) do + @@code = <<~RUBY + FORM_ID = "employees_form" + + DataTable(id: "employees_select") do + DataTableToolbar do + DataTableSearch(path: employees_path, value: @search) + DataTableBulkActions do + Button(type: "submit", form: FORM_ID, + formaction: bulk_delete_employees_path, + formmethod: "post", + variant: :destructive, size: :sm) { "Delete" } + 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() } + TableHead { "Name" } + TableHead { "Email" } + end + end + TableBody do + @rows.each do |r| + TableRow do + TableCell { DataTableRowCheckbox(value: r.id) } + TableCell { r.name } + TableCell { r.email } + end + end + end + end + end + end + + DataTablePaginationBar do + DataTableSelectionSummary(total_on_page: @rows.size) + DataTablePagination(page: @page, per_page: @per_page, + total_count: @total_count, path: employees_path) + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Column visibility", context: self) do + @@code = <<~RUBY + DataTable(id: "employees_cols") 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 + + render Docs::VisualCodeExample.new(title: "Expandable rows", context: self) do + @@code = <<~RUBY + DataTable(id: "employees_expand") do + Table do + TableHeader do + TableRow do + TableHead(class: "w-10") { } + TableHead { "Name" } + TableHead { "Email" } + end + end + TableBody do + @rows.each do |r| + detail_id = "row-\#{r.id}-detail" + TableRow do + TableCell { DataTableExpandToggle(controls: detail_id, label: "Toggle \#{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") do + p { "Salary: $\#{r.salary}" } + p { "Status: \#{r.status}" } + end + end + end + end + end + end + end + RUBY + end + + render Components::ComponentSetup::Tabs.new(component_name: component) + + render Docs::ComponentsTable.new(component_files(component)) + end + end +end diff --git a/lib/ruby_ui/data_table/data_table_expand_toggle.rb b/lib/ruby_ui/data_table/data_table_expand_toggle.rb new file mode 100644 index 00000000..55f43672 --- /dev/null +++ b/lib/ruby_ui/data_table/data_table_expand_toggle.rb @@ -0,0 +1,53 @@ +# 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 + # inline chevron-right SVG (lucide) + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "16", + height: "16", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "h-4 w-4 transition-transform duration-150 group-aria-expanded:rotate-90" + ) do |s| + s.polyline(points: "9 18 15 12 9 6") + end + 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/lib/ruby_ui/data_table/data_table_form.rb b/lib/ruby_ui/data_table/data_table_form.rb new file mode 100644 index 00000000..4708116b --- /dev/null +++ b/lib/ruby_ui/data_table/data_table_form.rb @@ -0,0 +1,39 @@ +# 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 + # In a Rails app, view_context provides a real CSRF token. + # Outside Rails (gem tests), fall back to a placeholder. + if respond_to?(:helpers, true) && helpers.respond_to?(:form_authenticity_token) + helpers.form_authenticity_token + elsif respond_to?(:view_context, true) && view_context.respond_to?(:form_authenticity_token) + view_context.form_authenticity_token + else + "csrf-token-placeholder" + end + end + + def default_attrs + {} + end + end +end diff --git a/lib/ruby_ui/data_table/data_table_pagination.rb b/lib/ruby_ui/data_table/data_table_pagination.rb new file mode 100644 index 00000000..02625c57 --- /dev/null +++ b/lib/ruby_ui/data_table/data_table_pagination.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "cgi" +require_relative "../data_table_pagination_adapters/manual" +require_relative "../data_table_pagination_adapters/pagy" +require_relative "../data_table_pagination_adapters/kaminari" + +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 = build_query(@query.merge(@page_param => p.to_s)) + qs.empty? ? @path : "#{@path}?#{qs}" + end + + def build_query(hash) + hash.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join("&") + 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/lib/ruby_ui/data_table/data_table_pagination_bar.rb b/lib/ruby_ui/data_table/data_table_pagination_bar.rb new file mode 100644 index 00000000..c980890e --- /dev/null +++ b/lib/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/lib/ruby_ui/data_table/data_table_per_page_select.rb b/lib/ruby_ui/data_table/data_table_per_page_select.rb new file mode 100644 index 00000000..d3c88f8b --- /dev/null +++ b/lib/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/lib/ruby_ui/data_table/data_table_row_checkbox.rb b/lib/ruby_ui/data_table/data_table_row_checkbox.rb new file mode 100644 index 00000000..0eba666a --- /dev/null +++ b/lib/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/lib/ruby_ui/data_table/data_table_search.rb b/lib/ruby_ui/data_table/data_table_search.rb new file mode 100644 index 00000000..8e9b01d9 --- /dev/null +++ b/lib/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.nil? || (v.respond_to?(:empty?) && v.empty?) + 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/lib/ruby_ui/data_table/data_table_search_controller.js b/lib/ruby_ui/data_table/data_table_search_controller.js new file mode 100644 index 00000000..0dc4101c --- /dev/null +++ b/lib/ruby_ui/data_table/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/lib/ruby_ui/data_table/data_table_select_all_checkbox.rb b/lib/ruby_ui/data_table/data_table_select_all_checkbox.rb new file mode 100644 index 00000000..d1478f9e --- /dev/null +++ b/lib/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/lib/ruby_ui/data_table/data_table_selection_summary.rb b/lib/ruby_ui/data_table/data_table_selection_summary.rb new file mode 100644 index 00000000..455e5f1c --- /dev/null +++ b/lib/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/lib/ruby_ui/data_table/data_table_sort_head.rb b/lib/ruby_ui/data_table/data_table_sort_head.rb new file mode 100644 index 00000000..d74ff0dd --- /dev/null +++ b/lib/ruby_ui/data_table/data_table_sort_head.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "cgi" + +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 = build_query(next_params) + qs.empty? ? @path : "#{@path}?#{qs}" + end + + def build_query(hash) + hash.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join("&") + 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" + render_sort_svg(icon_name, icon_class) + end + + def render_sort_svg(icon_name, icon_class) + case icon_name + when :chevron_up + # chevron-up: polyline pointing up + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "12", + height: "12", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: icon_class + ) { |s| s.polyline(points: "18 15 12 9 6 15") } + when :chevron_down + # chevron-down: polyline pointing down + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "12", + height: "12", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: icon_class + ) { |s| s.polyline(points: "6 9 12 15 18 9") } + else + # chevrons-up-down + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "12", + height: "12", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: icon_class + ) do |s| + s.polyline(points: "8 15 12 19 16 15") + s.polyline(points: "8 9 12 5 16 9") + end + end + end + end +end diff --git a/lib/ruby_ui/data_table/data_table_toolbar.rb b/lib/ruby_ui/data_table/data_table_toolbar.rb new file mode 100644 index 00000000..e94867a2 --- /dev/null +++ b/lib/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/lib/ruby_ui/data_table_pagination_adapters/kaminari.rb b/lib/ruby_ui/data_table_pagination_adapters/kaminari.rb new file mode 100644 index 00000000..85cc2b14 --- /dev/null +++ b/lib/ruby_ui/data_table_pagination_adapters/kaminari.rb @@ -0,0 +1,19 @@ +# 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/lib/ruby_ui/data_table_pagination_adapters/manual.rb b/lib/ruby_ui/data_table_pagination_adapters/manual.rb new file mode 100644 index 00000000..b038ff1c --- /dev/null +++ b/lib/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/lib/ruby_ui/data_table_pagination_adapters/pagy.rb b/lib/ruby_ui/data_table_pagination_adapters/pagy.rb new file mode 100644 index 00000000..a6e0f0a7 --- /dev/null +++ b/lib/ruby_ui/data_table_pagination_adapters/pagy.rb @@ -0,0 +1,19 @@ +# 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/test/ruby_ui/data_table_bulk_actions_test.rb b/test/ruby_ui/data_table_bulk_actions_test.rb new file mode 100644 index 00000000..e7dc2847 --- /dev/null +++ b/test/ruby_ui/data_table_bulk_actions_test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableBulkActionsTest < ComponentTest + def test_starts_hidden_with_bulk_actions_target_and_renders_children + output = phlex { RubyUI.DataTableBulkActions { "BUTTONS" } } + assert_match(/class="[^"]*hidden[^"]*"/, output) + assert_match(/data-ruby-ui--data-table-target="bulkActions"/, output) + assert_match(/BUTTONS/, output) + end +end diff --git a/test/ruby_ui/data_table_column_toggle_test.rb b/test/ruby_ui/data_table_column_toggle_test.rb new file mode 100644 index 00000000..ef3c4527 --- /dev/null +++ b/test/ruby_ui/data_table_column_toggle_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableColumnToggleTest < ComponentTest + def test_renders_dropdown_with_checkbox_per_column + output = phlex do + RubyUI.DataTableColumnToggle(columns: [ + {key: :email, label: "Email"}, + {key: :salary, label: "Salary"} + ]) + end + assert_match(/Columns/, output) + assert_match(/data-controller="[^"]*ruby-ui--data-table-column-visibility/, output) + assert_match(/data-column-key="email"/, output) + assert_match(/data-column-key="salary"/, output) + assert_match(/Email/, output) + assert_match(/Salary/, output) + end +end diff --git a/test/ruby_ui/data_table_expand_toggle_test.rb b/test/ruby_ui/data_table_expand_toggle_test.rb new file mode 100644 index 00000000..f7911287 --- /dev/null +++ b/test/ruby_ui/data_table_expand_toggle_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableExpandToggleTest < ComponentTest + def test_renders_button_with_aria_attributes_and_delegated_action + output = phlex { RubyUI.DataTableExpandToggle(controls: "emp-1-detail") } + assert_match(/]*type="button"/, output) + assert_match(/aria-expanded="false"/, output) + assert_match(/aria-controls="emp-1-detail"/, output) + assert_match(/aria-label="Toggle row details"/, output) + assert_match(/data-action="[^"]*click->ruby-ui--data-table#toggleRowDetail/, output) + refute_match(/data-controller="ruby-ui--data-table-row-expand"/, output) + end + + def test_accepts_custom_label_and_initial_expanded_state + output = phlex { RubyUI.DataTableExpandToggle(controls: "x", expanded: true, label: "Toggle") } + assert_match(/aria-expanded="true"/, output) + assert_match(/aria-label="Toggle"/, output) + end +end diff --git a/test/ruby_ui/data_table_form_test.rb b/test/ruby_ui/data_table_form_test.rb new file mode 100644 index 00000000..1eff30ea --- /dev/null +++ b/test/ruby_ui/data_table_form_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableFormTest < ComponentTest + def test_renders_form_with_method_post_and_action + output = phlex { RubyUI.DataTableForm(action: "/x") } + assert_match(/]*action="\/x"[^>]*method="post"|]*method="post"[^>]*action="\/x"/, output) + end + + def test_renders_hidden_authenticity_token + output = phlex { RubyUI.DataTableForm() } + assert_match(/]*type="hidden"[^>]*name="authenticity_token"[^>]*value="[^"]+"/, output) + end + + def test_yields_children + output = phlex { RubyUI.DataTableForm() { "INNER" } } + assert_match(/INNER/, output) + end + + def test_renders_form_with_id_attribute_when_given + output = phlex { RubyUI.DataTableForm(id: "my_form") } + assert_match(/]*id="my_form"/, output) + end + + def test_renders_form_with_method_get_when_given + output = phlex { RubyUI.DataTableForm(method: "get") } + assert_match(/]*method="get"/, output) + end + + def test_renders_form_with_method_delete_when_given + output = phlex { RubyUI.DataTableForm(method: "delete") } + assert_match(/]*method="delete"/, output) + end +end diff --git a/test/ruby_ui/data_table_pagination_adapters/kaminari_test.rb b/test/ruby_ui/data_table_pagination_adapters/kaminari_test.rb new file mode 100644 index 00000000..8ff7ac05 --- /dev/null +++ b/test/ruby_ui/data_table_pagination_adapters/kaminari_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "test_helper" +require "ruby_ui/data_table_pagination_adapters/kaminari" + +class RubyUI::DataTablePaginationAdapters::KaminariTest < ComponentTest + CollectionDouble = Data.define(:current_page, :total_pages, :total_count, :limit_value) + + def test_reads_current_page_total_pages_total_count_limit_value + 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/ruby_ui/data_table_pagination_adapters/manual_test.rb b/test/ruby_ui/data_table_pagination_adapters/manual_test.rb new file mode 100644 index 00000000..35d7f916 --- /dev/null +++ b/test/ruby_ui/data_table_pagination_adapters/manual_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "test_helper" +require "ruby_ui/data_table_pagination_adapters/manual" + +class RubyUI::DataTablePaginationAdapters::ManualTest < ComponentTest + def test_computes_total_pages_from_total_count_and_per_page + 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 + + def test_total_pages_is_at_least_1_for_empty_total + adapter = RubyUI::DataTablePaginationAdapters::Manual.new(page: 1, per_page: 10, total_count: 0) + assert_equal 1, adapter.total_pages + end + + def test_coerces_integer_inputs + 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/ruby_ui/data_table_pagination_adapters/pagy_test.rb b/test/ruby_ui/data_table_pagination_adapters/pagy_test.rb new file mode 100644 index 00000000..2c9c1682 --- /dev/null +++ b/test/ruby_ui/data_table_pagination_adapters/pagy_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "test_helper" +require "ruby_ui/data_table_pagination_adapters/pagy" + +class RubyUI::DataTablePaginationAdapters::PagyTest < ComponentTest + PagyDouble = Data.define(:page, :pages, :count, :items) + + def test_reads_page_pages_count_items + 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/ruby_ui/data_table_pagination_bar_test.rb b/test/ruby_ui/data_table_pagination_bar_test.rb new file mode 100644 index 00000000..ff36142e --- /dev/null +++ b/test/ruby_ui/data_table_pagination_bar_test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTablePaginationBarTest < ComponentTest + def test_renders_flex_justify_between_layout_and_children + output = phlex { RubyUI.DataTablePaginationBar { "INNER" } } + assert_match(/class="[^"]*flex[^"]*"/, output) + assert_match(/class="[^"]*justify-between[^"]*"/, output) + assert_match(/INNER/, output) + end +end diff --git a/test/ruby_ui/data_table_pagination_test.rb b/test/ruby_ui/data_table_pagination_test.rb new file mode 100644 index 00000000..5011b24b --- /dev/null +++ b/test/ruby_ui/data_table_pagination_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTablePaginationTest < ComponentTest + def test_accepts_manual_keyword_shortcut + output = phlex { RubyUI.DataTablePagination(page: 2, per_page: 10, total_count: 25, path: "/x", query: {}) } + assert_match(/href="\/x\?page=1"/, output) # Previous + assert_match(/href="\/x\?page=3"/, output) # Next + end + + def test_accepts_pagy_keyword_shortcut_duck_typed_double + pagy_double = Data.define(:page, :pages, :count, :items).new(page: 1, pages: 2, count: 15, items: 10) + output = phlex { RubyUI.DataTablePagination(pagy: pagy_double, path: "/x", query: {}) } + assert_match(/href="\/x\?page=2"/, output) + end + + def test_with_accepts_custom_adapter + custom = Data.define(:current_page, :total_pages, :total_count, :per_page).new(1, 3, 20, 10) + output = phlex { RubyUI.DataTablePagination(with: custom, path: "/x", query: {}) } + assert_match(/href="\/x\?page=2"/, output) + end + + def test_renames_page_param + output = phlex { RubyUI.DataTablePagination(page: 1, per_page: 10, total_count: 30, path: "/x", query: {}, page_param: "p") } + assert_match(/p=2/, output) + end + + def test_raises_when_no_adapter_and_no_manual_args + assert_raises(ArgumentError) { RubyUI::DataTablePagination.new(path: "/x", query: {}) } + end + + def test_window_kwarg_widens_numbered_page_range + out_narrow = phlex { RubyUI.DataTablePagination(page: 10, per_page: 1, total_count: 20, path: "/x", query: {}, window: 1) } + out_wide = phlex { RubyUI.DataTablePagination(page: 10, per_page: 1, total_count: 20, path: "/x", query: {}, window: 2) } + refute_match(/page=8/, out_narrow) + assert_match(/page=8/, out_wide) + end +end diff --git a/test/ruby_ui/data_table_per_page_select_test.rb b/test/ruby_ui/data_table_per_page_select_test.rb new file mode 100644 index 00000000..dff84747 --- /dev/null +++ b/test/ruby_ui/data_table_per_page_select_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTablePerPageSelectTest < ComponentTest + def test_renders_get_form_with_select_and_options + output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", value: 25, options: [5, 10, 25, 50]) } + assert_match(/]*(method="get"[^>]*action="\/x"|action="\/x"[^>]*method="get")/, output) + assert_match(/name="per_page"/, output) + assert_match(/value="25"[^>]*selected|selected[^>]*value="25"/, output) + assert_match(/onchange="this\.form\.requestSubmit\(\)"/, output) + end + + def test_renames_param_via_name + output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", name: "size") } + assert_match(/name="size"/, output) + end + + def test_includes_given_options + output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", options: [5, 10, 25]) } + assert_match(/]*value="5"/, output) + assert_match(/]*value="10"/, output) + assert_match(/]*value="25"/, output) + end +end diff --git a/test/ruby_ui/data_table_row_checkbox_test.rb b/test/ruby_ui/data_table_row_checkbox_test.rb new file mode 100644 index 00000000..d2ea00c5 --- /dev/null +++ b/test/ruby_ui/data_table_row_checkbox_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableRowCheckboxTest < ComponentTest + def test_renders_checkbox_input_with_name_and_value + output = phlex { RubyUI.DataTableRowCheckbox(value: 42) } + assert_match(/]*type="checkbox"/, output) + assert_match(/name="ids\[\]"/, output) + assert_match(/value="42"/, output) + end + + def test_accepts_custom_name + output = phlex { RubyUI.DataTableRowCheckbox(value: 1, name: "selected[]") } + assert_match(/name="selected\[\]"/, output) + end + + def test_carries_stimulus_target_and_action + output = phlex { RubyUI.DataTableRowCheckbox(value: 1) } + assert_match(/data-ruby-ui--data-table-target="rowCheckbox"/, output) + assert_match(/data-action="[^"]*change->ruby-ui--data-table#toggleRow/, output) + end + + def test_aria_label_contains_the_value + output = phlex { RubyUI.DataTableRowCheckbox(value: 7) } + assert_match(/aria-label="Select row 7"/, output) + end + + def test_custom_aria_label_via_label_kwarg + output = phlex { RubyUI.DataTableRowCheckbox(value: 1, label: "Select Alice Johnson") } + assert_match(/aria-label="Select Alice Johnson"/, output) + end +end diff --git a/test/ruby_ui/data_table_search_test.rb b/test/ruby_ui/data_table_search_test.rb new file mode 100644 index 00000000..7c295ef6 --- /dev/null +++ b/test/ruby_ui/data_table_search_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableSearchTest < ComponentTest + def test_renders_get_form_with_search_input + output = phlex { RubyUI.DataTableSearch(path: "/x", value: "alice", name: "search") } + assert_match(/]*method="get"[^>]*action="\/x"/, output) + assert_match(/name="search"/, output) + assert_match(/value="alice"/, output) + end + + def test_renames_param_via_name + output = phlex { RubyUI.DataTableSearch(path: "/x", name: "q") } + assert_match(/name="q"/, output) + end + + def test_emits_data_turbo_frame_when_frame_id_given + output = phlex { RubyUI.DataTableSearch(path: "/x", frame_id: "employees") } + assert_match(/data-turbo-frame="employees"/, output) + end + + def test_emits_debounce_controller_and_delay_value_and_action_by_default + output = phlex { RubyUI.DataTableSearch(path: "/x") } + assert_match(/data-controller="ruby-ui--data-table-search"/, output) + assert_match(/data-ruby-ui--data-table-search-delay-value="300"/, output) + assert_match(/data-action="input->ruby-ui--data-table-search#submit"/, output) + end + + def test_debounce_500_sets_custom_delay + output = phlex { RubyUI.DataTableSearch(path: "/x", debounce: 500) } + assert_match(/data-ruby-ui--data-table-search-delay-value="500"/, output) + end + + def test_debounce_false_disables_auto_submit + output = phlex { RubyUI.DataTableSearch(path: "/x", debounce: false) } + refute_match(/data-controller="ruby-ui--data-table-search"/, output) + refute_match(/data-ruby-ui--data-table-search-delay-value/, output) + end + + def test_debounce_0_disables_auto_submit + output = phlex { RubyUI.DataTableSearch(path: "/x", debounce: 0) } + refute_match(/data-controller="ruby-ui--data-table-search"/, output) + end + + def test_preserved_params_emits_hidden_inputs_for_each_key + output = phlex do + RubyUI.DataTableSearch(path: "/x", name: "search", + preserved_params: {"sort" => "name", "direction" => "asc", "per_page" => "10"}) + end + assert_match(/]*type="hidden"[^>]*name="sort"[^>]*value="name"/, output) + assert_match(/]*type="hidden"[^>]*name="direction"[^>]*value="asc"/, output) + assert_match(/]*type="hidden"[^>]*name="per_page"[^>]*value="10"/, output) + end + + def test_preserved_params_skips_blank_values + output = phlex do + RubyUI.DataTableSearch(path: "/x", preserved_params: {"sort" => "", "direction" => nil}) + end + refute_match(/name="sort"/, output) + refute_match(/name="direction"/, output) + end + + def test_preserved_params_skips_the_search_param_itself + output = phlex do + RubyUI.DataTableSearch(path: "/x", name: "q", preserved_params: {"q" => "alice", "sort" => "name"}) + end + refute_match(/]*type="hidden"[^>]*name="q"/, output) + assert_match(/name="sort"/, output) + end +end diff --git a/test/ruby_ui/data_table_select_all_checkbox_test.rb b/test/ruby_ui/data_table_select_all_checkbox_test.rb new file mode 100644 index 00000000..8721114c --- /dev/null +++ b/test/ruby_ui/data_table_select_all_checkbox_test.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableSelectAllCheckboxTest < ComponentTest + def test_carries_select_all_target_toggle_all_action_and_aria_label + output = phlex { RubyUI.DataTableSelectAllCheckbox() } + assert_match(/]*type="checkbox"/, output) + assert_match(/data-ruby-ui--data-table-target="selectAll"/, output) + assert_match(/data-action="[^"]*change->ruby-ui--data-table#toggleAll/, output) + assert_match(/aria-label="Select all"/, output) + end +end diff --git a/test/ruby_ui/data_table_selection_summary_test.rb b/test/ruby_ui/data_table_selection_summary_test.rb new file mode 100644 index 00000000..f7728365 --- /dev/null +++ b/test/ruby_ui/data_table_selection_summary_test.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableSelectionSummaryTest < ComponentTest + def test_renders_selection_count_text_with_target + output = phlex { RubyUI.DataTableSelectionSummary(total_on_page: 10) } + assert_match(/0 of 10 row\(s\) selected\./, output) + assert_match(/data-ruby-ui--data-table-target="selectionSummary"/, output) + end +end diff --git a/test/ruby_ui/data_table_sort_head_test.rb b/test/ruby_ui/data_table_sort_head_test.rb new file mode 100644 index 00000000..127b32b9 --- /dev/null +++ b/test/ruby_ui/data_table_sort_head_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableSortHeadTest < ComponentTest + def test_renders_th_with_sort_link_cycling_nil_to_asc + output = phlex { RubyUI.DataTableSortHead(column_key: :name, label: "Name", path: "/x", query: {}) } + assert_match(/ "alice"}) } + assert_match(/search=alice/, output) + end + + def test_renames_sort_and_direction_params + output = phlex { RubyUI.DataTableSortHead(column_key: :name, label: "Name", sort_param: "sort_by", direction_param: "sort_dir", path: "/x", query: {}) } + assert_match(/sort_by=name/, output) + assert_match(/sort_dir=asc/, output) + end + + def test_custom_page_param_is_dropped_from_next_href_when_sorting + output = phlex { RubyUI.DataTableSortHead(column_key: :name, label: "Name", page_param: "p", path: "/x", query: {"p" => "3", "search" => "bob"}) } + refute_match(/[?&]p=/, output) + assert_match(/search=bob/, output) + end +end diff --git a/test/ruby_ui/data_table_test.rb b/test/ruby_ui/data_table_test.rb new file mode 100644 index 00000000..41269d12 --- /dev/null +++ b/test/ruby_ui/data_table_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableTest < ComponentTest + def test_renders_turbo_frame_with_given_id + output = phlex { RubyUI.DataTable(id: "employees") } + assert_match %r{]*id="employees"[^>]*target="_top"}, output + end + + def test_sets_data_controller_on_inner_div + output = phlex { RubyUI.DataTable(id: "x") } + assert_match(/data-controller="ruby-ui--data-table"/, output) + end + + def test_does_not_render_a_form_wrapper + output = phlex { RubyUI.DataTable(id: "x") } + refute_match(/
]*class="[^"]*flex[^"]*"/, output) + assert_match(/INNER/, output) + end +end