From a2c2126b32954438dfd848a3e46d6ac0fd0a9ba2 Mon Sep 17 00:00:00 2001 From: Nacho Corcuera Date: Thu, 20 Aug 2026 10:50:45 +0000 Subject: [PATCH 1/2] feat: support dependent enum selectors Implemented-with: factorial-pr --- app/helpers/data_drip/backfill_runs_helper.rb | 43 ++++++++++++++---- .../controllers/enum_select_controller.js | 45 +++++++++++++++++-- lib/data_drip/concerns/schematized_options.rb | 8 +++- lib/data_drip/types/enum.rb | 10 ++++- .../data_drip/backfill_runs_helper_spec.rb | 43 ++++++++++++++++++ spec/lib/data_drip/backfill_spec.rb | 15 +++++++ spec/lib/data_drip/types/enum_spec.rb | 7 +++ 7 files changed, 157 insertions(+), 14 deletions(-) diff --git a/app/helpers/data_drip/backfill_runs_helper.rb b/app/helpers/data_drip/backfill_runs_helper.rb index 6ad62be..f6c3f37 100644 --- a/app/helpers/data_drip/backfill_runs_helper.rb +++ b/app/helpers/data_drip/backfill_runs_helper.rb @@ -365,16 +365,42 @@ def build_standard_input(name, type, value, field_prefix, required: false) def build_enum_input(name, type, values, field_prefix) raw_choices = type.available_values - # Normalize to [label, value] pairs — supports both ["a","b"] and [["Label","val"],...] - pairs = raw_choices.map { |choice| choice.is_a?(Array) ? choice : [ choice, choice ] } + choices = raw_choices.map { |choice| choice.is_a?(Array) ? choice : [ choice, choice ] } field_name = "#{field_prefix}[#{name}]" field_id = "enum_#{name}" current_value = values[name].to_s + unless type.multiple? + options = choices.map { |label, value, _dependency| [ label, value ] } + return select_tag( + field_name, + options_for_select(options, current_value), + id: field_id, + class: INPUT_CLASSES, + data: { + controller: "enum-select", + enum_select_name_value: name, + action: "change->enum-select#singleChanged" + } + ) + end + + dependency_value = (values[type.depends_on] || values[type.depends_on.to_s]).to_s if type.depends_on + eligible_choices = + if type.depends_on && dependency_value.present? + choices.select { |_label, _value, dependency| dependency.to_s == dependency_value } + else + choices + end selected_values = - current_value.present? ? current_value.split(",") : pairs.map(&:last).map(&:to_s) + current_value.present? ? current_value.split(",") : eligible_choices.map { |choice| choice[1].to_s } - content_tag :div, data: { controller: "enum-select" } do + content_tag :div, + data: { + controller: "enum-select", + enum_select_depends_on_value: type.depends_on, + action: "data-drip:enum-change@window->enum-select#dependencyChanged" + } do hidden = hidden_field_tag field_name, selected_values.join(","), @@ -406,7 +432,7 @@ def build_enum_input(name, type, values, field_prefix) check_box_tag( "#{field_id}_select_all", "1", - selected_values.length == pairs.length, + selected_values.length == eligible_choices.length, class: "size-4 accent-drip-700 dark:accent-drip-400", data: { enum_select_target: "selectAll", @@ -424,7 +450,7 @@ def build_enum_input(name, type, values, field_prefix) counter = content_tag :span, - "#{selected_values.length}/#{pairs.length} selected", + "#{selected_values.length}/#{eligible_choices.length} selected", class: "text-xs text-zinc-500 tabular-nums dark:text-zinc-400", data: { enum_select_target: "counter" @@ -446,7 +472,7 @@ def build_enum_input(name, type, values, field_prefix) checkboxes = safe_join( - pairs.map do |label, value| + choices.map do |label, value, dependency| value_string = value.to_s checkbox_id = "#{field_id}_#{value_string.parameterize(separator: "_")}" @@ -456,7 +482,8 @@ def build_enum_input(name, type, values, field_prefix) "hover:bg-zinc-950/5 dark:hover:bg-white/5", data: { enum_select_target: "row", - search: label.to_s.downcase + search: label.to_s.downcase, + dependency: dependency } do check_box_tag( checkbox_id, diff --git a/app/javascript/data_drip/controllers/enum_select_controller.js b/app/javascript/data_drip/controllers/enum_select_controller.js index 786d475..8e746e6 100644 --- a/app/javascript/data_drip/controllers/enum_select_controller.js +++ b/app/javascript/data_drip/controllers/enum_select_controller.js @@ -5,8 +5,10 @@ import { Controller } from "@hotwired/stimulus" // in sync with the individual checkboxes. export default class extends Controller { static targets = ["hidden", "search", "selectAll", "counter", "row", "checkbox", "noResults"] + static values = { name: String, dependsOn: String } connect() { + if (this.hasDependsOnValue) this.#applyDependency(this.#dependencyFieldValue()) this.sync() } @@ -19,6 +21,21 @@ export default class extends Controller { this.timer = setTimeout(() => this.#applyFilter(), 150) } + singleChanged(event) { + window.dispatchEvent( + new CustomEvent("data-drip:enum-change", { + detail: { name: this.nameValue, value: event.target.value } + }) + ) + } + + dependencyChanged(event) { + if (!this.hasDependsOnValue || event.detail.name !== this.dependsOnValue) return + + this.#applyDependency(event.detail.value) + this.sync() + } + toggleAll() { const checked = this.selectAllTarget.checked @@ -40,13 +57,18 @@ export default class extends Controller { } sync() { + if (!this.hasHiddenTarget) return + const values = this.checkboxTargets .filter((checkbox) => checkbox.checked) .map((checkbox) => checkbox.value) this.hiddenTarget.value = values.join(",") - this.counterTarget.textContent = `${values.length}/${this.checkboxTargets.length} selected` - this.selectAllTarget.checked = values.length === this.checkboxTargets.length + const visible = this.checkboxTargets.filter( + (checkbox) => !checkbox.closest("[data-search]").classList.contains("hidden") + ) + this.counterTarget.textContent = `${values.length}/${visible.length} selected` + this.selectAllTarget.checked = visible.length > 0 && values.length === visible.length this.selectAllTarget.indeterminate = values.length > 0 && values.length < this.checkboxTargets.length } @@ -56,11 +78,28 @@ export default class extends Controller { let visible = 0 this.rowTargets.forEach((row) => { - const match = !query || row.dataset.search.includes(query) + const dependencyMatch = !this.hasDependsOnValue || row.dataset.dependency === this.currentDependency + const match = dependencyMatch && (!query || row.dataset.search.includes(query)) row.classList.toggle("hidden", !match) if (match) visible++ }) this.noResultsTarget.classList.toggle("hidden", visible > 0) } + + #dependencyFieldValue() { + const field = document.querySelector(`[name$="[${this.dependsOnValue}]"]`) + return field?.value || "" + } + + #applyDependency(value) { + this.currentDependency = value + this.checkboxTargets.forEach((checkbox) => { + const row = checkbox.closest("[data-search]") + const matches = row.dataset.dependency === value + row.classList.toggle("hidden", !matches) + checkbox.checked = matches + }) + this.#applyFilter() + } } diff --git a/lib/data_drip/concerns/schematized_options.rb b/lib/data_drip/concerns/schematized_options.rb index 306fd81..3c432c8 100644 --- a/lib/data_drip/concerns/schematized_options.rb +++ b/lib/data_drip/concerns/schematized_options.rb @@ -28,7 +28,11 @@ def define_schema_attribute(name, type = nil, default: nil, required: false, rea raise "Method #{name} already defined in #{self.class.name}" if instance_methods.include?(name.to_sym) if type == :enum - enum_type = DataDrip::Types::Enum.new(values: options.delete(:values) || []) + enum_type = DataDrip::Types::Enum.new( + values: options.delete(:values) || [], + multiple: options.delete(:multiple) { true }, + depends_on: options.delete(:depends_on) + ) schema_options_class.attribute(name, enum_type, default: default, **options) # Reject submitted values (a comma-separated list) that aren't part of @@ -38,7 +42,7 @@ def define_schema_attribute(name, type = nil, default: nil, required: false, rea raw = public_send(attribute_name) if raw.present? allowed = - enum_type.available_values.map { |value| (value.is_a?(Array) ? value.last : value).to_s } + enum_type.available_values.map { |value| (value.is_a?(Array) ? value[1] : value).to_s } unless (raw.to_s.split(",") - allowed).empty? errors.add(attribute_name, "is not included in the list") end diff --git a/lib/data_drip/types/enum.rb b/lib/data_drip/types/enum.rb index c8fc29d..e09e519 100644 --- a/lib/data_drip/types/enum.rb +++ b/lib/data_drip/types/enum.rb @@ -3,8 +3,12 @@ module DataDrip module Types class Enum < ActiveModel::Type::String - def initialize(values: [], **options) + attr_reader :depends_on + + def initialize(values: [], multiple: true, depends_on: nil, **options) @values_source = values + @multiple = multiple + @depends_on = depends_on&.to_sym super(**options) end @@ -15,6 +19,10 @@ def type def available_values @values_source.respond_to?(:call) ? @values_source.call : @values_source end + + def multiple? + @multiple + end end end end diff --git a/spec/helpers/data_drip/backfill_runs_helper_spec.rb b/spec/helpers/data_drip/backfill_runs_helper_spec.rb index 9689b51..19f092f 100644 --- a/spec/helpers/data_drip/backfill_runs_helper_spec.rb +++ b/spec/helpers/data_drip/backfill_runs_helper_spec.rb @@ -79,6 +79,32 @@ def process_element(_element); end end end + describe "#backfill_option_inputs with dependent enums" do + let(:backfill_run) do + DataDrip::BackfillRun.new( + backfill_class_name: "BackfillRunsHelperSpec::DependentEnumBackfill", + options: { "entity" => "employees" } + ) + end + + let(:html) { helper.backfill_option_inputs(backfill_run) } + + it "renders the parent as a single select" do + expect(html).to match( + %r{]*name="backfill_run\[options\]\[entity\]"[^>]*data-controller="enum-select"} + ) + expect(html).not_to include(%(id="enum_entity_select_all")) + end + + it "renders dependency metadata and preselects only the current entity columns" do + expect(html).to include(%(data-enum-select-depends-on-value="entity")) + expect(html).to include(%(data-dependency="employees")) + expect(html).to match( + %r{name="backfill_run\[options\]\[columns\]"[^>]*value="employees:attendable"} + ) + end + end + describe "#backfill_option_inputs with a required attribute" do let(:backfill_run) do DataDrip::BackfillRun.new( @@ -346,6 +372,23 @@ def scope def process_element(_element); end end + class DependentEnumBackfill < DataDrip::Backfill + attribute :entity, :enum, values: %w[employees contracts], multiple: false + attribute :columns, + :enum, + values: [ + [ "Attendable", "employees:attendable", "employees" ], + [ "Job title", "contracts:job_title", "contracts" ] + ], + depends_on: :entity + + def scope + Employee.all + end + + def process_element(_element); end + end + class TypedBackfill < DataDrip::Backfill attribute :quantity, :integer attribute :ratio, :float diff --git a/spec/lib/data_drip/backfill_spec.rb b/spec/lib/data_drip/backfill_spec.rb index b5eb8be..22ef961 100644 --- a/spec/lib/data_drip/backfill_spec.rb +++ b/spec/lib/data_drip/backfill_spec.rb @@ -45,6 +45,21 @@ expect(attr_type.available_values).to eq(%w[a b c]) end + it "supports single and dependent enum selectors" do + klass = Class.new(DataDrip::Backfill) do + attribute :entity, :enum, values: %w[employees contracts], multiple: false + attribute :columns, + :enum, + values: [ [ "Attendable", "employees:attendable", "employees" ] ], + depends_on: :entity + end + + entity_type = klass.backfill_options_class.attribute_types["entity"] + columns_type = klass.backfill_options_class.attribute_types["columns"] + expect(entity_type).not_to be_multiple + expect(columns_type.depends_on).to eq(:entity) + end + it "casts :enum values as strings" do klass = Class.new(DataDrip::Backfill) do attribute :color, :enum, values: %w[red green blue] diff --git a/spec/lib/data_drip/types/enum_spec.rb b/spec/lib/data_drip/types/enum_spec.rb index cc64b37..cac88d3 100644 --- a/spec/lib/data_drip/types/enum_spec.rb +++ b/spec/lib/data_drip/types/enum_spec.rb @@ -25,6 +25,13 @@ end end + it "exposes selector cardinality and dependency metadata" do + type = described_class.new(values: %w[a b], multiple: false, depends_on: :entity) + + expect(type).not_to be_multiple + expect(type.depends_on).to eq(:entity) + end + describe "casting" do it "casts values to strings, like its String parent" do type = described_class.new(values: %w[a b]) From 554192a04c870be8afe730e1c60f4d71f1e554ee Mon Sep 17 00:00:00 2001 From: Nacho Corcuera Date: Thu, 20 Aug 2026 11:39:47 +0000 Subject: [PATCH 2/2] docs: explain dependent enum selectors Implemented-with: factorial-pr --- CHANGELOG.md | 1 + README.md | 32 +++++++++++++++++++ .../controllers/enum_select_controller.js | 7 ++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 923bde3..54a4412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [Unreleased] ### Added +- Enum options support single-value selectors with `multiple: false` and dependent multi-value selectors with `depends_on:`. - Backfill options can be declared as mandatory with `attribute :name, :string, required: true`. The form marks required fields and the server rejects runs with blank required options (also guarding `scope` from running with missing options). - Full UI redesign: slim header shell (replaces the empty sidebar), stats strip, tabbed runs list with class-name search and status filter, progress bars, relative timestamps, empty states, and dark mode support (follows the OS preference). - Run detail page now shows a live progress hero (percent, throughput, estimated time remaining, elapsed) that auto-refreshes while the run is active, plus a metadata panel with the run's options. diff --git a/README.md b/README.md index cdc90a4..b11ec63 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,38 @@ DataDrip supports various attribute types that automatically generate appropriat - **`:date`** - Date picker - **`:time`** - Time picker - **`:datetime`** - Date and time picker +- **`:enum`** - Searchable selector constrained to declared values + +#### Enum Selectors + +Enums are multi-value selectors by default and submit the selected values as a +comma-separated string. Set `multiple: false` for a single-value selector: + +```ruby +attribute :entity, + :enum, + values: %w[employees contracts], + multiple: false, + default: "employees" +``` + +A multi-value enum can depend on another enum. Dependent choices use +`[label, value, parent_value]`; the UI displays and submits only choices whose +parent value matches the current selection: + +```ruby +attribute :columns, + :enum, + values: [ + [ "Attendable", "employees:attendable", "employees" ], + [ "Job title", "contracts:job_title", "contracts" ] + ], + depends_on: :entity +``` + +The server still validates submitted values against the full declared +allowlist. Backfills should additionally validate cross-field rules, such as +ensuring every submitted column belongs to the selected entity. ### Backfill Structure diff --git a/app/javascript/data_drip/controllers/enum_select_controller.js b/app/javascript/data_drip/controllers/enum_select_controller.js index 8e746e6..fe11fb5 100644 --- a/app/javascript/data_drip/controllers/enum_select_controller.js +++ b/app/javascript/data_drip/controllers/enum_select_controller.js @@ -69,8 +69,7 @@ export default class extends Controller { ) this.counterTarget.textContent = `${values.length}/${visible.length} selected` this.selectAllTarget.checked = visible.length > 0 && values.length === visible.length - this.selectAllTarget.indeterminate = - values.length > 0 && values.length < this.checkboxTargets.length + this.selectAllTarget.indeterminate = values.length > 0 && values.length < visible.length } #applyFilter() { @@ -88,7 +87,9 @@ export default class extends Controller { } #dependencyFieldValue() { - const field = document.querySelector(`[name$="[${this.dependsOnValue}]"]`) + const field = this.element + .closest("form") + ?.querySelector(`[name$="[${this.dependsOnValue}]"]`) return field?.value || "" }