Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 35 additions & 8 deletions app/helpers/data_drip/backfill_runs_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(","),
Expand Down Expand Up @@ -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",
Expand All @@ -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"
Expand All @@ -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: "_")}"

Expand All @@ -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,
Expand Down
50 changes: 45 additions & 5 deletions app/javascript/data_drip/controllers/enum_select_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand All @@ -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

Expand All @@ -40,27 +57,50 @@ 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
this.selectAllTarget.indeterminate =
values.length > 0 && 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 < visible.length
}

#applyFilter() {
const query = this.searchTarget.value.trim().toLowerCase()
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 = this.element
.closest("form")
?.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()
}
}
8 changes: 6 additions & 2 deletions lib/data_drip/concerns/schematized_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion lib/data_drip/types/enum.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
43 changes: 43 additions & 0 deletions spec/helpers/data_drip/backfill_runs_helper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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{<select[^>]*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(
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions spec/lib/data_drip/backfill_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
7 changes: 7 additions & 0 deletions spec/lib/data_drip/types/enum_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading