diff --git a/.gitignore b/.gitignore index 2e68bcd7..f4a339bb 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ Gemfile.lock /spec/dummy_rails/tmp /spec/dummy_rails/db/*.sqlite3 /spec/dummy_rails/db/*.sqlite3-* +vendor/ +.bundle/ diff --git a/lib/tiny_admin.rb b/lib/tiny_admin.rb index 04466551..4ab6a786 100644 --- a/lib/tiny_admin.rb +++ b/lib/tiny_admin.rb @@ -16,8 +16,8 @@ def configure(&block) block&.call(settings) || settings end - def configure_from_file(file) - settings.reset! + def configure_from_file(file, reset: true) + settings.reset! if reset config = YAML.load_file(file, symbolize_names: true) config.each do |key, value| settings[key] = value diff --git a/lib/tiny_admin/actions/csv_export.rb b/lib/tiny_admin/actions/csv_export.rb new file mode 100644 index 00000000..17d4e057 --- /dev/null +++ b/lib/tiny_admin/actions/csv_export.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "csv" + +module TinyAdmin + module Actions + # CsvExport is a collection action that streams all matching records as a + # CSV file attachment. It honours the same field/attribute config as the + # Index action and applies any active filters, but skips pagination so the + # full dataset is returned. + # + # Register it as a collection action in your resource section: + # + # collection_actions: + # - csv_export: TinyAdmin::Actions::CsvExport + # + # The action respects the resource's +index.attributes+ config for the + # columns to export. If no attributes are configured, all repository fields + # are exported. + # + # Use the +max_export_limit+ option to cap the number of rows returned (default: 10_000). + # Set it to nil to disable the cap (not recommended for large datasets). + class CsvExport < BasicAction + DEFAULT_MAX_EXPORT_LIMIT = 10_000 + + def call(app:, context:, options:) + repository = context.repository + fields_options = attribute_options(options[:attributes]) + fields = repository.fields(options: fields_options) + filters = prepare_filters(fields, context.request.params, options) + records = fetch_all_records(repository, filters, options) + + csv_content = build_csv(records, fields, repository, fields_options) + set_csv_response_headers(app, context.slug) + app.render(inline: csv_content) + end + + private + + def fetch_all_records(repository, filters, options) + limit = options.key?(:max_export_limit) ? options[:max_export_limit] : DEFAULT_MAX_EXPORT_LIMIT + # When limit is nil, fetch all records (use with caution on large datasets). + effective_limit = limit || repository.list(page: 1, limit: 1).last + records, = repository.list(page: 1, limit: effective_limit, filters: filters, sort: options[:sort]) + records + end + + def set_csv_response_headers(app, slug) + filename = "#{slug}-#{Time.now.strftime('%Y%m%d%H%M%S')}.csv" + app.response["Content-Type"] = "text/csv; charset=utf-8" + app.response["Content-Disposition"] = "attachment; filename=\"#{filename}\"" + end + + def prepare_filters(fields, params, options) + filter_config = (options[:filters] || []).map { _1.is_a?(Hash) ? _1 : { field: _1 } } + filter_map = filter_config.to_h { |f| [f[:field], f] } + values = params["q"] || {} + fields.each_with_object({}) do |(name, field), result| + result[field] = { value: values[name], filter: filter_map[name] } if filter_map.key?(name) + end + end + + def build_csv(records, fields, repository, fields_options) + CSV.generate(headers: true) do |csv| + csv << fields.values.map { |f| f.options[:header] || f.title } + + records.each do |record| + attrs = repository.index_record_attrs(record, fields: fields_options) + row = fields.keys.map { |key| attrs[key]&.to_s } + csv << row + end + end + end + end + end +end diff --git a/lib/tiny_admin/actions/index.rb b/lib/tiny_admin/actions/index.rb index 0c15349a..625d5864 100644 --- a/lib/tiny_admin/actions/index.rb +++ b/lib/tiny_admin/actions/index.rb @@ -20,7 +20,8 @@ def call(app:, context:, options:) evaluate_options(options) fields = repository.fields(options: fields_options) filters = prepare_filters(fields) - records, count = repository.list(page: current_page, limit: pagination, filters: filters, sort: options[:sort]) + sort = merge_sort(options[:sort], fields) + records, count = repository.list(page: current_page, limit: pagination, filters: filters, sort: sort) attributes = { actions: context.actions, fields: fields, @@ -28,7 +29,9 @@ def call(app:, context:, options:) links: options[:links], prepare_record: ->(record) { repository.index_record_attrs(record, fields: fields_options) }, records: records, + show_link: options.fetch(:show_link, true), slug: context.slug, + sort_params: @sort_params, title: repository.index_title, widgets: options[:widgets] } @@ -46,7 +49,25 @@ def evaluate_options(options) @repository = context.repository @pagination = options[:pagination] || 10 @current_page = (params["p"] || 1).to_i - @query_string = params_to_s(params.except("p")) + @query_string = params_to_s(params.except("p", "sort")) + @sort_params = params["sort"] + end + + # Merge user-supplied sort params (from query string) with the configured + # sort defaults. Only fields that are actually returned by the repository + # are accepted to prevent arbitrary column injection. + def merge_sort(configured_sort, fields) + raw = params["sort"] + return configured_sort unless raw.is_a?(Hash) + + allowed = fields.keys.map(&:to_s) + dynamic = raw.each_with_object([]) do |(field, dir), list| + next unless allowed.include?(field.to_s) + + direction = dir.to_s.downcase == "desc" ? "DESC" : "ASC" + list << "#{field} #{direction}" + end + dynamic.any? ? dynamic : configured_sort end def prepare_filters(fields) diff --git a/lib/tiny_admin/basic_app.rb b/lib/tiny_admin/basic_app.rb index a14e97f1..f48b14d2 100644 --- a/lib/tiny_admin/basic_app.rb +++ b/lib/tiny_admin/basic_app.rb @@ -19,6 +19,9 @@ def authentication_plugin plugin :render, engine: "html" plugin :sessions, secret: ENV.fetch("TINY_ADMIN_SECRET") { SecureRandom.hex(64) } + # NOTE: The authentication plugin is applied at class-load time. Ensure + # TinyAdmin.configure / TinyAdmin.configure_from_file are called before + # BasicApp (or its subclass Router) is first referenced. plugin authentication_plugin, TinyAdmin.settings.authentication not_found { prepare_page(TinyAdmin.settings.page_not_found).call } diff --git a/lib/tiny_admin/plugins/active_record_repository.rb b/lib/tiny_admin/plugins/active_record_repository.rb index 75b1c0bc..30d57a16 100644 --- a/lib/tiny_admin/plugins/active_record_repository.rb +++ b/lib/tiny_admin/plugins/active_record_repository.rb @@ -46,8 +46,7 @@ def collection def list(page: 1, limit: 10, sort: nil, filters: nil) query = sort ? collection.order(sort) : collection query = apply_filters(query, filters) if filters - page_offset = page.positive? ? (page - 1) * limit : 0 - records = query.offset(page_offset).limit(limit).to_a + records = query.offset(page_offset(page, limit)).limit(limit).to_a [records, query.count] end @@ -57,16 +56,41 @@ def apply_filters(query, filters) next if value.nil? || value == "" query = - case field.type - when :string - value = ActiveRecord::Base.sanitize_sql_like(value.strip) - query.where("#{field.name} LIKE ?", "%#{value}%") + if value.is_a?(Hash) + apply_hash_filter(query, field, value) + elsif value.is_a?(Array) + non_empty = value.reject { |v| v.to_s.empty? } + next if non_empty.empty? + + query.where(field.name => non_empty) else - query.where(field.name => value) + apply_scalar_filter(query, field, value) end end query end + + private + + # Handle range filters: { "gte" => min, "lte" => max } + def apply_hash_filter(query, field, value) + gte = value["gte"] || value[:gte] + lte = value["lte"] || value[:lte] + query = query.where("#{field.name} >= ?", gte) if gte.present? + query = query.where("#{field.name} <= ?", lte) if lte.present? + query + end + + # Handle scalar (single-value) filters. + def apply_scalar_filter(query, field, value) + case field.type + when :string + sanitized = ActiveRecord::Base.sanitize_sql_like(value.strip) + query.where("#{field.name} LIKE ?", "%#{sanitized}%") + else + query.where(field.name => value) + end + end end end end diff --git a/lib/tiny_admin/plugins/base_repository.rb b/lib/tiny_admin/plugins/base_repository.rb index 4e2ffb0c..4b53a23d 100644 --- a/lib/tiny_admin/plugins/base_repository.rb +++ b/lib/tiny_admin/plugins/base_repository.rb @@ -2,6 +2,44 @@ module TinyAdmin module Plugins + # BaseRepository is the contract that every repository plugin must satisfy. + # + # Required methods (must be overridden in subclasses): + # + # fields(options: nil) -> Hash + # Return a hash mapping field name strings to TinyAdmin::Field instances. + # When +options+ is provided it should be a hash of field_name => config + # and only those fields should be returned. + # + # index_record_attrs(record, fields: nil) -> Hash + # Return a hash of attribute values for the given record suitable for + # display in the index (collection) view. When +fields+ is nil, return + # all attributes; otherwise only the specified fields. + # + # show_record_attrs(record, fields: nil) -> Hash + # Same as index_record_attrs but used in the detail (show) view. + # + # index_title -> String + # Return the human-readable title for the collection page. + # + # show_title(record) -> String + # Return the human-readable title for the detail page of the given record. + # + # find(reference) -> Object + # Find and return the record identified by +reference+ (usually a primary + # key string from the URL). Raise BaseRepository::RecordNotFound when no + # record is found. + # + # collection -> Enumerable + # Return a "base scope" representing all records of the resource. + # + # list(page: 1, limit: 10, sort: nil, filters: nil) -> [Array, Integer] + # Return a two-element array: the page of records and the total count. + # +sort+ may be nil, a String/Array accepted by the underlying ORM, or any + # structure the concrete repository understands. + # +filters+ is a Hash + # as built by Actions::Index#prepare_filters. + # class BaseRepository class RecordNotFound < StandardError end @@ -11,6 +49,13 @@ class RecordNotFound < StandardError def initialize(model) @model = model end + + protected + + # Shared helper: compute the zero-based offset for a given page / limit. + def page_offset(page, limit) + page.positive? ? (page - 1) * limit : 0 + end end end end diff --git a/lib/tiny_admin/plugins/sequel_repository.rb b/lib/tiny_admin/plugins/sequel_repository.rb new file mode 100644 index 00000000..c3585e89 --- /dev/null +++ b/lib/tiny_admin/plugins/sequel_repository.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module TinyAdmin + module Plugins + # SequelRepository implements the BaseRepository contract for Sequel datasets. + # + # Usage in config: + # + # sections: + # - slug: posts + # name: Posts + # type: resource + # model: Post # a Sequel::Model subclass + # repository: TinyAdmin::Plugins::SequelRepository + # + # Requires the +sequel+ gem. + class SequelRepository < BaseRepository + def index_record_attrs(record, fields: nil) + return record.values.transform_values(&:to_s) unless fields + + fields.to_h { [_1, record.send(_1)] } + end + + def index_title + title = model.to_s + title.respond_to?(:pluralize) ? title.pluralize : title + end + + # Build Field objects from the model's schema. + def fields(options: nil) + schema_types = model.db_schema.to_h { |col, info| [col.to_s, sequel_type_to_sym(info[:type])] } + + if options + options.to_h do |name, field_options| + [name, TinyAdmin::Field.create_field(name: name, type: schema_types[name.to_s], options: field_options)] + end + else + schema_types.to_h do |name, type| + [name, TinyAdmin::Field.create_field(name: name, type: type)] + end + end + end + + alias show_record_attrs index_record_attrs + + def show_title(record) + "#{model} ##{record.pk}" + end + + def find(reference) + model[reference] || raise(BaseRepository::RecordNotFound, "#{model} with pk=#{reference} not found") + end + + def collection + model.dataset + end + + def list(page: 1, limit: 10, sort: nil, filters: nil) + query = sort ? collection.order(*Array(sort).map { Sequel.lit(_1) }) : collection + query = apply_filters(query, filters) if filters + records = query.offset(page_offset(page, limit)).limit(limit).all + [records, query.count] + end + + def apply_filters(query, filters) + filters.reduce(query) do |q, (field, filter)| + apply_single_filter(q, field, filter) + end + end + + private + + def apply_single_filter(query, field, filter) + value = filter&.dig(:value) + return query if value.nil? || value == "" + + if value.is_a?(Hash) + apply_hash_filter(query, field, value) + elsif value.is_a?(Array) + non_empty = value.reject { |v| v.to_s.empty? } + non_empty.any? ? query.where(Sequel[field.name.to_sym] => non_empty) : query + else + apply_scalar_filter(query, field, value) + end + end + + # Map Sequel schema type symbols to the TinyAdmin field type convention. + def sequel_type_to_sym(type) + case type + when :integer, :bigint, :smallint then :integer + when :boolean then :boolean + when :date then :date + when :datetime, :timestamp then :datetime + when :float, :decimal, :numeric then :float + else :string + end + end + + def apply_hash_filter(query, field, value) + gte = value["gte"] || value[:gte] + lte = value["lte"] || value[:lte] + col = Sequel[field.name.to_sym] + query = query.where(col >= gte) if gte && gte != "" + query = query.where(col <= lte) if lte && lte != "" + query + end + + def apply_scalar_filter(query, field, value) + col = Sequel[field.name.to_sym] + case field.type + when :string + query.where(Sequel.ilike(col, "%#{value.strip}%")) + else + query.where(col => value) + end + end + end + end +end diff --git a/lib/tiny_admin/plugins/simple_auth.rb b/lib/tiny_admin/plugins/simple_auth.rb index b88c39f6..d67eed86 100644 --- a/lib/tiny_admin/plugins/simple_auth.rb +++ b/lib/tiny_admin/plugins/simple_auth.rb @@ -4,21 +4,101 @@ module TinyAdmin module Plugins + # SimpleAuth provides session-based password authentication via Warden. + # + # Configuration options: + # password - Password hash. Accepts: + # * BCrypt hash (starts with "$2b$" or "$2a$") – recommended. + # * SHA-512 hex digest – kept for backward compatibility + # (deprecated; prefer BCrypt). + # username - Optional plain-text username. When set the login form must + # provide a matching "username" param in addition to "secret". + # max_attempts - Max failed login attempts before lockout (default: 5). + # lockout_seconds - Seconds to lock out after max_attempts (default: 300). + # + # Disclaimer: this plugin is provided as an example. For production use, + # implement your own authentication backed by a proper user model. module SimpleAuth + # BCrypt hash prefixes (2a, 2b, 2y are all valid bcrypt identifiers). + BCRYPT_PREFIX = /\A\$2[aby]\$/ + class << self def configure(app, opts = {}) opts ||= {} - password_hash = opts[:password] || ENV.fetch("ADMIN_PASSWORD_HASH", nil) + config = extract_config(opts) + register_warden_strategy(config) + configure_warden_manager(app, opts) + end + + # Exposed for use inside the Warden strategy block. + def password_valid?(secret, password_hash) + return false unless password_hash + + if password_hash.match?(BCRYPT_PREFIX) + require "bcrypt" + BCrypt::Password.new(password_hash).is_password?(secret) + else + Digest::SHA512.hexdigest(secret) == password_hash + end + end + + private + def extract_config(opts) + { + password_hash: opts[:password] || ENV.fetch("ADMIN_PASSWORD_HASH", nil), + username: opts[:username] || ENV.fetch("ADMIN_USERNAME", nil), + max_attempts: (opts[:max_attempts] || 5).to_i, + lockout_seconds: (opts[:lockout_seconds] || 300).to_i + } + end + + def register_warden_strategy(config) Warden::Strategies.add(:secret) do define_method(:authenticate!) do - secret = params["secret"] || "" - return fail(:invalid_credentials) if Digest::SHA512.hexdigest(secret) != password_hash + rate_check(config) && username_check(config) && password_check(config) + end - success!(app: "TinyAdmin") + define_method(:rate_check) do |_cfg| + locked_until = env["rack.session"]["tiny_admin_locked_until"] + return fail!(:locked_out) if locked_until && Time.now.to_i < locked_until + + true + end + + define_method(:username_check) do |cfg| + return true unless cfg[:username] + return fail!(:invalid_credentials) if params["username"].to_s != cfg[:username] + + true + end + + define_method(:password_check) do |cfg| + secret = params["secret"].to_s + if SimpleAuth.password_valid?(secret, cfg[:password_hash]) + env["rack.session"].delete("tiny_admin_failed_attempts") + env["rack.session"].delete("tiny_admin_locked_until") + success!(app: "TinyAdmin") + else + record_failed_attempt(cfg) + end + end + + define_method(:record_failed_attempt) do |cfg| + attempts = env["rack.session"]["tiny_admin_failed_attempts"].to_i + 1 + if attempts >= cfg[:max_attempts] + env["rack.session"]["tiny_admin_locked_until"] = Time.now.to_i + cfg[:lockout_seconds] + env["rack.session"].delete("tiny_admin_failed_attempts") + fail!(:locked_out) + else + env["rack.session"]["tiny_admin_failed_attempts"] = attempts + fail!(:invalid_credentials) + end end end + end + def configure_warden_manager(app, opts) app.opts[:login_form] = opts[:login_form] || TinyAdmin::Views::Pages::SimpleAuthLogin app.use Warden::Manager do |manager| manager.default_strategies :secret diff --git a/lib/tiny_admin/router.rb b/lib/tiny_admin/router.rb index 40bd30b2..4f960b83 100644 --- a/lib/tiny_admin/router.rb +++ b/lib/tiny_admin/router.rb @@ -83,7 +83,8 @@ def setup_resource_routes(req, slug, options:) end def setup_collection_routes(req, slug, options:, repository:) - action_options = options[:index] || {} + # Merge show_link so the index view knows whether the show action is enabled + action_options = (options[:index] || {}).merge(show_link: options[:only].include?(:show)) # Custom actions custom_actions = setup_custom_actions( @@ -95,7 +96,7 @@ def setup_collection_routes(req, slug, options:, repository:) ) # Index - if options[:only].include?(:index) || options[:only].include?("index") + if options[:only].include?(:index) req.is do authorize!(:resource_index, slug) do context = Context.new( @@ -127,7 +128,7 @@ def setup_member_routes(req, slug, options:, repository:) ) # Show - if options[:only].include?(:show) || options[:only].include?("show") + if options[:only].include?(:show) req.is do authorize!(:resource_show, slug) do context = Context.new( diff --git a/lib/tiny_admin/settings.rb b/lib/tiny_admin/settings.rb index 0f7d0ce4..a104d645 100644 --- a/lib/tiny_admin/settings.rb +++ b/lib/tiny_admin/settings.rb @@ -40,9 +40,16 @@ class Settings root_path sections scripts + strict_config style_links ].freeze + # Valid section type values (as symbols). + VALID_SECTION_TYPES = %i[content page resource url].freeze + + # Repository interface methods that must be present on a repository class. + REPOSITORY_INTERFACE = %i[fields index_record_attrs show_record_attrs index_title show_title find collection list].freeze + attr_reader :store OPTIONS.each do |option| @@ -79,6 +86,8 @@ def load_settings end end + validate_config! + @store ||= TinyAdmin::Store.new(self) self.root_path = "/" if root_path == "" @@ -87,6 +96,7 @@ def load_settings authentication[:logout] ||= TinyAdmin::Section.new(name: "logout", slug: "logout", path: logout_path) end store.prepare_sections(sections, logout: authentication[:logout]) + @loaded = true end @@ -125,5 +135,50 @@ def resolve_class(class_name, setting:) rescue NameError => e raise NameError, "TinyAdmin: invalid class '#{class_name}' for setting '#{setting}' - #{e.message}" end + + # Validate the current configuration, raising or warning about problems. + # When strict_config: true is set, all issues raise ArgumentError; otherwise + # they emit a warning via Kernel.warn. + def validate_config! + @options ||= {} + + # Unknown top-level keys + unknown_keys = @options.keys - OPTIONS + unknown_keys.each do |key| + config_problem("unknown configuration key '#{key}' – did you mean one of #{OPTIONS.join(', ')}?") + end + + # Section type validation + Array(sections).each do |section| + next unless section.is_a?(Hash) + + type = section[:type]&.to_sym + next if VALID_SECTION_TYPES.include?(type) + + config_problem( + "section '#{section[:slug]}' has invalid type '#{section[:type]}' – must be one of #{VALID_SECTION_TYPES.join(', ')}" + ) + end + + # Repository interface check + repo = repository + if repo && repo.is_a?(Module) + missing = REPOSITORY_INTERFACE.reject { |m| repo.method_defined?(m) || repo.public_instance_methods.include?(m) } + if missing.any? + config_problem( + "repository '#{repo}' is missing required methods: #{missing.join(', ')}" + ) + end + end + end + + def config_problem(message) + full_message = "TinyAdmin configuration: #{message}" + if strict_config + raise ArgumentError, full_message + else + warn full_message + end + end end end diff --git a/lib/tiny_admin/store.rb b/lib/tiny_admin/store.rb index 1aee10c2..83c35901 100644 --- a/lib/tiny_admin/store.rb +++ b/lib/tiny_admin/store.rb @@ -47,7 +47,7 @@ def add_page_section(slug, section) def add_resource_section(slug, section) resource = section.slice(:resource, :only, :index, :show, :collection_actions, :member_actions) - resource[:only] ||= %i[index show] + resource[:only] = (resource[:only] || %i[index show]).map(&:to_sym) resources[slug] = resource.merge( model: to_class(section[:model]), repository: to_class(section[:repository] || settings.repository) diff --git a/lib/tiny_admin/views/actions/index.rb b/lib/tiny_admin/views/actions/index.rb index 89bacce5..2edd584f 100644 --- a/lib/tiny_admin/views/actions/index.rb +++ b/lib/tiny_admin/views/actions/index.rb @@ -11,7 +11,9 @@ class Index < DefaultLayout :pagination_component, :prepare_record, :records, - :slug + :show_link, + :slug, + :sort_params def view_template super do @@ -61,7 +63,7 @@ def table_header tr { fields.each_value do |field| td(class: "field-header-#{field.name} field-header-type-#{field.type}") { - field.options[:header] || field.title + render_sortable_header(field) } end td { whitespace } @@ -101,9 +103,12 @@ def table_body end end else - a(href: TinyAdmin.route_for(slug, reference: record.id), class: link_class) { - label_for("Show", options: ["actions.index.links.show"]) - } + # Only show the default "Show" link when the show action is enabled for this resource + if show_link != false + a(href: TinyAdmin.route_for(slug, reference: record.id), class: link_class) { + label_for("Show", options: ["actions.index.links.show"]) + } + end end } } @@ -117,6 +122,30 @@ def actions_buttons buttons.update_attributes(actions: actions, slug: slug) render buttons end + + # Render a column header as a sortable link. + # Clicking toggles between ASC and DESC; the current sort direction is + # preserved in the link so the user can always toggle back. + def render_sortable_header(field) + label = field.options[:header] || field.title + current_dir = sort_params.is_a?(Hash) ? sort_params[field.name] : nil + next_dir = current_dir&.downcase == "asc" ? "desc" : "asc" + href = "?#{sort_query_string(field.name, next_dir)}" + indicator = case current_dir&.downcase + when "asc" then " ▲" + when "desc" then " ▼" + end + a(href: href, class: "sort-link text-decoration-none text-reset") { + plain "#{label}#{indicator}" + } + end + + # Build a query string that retains existing filter/page params but sets + # the sort field and direction. + def sort_query_string(field_name, direction) + base = params&.except("sort", "p") || {} + params_to_s(base.merge("sort" => { field_name => direction })) + end end end end diff --git a/lib/tiny_admin/views/components/filters_form.rb b/lib/tiny_admin/views/components/filters_form.rb index f917275c..4e9a6e3e 100644 --- a/lib/tiny_admin/views/components/filters_form.rb +++ b/lib/tiny_admin/views/components/filters_form.rb @@ -13,30 +13,22 @@ def view_template filter_data = filter[:filter] div(class: "mb-3") { label(for: "filter-#{name}", class: "form-label") { field.title } - case filter_data[:type]&.to_sym || field.type + filter_type = filter_data[:type]&.to_sym || field.type + case filter_type when :boolean - select(class: "form-select", id: "filter-#{name}", name: "q[#{name}]") { - option(value: "") { "-" } - option(value: "0", selected: filter[:value] == "0") { - TinyAdmin.settings.helper_class.label_for("false", options: ["components.filters_form.boolean.false"]) - } - option(value: "1", selected: filter[:value] == "1") { - TinyAdmin.settings.helper_class.label_for("true", options: ["components.filters_form.boolean.true"]) - } - } + render_boolean_filter(name, filter) when :date input(type: "date", class: "form-control", id: "filter-#{name}", name: "q[#{name}]", value: filter[:value]) when :datetime input(type: "datetime-local", class: "form-control", id: "filter-#{name}", name: "q[#{name}]", value: filter[:value]) when :integer input(type: "number", class: "form-control", id: "filter-#{name}", name: "q[#{name}]", value: filter[:value]) + when :range + render_range_filter(name, filter) when :select - select(class: "form-select", id: "filter-#{name}", name: "q[#{name}]") { - option(value: "") { "-" } - filter_data[:values].each do |value| - option(selected: filter[:value] == value) { value } - end - } + render_select_filter(name, filter, filter_data) + when :association + render_association_filter(name, filter, filter_data) else input(type: "text", class: "form-control", id: "filter-#{name}", name: "q[#{name}]", value: filter[:value]) end @@ -54,6 +46,76 @@ def view_template } } end + + private + + def render_boolean_filter(name, filter) + select(class: "form-select", id: "filter-#{name}", name: "q[#{name}]") { + option(value: "") { "-" } + option(value: "0", selected: filter[:value] == "0") { + TinyAdmin.settings.helper_class.label_for("false", options: ["components.filters_form.boolean.false"]) + } + option(value: "1", selected: filter[:value] == "1") { + TinyAdmin.settings.helper_class.label_for("true", options: ["components.filters_form.boolean.true"]) + } + } + end + + # Renders two number/date inputs for a min–max range filter. + # Values are submitted as q[field][gte] and q[field][lte]. + def render_range_filter(name, filter) + value = filter[:value].is_a?(Hash) ? filter[:value] : {} + div(class: "d-flex gap-2") { + input( + type: "text", + class: "form-control", + id: "filter-#{name}-gte", + name: "q[#{name}][gte]", + placeholder: TinyAdmin.settings.helper_class.label_for("From", options: ["components.filters_form.range.from"]), + value: value["gte"] || value[:gte] + ) + input( + type: "text", + class: "form-control", + id: "filter-#{name}-lte", + name: "q[#{name}][lte]", + placeholder: TinyAdmin.settings.helper_class.label_for("To", options: ["components.filters_form.range.to"]), + value: value["lte"] || value[:lte] + ) + } + end + + # Renders a whose options come from a related model. + # Requires filter_data keys: association (class), value_field, label_field. + def render_association_filter(name, filter, filter_data) + assoc_class = filter_data[:association] + value_field = (filter_data[:value_field] || :id).to_sym + label_field = (filter_data[:label_field] || :name).to_sym + current = filter[:value].to_s + records = assoc_class.respond_to?(:all) ? assoc_class.all : [] + select(class: "form-select", id: "filter-#{name}", name: "q[#{name}]") { + option(value: "") { "-" } + records.each do |record| + val = record.public_send(value_field).to_s + lbl = record.public_send(label_field).to_s + option(value: val, selected: current == val) { lbl } + end + } + end end end end diff --git a/lib/tiny_admin/views/components/widgets.rb b/lib/tiny_admin/views/components/widgets.rb index a45c9309..891cdc3e 100644 --- a/lib/tiny_admin/views/components/widgets.rb +++ b/lib/tiny_admin/views/components/widgets.rb @@ -16,7 +16,9 @@ def view_template @widgets.each_slice(2).each do |row| div(class: "row") { row.each do |widget| - next unless widget < Phlex::HTML + unless widget < Phlex::HTML + raise ArgumentError, "Widget #{widget.inspect} must be a subclass of Phlex::HTML" + end div(class: "col") { div(class: "card") { diff --git a/sig/tiny_admin.rbs b/sig/tiny_admin.rbs index c4d3d2b0..a4e8e9a1 100644 --- a/sig/tiny_admin.rbs +++ b/sig/tiny_admin.rbs @@ -3,7 +3,7 @@ module TinyAdmin def configure: () { (Settings) -> untyped } -> untyped - def configure_from_file: (String) -> void + def configure_from_file: (String, ?reset: bool) -> void def route_for: (String, reference: String?, action: String?, query: String?) -> String diff --git a/sig/tiny_admin/actions/csv_export.rbs b/sig/tiny_admin/actions/csv_export.rbs new file mode 100644 index 00000000..8fb82020 --- /dev/null +++ b/sig/tiny_admin/actions/csv_export.rbs @@ -0,0 +1,13 @@ +module TinyAdmin + module Actions + class CsvExport < BasicAction + def call: (app: BasicApp, context: Context, options: Hash[Symbol, untyped]) -> untyped + + private + + def prepare_filters: (Hash[String, Field], Hash[String, untyped], Hash[Symbol, untyped]) -> Hash[Field, Hash[Symbol, untyped]] + + def build_csv: (Array[untyped], Hash[String, Field], untyped, untyped) -> String + end + end +end diff --git a/sig/tiny_admin/actions/index.rbs b/sig/tiny_admin/actions/index.rbs index 9fa655e5..12eeee5a 100644 --- a/sig/tiny_admin/actions/index.rbs +++ b/sig/tiny_admin/actions/index.rbs @@ -11,6 +11,7 @@ module TinyAdmin attr_reader params: untyped attr_reader query_string: String attr_reader repository: untyped + attr_reader sort_params: untyped def call: (app: BasicApp, context: Context, options: Hash[Symbol, untyped]) -> untyped @@ -18,7 +19,9 @@ module TinyAdmin def evaluate_options: (Hash[Symbol, untyped]) -> void - def prepare_filters: (Hash[untyped, untyped]) -> void + def merge_sort: (untyped?, Hash[String, Field]) -> untyped + + def prepare_filters: (Hash[untyped, untyped]) -> Hash[Field, Hash[Symbol, untyped]] def setup_pagination: (untyped, untyped, total_count: Integer) -> void end diff --git a/sig/tiny_admin/basic_app.rbs b/sig/tiny_admin/basic_app.rbs index 61b2e8ca..978a0405 100644 --- a/sig/tiny_admin/basic_app.rbs +++ b/sig/tiny_admin/basic_app.rbs @@ -1,5 +1,5 @@ module TinyAdmin class BasicApp - def self.authentication_plugin: () -> void + def self.authentication_plugin: () -> untyped end end diff --git a/sig/tiny_admin/plugins/active_record_repository.rbs b/sig/tiny_admin/plugins/active_record_repository.rbs index eda6805b..f07b9330 100644 --- a/sig/tiny_admin/plugins/active_record_repository.rbs +++ b/sig/tiny_admin/plugins/active_record_repository.rbs @@ -18,6 +18,12 @@ module TinyAdmin def show_title: (untyped) -> String alias show_record_attrs index_record_attrs + + private + + def apply_hash_filter: (untyped, Field, Hash[untyped, untyped]) -> untyped + + def apply_scalar_filter: (untyped, Field, untyped) -> untyped end end end diff --git a/sig/tiny_admin/plugins/base_repository.rbs b/sig/tiny_admin/plugins/base_repository.rbs index ea16c1b8..a16b2753 100644 --- a/sig/tiny_admin/plugins/base_repository.rbs +++ b/sig/tiny_admin/plugins/base_repository.rbs @@ -7,6 +7,8 @@ module TinyAdmin attr_reader model: untyped def initialize: (untyped) -> void + + def page_offset: (Integer, Integer) -> Integer end end end diff --git a/sig/tiny_admin/plugins/sequel_repository.rbs b/sig/tiny_admin/plugins/sequel_repository.rbs new file mode 100644 index 00000000..b581c66b --- /dev/null +++ b/sig/tiny_admin/plugins/sequel_repository.rbs @@ -0,0 +1,31 @@ +module TinyAdmin + module Plugins + class SequelRepository < BaseRepository + def apply_filters: (untyped, Enumerable[untyped]) -> untyped + + def collection: () -> untyped + + def fields: (?options: Hash[untyped, untyped]?) -> Hash[String, Field] + + def find: (untyped) -> untyped + + def index_record_attrs: (untyped, ?fields: Hash[untyped, untyped]?) -> Hash[untyped, untyped] + + def index_title: () -> String + + def list: (?page: Integer, ?limit: Integer, ?sort: untyped?, ?filters: untyped?) -> [Array[untyped], Integer] + + def show_title: (untyped) -> String + + alias show_record_attrs index_record_attrs + + private + + def sequel_type_to_sym: (untyped) -> Symbol + + def apply_hash_filter: (untyped, Field, Hash[untyped, untyped]) -> untyped + + def apply_scalar_filter: (untyped, Field, untyped) -> untyped + end + end +end diff --git a/sig/tiny_admin/plugins/simple_auth.rbs b/sig/tiny_admin/plugins/simple_auth.rbs index ee13b8c5..97d295a2 100644 --- a/sig/tiny_admin/plugins/simple_auth.rbs +++ b/sig/tiny_admin/plugins/simple_auth.rbs @@ -1,6 +1,8 @@ module TinyAdmin module Plugins module SimpleAuth + BCRYPT_PREFIX: Regexp + def self.configure: (untyped, Hash[untyped, untyped]) -> void end end diff --git a/sig/tiny_admin/settings.rbs b/sig/tiny_admin/settings.rbs index b58026c9..4d2c032b 100644 --- a/sig/tiny_admin/settings.rbs +++ b/sig/tiny_admin/settings.rbs @@ -2,6 +2,8 @@ module TinyAdmin class Settings DEFAULTS: Hash[Array[Symbol], untyped] OPTIONS: Array[Symbol] + VALID_SECTION_TYPES: Array[Symbol] + REPOSITORY_INTERFACE: Array[Symbol] @options: Hash[Array[Symbol], untyped] @@ -36,6 +38,8 @@ module TinyAdmin def sections=: (untyped) -> untyped def scripts: () -> untyped def scripts=: (untyped) -> untyped + def strict_config: () -> untyped + def strict_config=: (untyped) -> untyped def style_links: () -> untyped def style_links=: (untyped) -> untyped @@ -52,5 +56,9 @@ module TinyAdmin def convert_value: (untyped, untyped) -> void def fetch_setting: (Array[String | Symbol]) -> Array[untyped] + + def validate_config!: () -> void + + def config_problem: (String) -> void end end diff --git a/sig/tiny_admin/views/actions/index.rbs b/sig/tiny_admin/views/actions/index.rbs index 4fc8b3d5..04f277c4 100644 --- a/sig/tiny_admin/views/actions/index.rbs +++ b/sig/tiny_admin/views/actions/index.rbs @@ -9,7 +9,9 @@ module TinyAdmin attr_accessor pagination_component: untyped attr_accessor prepare_record: Proc attr_accessor records: Enumerable[untyped] + attr_accessor show_link: bool? attr_accessor slug: String + attr_accessor sort_params: untyped def view_template: () ?{ (untyped) -> void } -> void @@ -17,6 +19,10 @@ module TinyAdmin def actions_buttons: () -> void + def render_sortable_header: (Field) -> void + + def sort_query_string: (String, String) -> String + def table_body: () -> void def table_header: () -> void diff --git a/sig/tiny_admin/views/components/filters_form.rbs b/sig/tiny_admin/views/components/filters_form.rbs index 508776b6..382c9845 100644 --- a/sig/tiny_admin/views/components/filters_form.rbs +++ b/sig/tiny_admin/views/components/filters_form.rbs @@ -6,6 +6,16 @@ module TinyAdmin attr_accessor section_path: String def view_template: () ?{ (untyped) -> void } -> void + + private + + def render_boolean_filter: (String, Hash[Symbol, untyped]) -> void + + def render_range_filter: (String, Hash[Symbol, untyped]) -> void + + def render_select_filter: (String, Hash[Symbol, untyped], Hash[Symbol, untyped]) -> void + + def render_association_filter: (String, Hash[Symbol, untyped], Hash[Symbol, untyped]) -> void end end end diff --git a/spec/dummy_rails/db/schema.rb b/spec/dummy_rails/db/schema.rb index ce418bc8..f7ac23f0 100644 --- a/spec/dummy_rails/db/schema.rb +++ b/spec/dummy_rails/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2018_06_07_053857) do +ActiveRecord::Schema[7.2].define(version: 2018_06_07_053857) do create_table "authors", force: :cascade do |t| t.string "name" t.integer "age" diff --git a/spec/lib/tiny_admin/actions/index_sort_spec.rb b/spec/lib/tiny_admin/actions/index_sort_spec.rb new file mode 100644 index 00000000..f536182d --- /dev/null +++ b/spec/lib/tiny_admin/actions/index_sort_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "dummy_rails_app" +require "rails_helper" + +RSpec.describe TinyAdmin::Actions::Index do + let(:repository) { TinyAdmin::Plugins::ActiveRecordRepository.new(Post) } + let(:request) { instance_double(Rack::Request, params: {}) } + let(:context) do + TinyAdmin::Context.new( + actions: {}, + repository: repository, + request: request, + router: nil, + slug: "posts" + ) + end + + before { setup_data(posts_count: 3) } + + describe "#merge_sort" do + let(:action) { described_class.new } + let(:fields) { repository.fields } + + it "returns configured sort when no params provided" do + allow(request).to receive(:params).and_return({}) + action.instance_variable_set(:@params, {}) + configured = "id DESC" + expect(action.send(:merge_sort, configured, fields)).to eq(configured) + end + + it "accepts valid field sort from params" do + allow(request).to receive(:params).and_return({ "sort" => { "title" => "asc" } }) + action.instance_variable_set(:@params, { "sort" => { "title" => "asc" } }) + result = action.send(:merge_sort, nil, fields) + expect(result).to eq(["title ASC"]) + end + + it "rejects unknown fields to prevent injection" do + allow(request).to receive(:params).and_return({ "sort" => { "DROP TABLE posts" => "asc" } }) + action.instance_variable_set(:@params, { "sort" => { "DROP TABLE posts" => "asc" } }) + configured = "id DESC" + expect(action.send(:merge_sort, configured, fields)).to eq(configured) + end + + it "normalises direction to ASC or DESC" do + allow(request).to receive(:params).and_return({ "sort" => { "title" => "invalid" } }) + action.instance_variable_set(:@params, { "sort" => { "title" => "invalid" } }) + result = action.send(:merge_sort, nil, fields) + expect(result).to eq(["title ASC"]) + end + end + + # show_link propagation is tested at the view level in views/actions/index_new_features_spec.rb +end diff --git a/spec/lib/tiny_admin/configure_from_file_spec.rb b/spec/lib/tiny_admin/configure_from_file_spec.rb new file mode 100644 index 00000000..fa328c08 --- /dev/null +++ b/spec/lib/tiny_admin/configure_from_file_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +RSpec.describe TinyAdmin do + describe ".configure_from_file" do + let(:config_file) { Tempfile.new(["tiny_admin", ".yml"]) } + + before do + config_file.write("---\nroot_path: '/dashboard'\n") + config_file.flush + end + + after { config_file.close! } + + it "resets settings by default" do + TinyAdmin.configure { |s| s.root_path = "/custom" } + TinyAdmin.configure_from_file(config_file.path) + expect(TinyAdmin.settings.root_path).to eq("/dashboard") + end + + it "preserves programmatic settings when reset: false" do + TinyAdmin.settings.reset! + TinyAdmin.configure { |s| s.extra_styles = "body { color: red; }" } + TinyAdmin.configure_from_file(config_file.path, reset: false) + expect(TinyAdmin.settings.root_path).to eq("/dashboard") + expect(TinyAdmin.settings.extra_styles).to eq("body { color: red; }") + end + end +end diff --git a/spec/lib/tiny_admin/plugins/active_record_repository_filters_spec.rb b/spec/lib/tiny_admin/plugins/active_record_repository_filters_spec.rb new file mode 100644 index 00000000..ccb06065 --- /dev/null +++ b/spec/lib/tiny_admin/plugins/active_record_repository_filters_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "dummy_rails_app" +require "rails_helper" + +RSpec.describe TinyAdmin::Plugins::ActiveRecordRepository do + let(:post_repository) { described_class.new(Post) } + + before { setup_data(posts_count: 5) } + + describe "#apply_filters with range values" do + it "applies gte filter" do + post = Post.order(:id).first + id_field = TinyAdmin::Field.new(name: "id", title: "ID", type: :integer) + filters = { id_field => { value: { "gte" => post.id.to_s } } } + results = post_repository.apply_filters(Post.all, filters) + expect(results.minimum(:id)).to be >= post.id + end + + it "applies lte filter" do + post = Post.order(:id).last + id_field = TinyAdmin::Field.new(name: "id", title: "ID", type: :integer) + filters = { id_field => { value: { "lte" => post.id.to_s } } } + results = post_repository.apply_filters(Post.all, filters) + expect(results.maximum(:id)).to be <= post.id + end + + it "applies both gte and lte (range)" do + posts = Post.order(:id).limit(3) + first_id = posts.first.id + last_id = posts.last.id + id_field = TinyAdmin::Field.new(name: "id", title: "ID", type: :integer) + filters = { id_field => { value: { "gte" => first_id.to_s, "lte" => last_id.to_s } } } + results = post_repository.apply_filters(Post.all, filters) + expect(results.count).to eq(3) + end + + it "ignores empty gte/lte values" do + id_field = TinyAdmin::Field.new(name: "id", title: "ID", type: :integer) + filters = { id_field => { value: { "gte" => "", "lte" => "" } } } + results = post_repository.apply_filters(Post.all, filters) + expect(results.count).to eq(Post.count) + end + end + + describe "#apply_filters with array values (multi-select)" do + it "applies IN filter for multiple values" do + posts = Post.order(:id).limit(2) + ids = posts.map(&:id) + id_field = TinyAdmin::Field.new(name: "id", title: "ID", type: :integer) + filters = { id_field => { value: ids } } + results = post_repository.apply_filters(Post.all, filters) + expect(results.map(&:id)).to match_array(ids) + end + + it "skips empty array values" do + id_field = TinyAdmin::Field.new(name: "id", title: "ID", type: :integer) + filters = { id_field => { value: ["", ""] } } + results = post_repository.apply_filters(Post.all, filters) + expect(results.count).to eq(Post.count) + end + end +end diff --git a/spec/lib/tiny_admin/plugins/base_repository_page_offset_spec.rb b/spec/lib/tiny_admin/plugins/base_repository_page_offset_spec.rb new file mode 100644 index 00000000..8db06de3 --- /dev/null +++ b/spec/lib/tiny_admin/plugins/base_repository_page_offset_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +RSpec.describe TinyAdmin::Plugins::BaseRepository do + let(:repo) { described_class.new(nil) } + + describe "#page_offset" do + it "returns 0 for page 1 with limit 10" do + expect(repo.send(:page_offset, 1, 10)).to eq(0) + end + + it "returns correct offset for subsequent pages" do + expect(repo.send(:page_offset, 2, 10)).to eq(10) + expect(repo.send(:page_offset, 3, 10)).to eq(20) + end + + it "returns 0 for non-positive page" do + expect(repo.send(:page_offset, 0, 10)).to eq(0) + end + end +end diff --git a/spec/lib/tiny_admin/settings_validation_spec.rb b/spec/lib/tiny_admin/settings_validation_spec.rb new file mode 100644 index 00000000..a4815092 --- /dev/null +++ b/spec/lib/tiny_admin/settings_validation_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "dummy_rails_app" +require "rails_helper" + +RSpec.describe TinyAdmin::Settings do + let(:settings) { described_class.instance } + + describe "#load_settings validation" do + context "with an unknown top-level key" do + before { settings[:unknown_key] = "value" } + + it "emits a warning in default mode" do + expect { settings.load_settings } + .to output(/unknown configuration key 'unknown_key'/).to_stderr + end + + it "raises in strict mode" do + settings[:strict_config] = true + expect { settings.load_settings } + .to raise_error(ArgumentError, /unknown configuration key 'unknown_key'/) + end + end + + context "with an invalid section type" do + before do + settings[:sections] = [{ slug: "test", name: "Test", type: :invalid_type }] + settings[:strict_config] = true + end + + it "raises for invalid section type" do + expect { settings.load_settings } + .to raise_error(ArgumentError, /invalid type 'invalid_type'/) + end + end + + context "with a repository missing required methods" do + let(:broken_repo) do + Class.new do + # Missing most required methods + def initialize(_model); end + end + end + + before do + settings[:repository] = broken_repo + settings[:strict_config] = true + end + + it "raises for a repository missing required methods" do + expect { settings.load_settings } + .to raise_error(ArgumentError, /missing required methods/) + end + end + + context "with valid configuration" do + it "does not warn or raise" do + expect { settings.load_settings }.not_to raise_error + end + end + end +end diff --git a/spec/lib/tiny_admin/store_only_normalization_spec.rb b/spec/lib/tiny_admin/store_only_normalization_spec.rb new file mode 100644 index 00000000..1f8fa5f2 --- /dev/null +++ b/spec/lib/tiny_admin/store_only_normalization_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "dummy_rails_app" +require "rails_helper" + +RSpec.describe TinyAdmin::Store do + let(:settings) do + instance_double( + TinyAdmin::Settings, + content_page: TinyAdmin::Views::Pages::Content, + repository: TinyAdmin::Plugins::ActiveRecordRepository + ) + end + let(:store) { described_class.new(settings) } + + describe "#add_resource_section normalizes only to symbols" do + it "converts string only values to symbols" do + sections = [{ slug: "posts", name: "Posts", type: :resource, model: Post, only: ["index", "show"] }] + store.prepare_sections(sections, logout: nil) + expect(store.resources["posts"][:only]).to eq(%i[index show]) + end + + it "keeps symbol only values as symbols" do + sections = [{ slug: "posts", name: "Posts", type: :resource, model: Post, only: %i[index] }] + store.prepare_sections(sections, logout: nil) + expect(store.resources["posts"][:only]).to eq(%i[index]) + end + + it "defaults to [:index, :show] when only is not specified" do + sections = [{ slug: "posts", name: "Posts", type: :resource, model: Post }] + store.prepare_sections(sections, logout: nil) + expect(store.resources["posts"][:only]).to eq(%i[index show]) + end + end +end diff --git a/spec/lib/tiny_admin/views/actions/index_new_features_spec.rb b/spec/lib/tiny_admin/views/actions/index_new_features_spec.rb new file mode 100644 index 00000000..0e873425 --- /dev/null +++ b/spec/lib/tiny_admin/views/actions/index_new_features_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "dummy_rails_app" +require "rails_helper" + +RSpec.describe TinyAdmin::Views::Actions::Index do + let(:view) { described_class.new } + let(:field) { TinyAdmin::Field.create_field(name: "title") } + let(:fields) { { "title" => field } } + + before { TinyAdmin::Settings.instance.load_settings } + + describe "#render_sortable_header" do + before do + view.update_attributes( + actions: {}, + fields: fields, + filters: {}, + prepare_record: ->(r) { {} }, + records: [], + slug: "posts", + sort_params: nil + ) + end + + it "renders a link with the field name" do + html = view.call + expect(html).to include("sort-link") + expect(html).to include("Title") + end + + it "shows ASC indicator when currently sorted asc" do + view.sort_params = { "title" => "asc" } + html = view.call + expect(html).to include("▲") + end + + it "shows DESC indicator when currently sorted desc" do + view.sort_params = { "title" => "desc" } + html = view.call + expect(html).to include("▼") + end + + it "omits sort indicator when field is not sorted" do + view.sort_params = {} + html = view.call + expect(html).not_to include("▲") + expect(html).not_to include("▼") + end + end + + describe "show_link" do + let(:fake_record) { double("record", id: 1) } + + before do + view.update_attributes( + actions: {}, + fields: fields, + filters: {}, + prepare_record: ->(r) { { "title" => "Test" } }, + records: [fake_record], + slug: "posts", + sort_params: nil + ) + end + + it "renders a Show link by default (show_link nil)" do + html = view.call + expect(html).to include("Show") + end + + it "renders a Show link when show_link is true" do + view.show_link = true + html = view.call + expect(html).to include("Show") + end + + it "does not render Show link when show_link is false" do + view.show_link = false + html = view.call + expect(html).not_to include("Show") + end + end +end diff --git a/spec/lib/tiny_admin/views/components/filters_form_new_spec.rb b/spec/lib/tiny_admin/views/components/filters_form_new_spec.rb new file mode 100644 index 00000000..e9008303 --- /dev/null +++ b/spec/lib/tiny_admin/views/components/filters_form_new_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "dummy_rails_app" +require "rails_helper" + +RSpec.describe TinyAdmin::Views::Components::FiltersForm do + let(:component) { described_class.new } + + before do + TinyAdmin::Settings.instance.load_settings + component.update_attributes(section_path: "/admin/posts", filters: filters) + end + + describe "range filter" do + let(:field) { TinyAdmin::Field.new(name: "age", type: :integer, title: "Age", options: {}) } + let(:filters) { { field => { filter: { type: :range }, value: { "gte" => "18", "lte" => "65" } } } } + + it "renders two inputs for min and max", :aggregate_failures do + html = component.call + expect(html).to include('name="q[age][gte]"') + expect(html).to include('name="q[age][lte]"') + expect(html).to include('value="18"') + expect(html).to include('value="65"') + end + end + + describe "multi-value select filter" do + let(:field) { TinyAdmin::Field.new(name: "state", type: :string, title: "State", options: {}) } + let(:filters) do + { + field => { + filter: { type: :select, values: %w[draft published archived], multiple: true }, + value: ["published"] + } + } + end + + it "renders a multiple select element", :aggregate_failures do + html = component.call + expect(html).to include("multiple") + expect(html).to include("published") + expect(html).to include("draft") + expect(html).to include("archived") + end + end + + describe "association filter" do + let(:author_class) do + Struct.new(:id, :name) do + def self.all = [new(1, "Alice"), new(2, "Bob")] + end + end + + let(:field) { TinyAdmin::Field.new(name: "author_id", type: :integer, title: "Author", options: {}) } + let(:filters) do + { + field => { + filter: { type: :association, association: author_class, value_field: :id, label_field: :name }, + value: "1" + } + } + end + + it "renders a select with options from the associated model", :aggregate_failures do + html = component.call + expect(html).to include("Alice") + expect(html).to include("Bob") + expect(html).to include('value="1"') + expect(html).to include('value="2"') + end + end +end diff --git a/spec/lib/tiny_admin/views/components/widgets_spec.rb b/spec/lib/tiny_admin/views/components/widgets_spec.rb index dff1168f..4e2b4a05 100644 --- a/spec/lib/tiny_admin/views/components/widgets_spec.rb +++ b/spec/lib/tiny_admin/views/components/widgets_spec.rb @@ -35,9 +35,9 @@ def view_template end describe "with non-Phlex widget" do - it "skips non-Phlex classes" do - html = described_class.new([String]).call - expect(html).not_to include("card-body") + it "raises ArgumentError for non-Phlex classes" do + expect { described_class.new([String]).call } + .to raise_error(ArgumentError, /Widget String.*must be a subclass of Phlex::HTML/) end end end