diff --git a/app/controllers/data_drip/backfill_runs_controller.rb b/app/controllers/data_drip/backfill_runs_controller.rb index be4501f..cc076ae 100644 --- a/app/controllers/data_drip/backfill_runs_controller.rb +++ b/app/controllers/data_drip/backfill_runs_controller.rb @@ -160,7 +160,7 @@ def backfill_options if backfill_class_name.blank? || backfill_class_name == "Select a backfill class" - render json: { html: "" } + render json: { html: "", instructions: nil } return end @@ -168,7 +168,7 @@ def backfill_options DataDrip.all.find { |klass| klass.name == backfill_class_name } if backfill_class.nil? - render json: { html: "" } + render json: { html: "", instructions: nil } return end @@ -180,8 +180,9 @@ def backfill_options ) html = helpers.backfill_option_inputs(temp_run) + instructions = backfill_class.instructions - render json: { html: html } + render json: { html: html, instructions: instructions } end def find_current_backfiller diff --git a/app/javascript/data_drip/controllers/backfill_form_controller.js b/app/javascript/data_drip/controllers/backfill_form_controller.js new file mode 100644 index 0000000..9e13004 --- /dev/null +++ b/app/javascript/data_drip/controllers/backfill_form_controller.js @@ -0,0 +1,67 @@ +import { Controller } from "@hotwired/stimulus" +import { renderInstructions } from "markdown" + +// Drives the "New Backfill Run" form: +// - captures the browser timezone into a hidden field +// - when a backfill class is picked, fetches its option inputs + instructions +// and renders them +// - disables the submit button while the form is being submitted +export default class extends Controller { + static targets = ["classInput", "optionsContainer", "instructionsContainer", "timezone", "submit"] + static values = { optionsUrl: String } + + connect() { + if (this.hasTimezoneTarget) { + this.timezoneTarget.value = Intl.DateTimeFormat().resolvedOptions().timeZone + } + } + + classChanged() { + const selectedClass = this.classInputTarget.value + + this.optionsContainerTarget.innerHTML = "" + if (this.hasInstructionsContainerTarget) this.instructionsContainerTarget.innerHTML = "" + + if (!selectedClass || selectedClass === "Select a backfill class") return + + fetch(`${this.optionsUrlValue}?backfill_class_name=${encodeURIComponent(selectedClass)}`, { + method: "GET", + headers: { + Accept: "application/json", + "X-Requested-With": "XMLHttpRequest", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") + } + }) + .then((response) => { + if (!response.ok) throw new Error(`Network response was not ok: ${response.status}`) + return response.json() + }) + .then((data) => { + if (data.instructions && this.hasInstructionsContainerTarget) { + this.instructionsContainerTarget.innerHTML = + '
' + + '

INSTRUCTIONS

' + + '
' + renderInstructions(data.instructions) + "
" + + "
" + } + if (data.html) { + this.optionsContainerTarget.innerHTML = data.html + this.optionsContainerTarget.querySelectorAll("script").forEach((oldScript) => { + const newScript = document.createElement("script") + newScript.textContent = oldScript.textContent + oldScript.parentNode.replaceChild(newScript, oldScript) + }) + } + }) + .catch((error) => { + console.error("Error fetching backfill options:", error) + }) + } + + disableSubmit() { + if (!this.hasSubmitTarget) return + this.submitTarget.disabled = true + this.submitTarget.value = "Creating..." + this.submitTarget.classList.add("opacity-50", "cursor-not-allowed") + } +} diff --git a/app/javascript/data_drip/markdown.js b/app/javascript/data_drip/markdown.js new file mode 100644 index 0000000..7887e19 --- /dev/null +++ b/app/javascript/data_drip/markdown.js @@ -0,0 +1,84 @@ +// Minimal markdown-subset renderer for backfill instructions. +// +// Supports: `#`/`##`/`###` headers, `**bold**`, `` `inline code` ``, +// `- `/`* ` bullet lists, and triple-backtick fenced code blocks. +// +// Kept intentionally tiny so DataDrip stays dependency-free (this is an +// importmap project with no npm/bundler). If richer markdown is ever needed, +// this module is the single place to swap in a library such as +// marked (https://github.com/markedjs/marked). +// +// Styling uses inline styles on purpose: the rendered HTML is injected +// dynamically into the page, and the engine's Tailwind build cannot generate +// utilities for class names it never sees in a scanned template. + +function esc(str) { + return str.replace(/&/g, "&").replace(//g, ">") +} + +function inlineFormat(text) { + return text + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/`(.+?)`/g, '$1') +} + +export function renderInstructions(text) { + const lines = text.split("\n") + let html = "" + let inList = false + let inCodeBlock = false + let codeLines = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + if (line.trim().match(/^```/)) { + if (inCodeBlock) { + html += '
' + esc(codeLines.join("\n")) + "
" + codeLines = [] + inCodeBlock = false + } else { + if (inList) { html += ""; inList = false } + inCodeBlock = true + } + continue + } + + if (inCodeBlock) { + codeLines.push(line) + continue + } + + if (line.trim() === "") { + if (inList) { html += ""; inList = false } + html += '
' + continue + } + + const headerMatch = line.match(/^(#{1,3})\s+(.+)$/) + if (headerMatch) { + if (inList) { html += ""; inList = false } + const level = headerMatch[1].length + const style = level === 1 + ? "font-size:15px;font-weight:600;color:#1e3a5f;margin-bottom:2px" + : level === 2 + ? "font-size:13px;font-weight:600;color:#2563eb;margin-bottom:2px;text-transform:uppercase;letter-spacing:0.03em" + : "font-size:13px;font-weight:500;color:#475569;margin-bottom:2px" + html += '
' + inlineFormat(esc(headerMatch[2])) + "
" + continue + } + + const bulletMatch = line.match(/^[-*]\s+(.+)$/) + if (bulletMatch) { + if (!inList) { html += '"; inList = false } + html += '

' + inlineFormat(esc(line)) + "

" + } + + if (inList) html += "" + return html +} diff --git a/app/views/data_drip/backfill_runs/new.html.erb b/app/views/data_drip/backfill_runs/new.html.erb index e1b7c65..8dba370 100644 --- a/app/views/data_drip/backfill_runs/new.html.erb +++ b/app/views/data_drip/backfill_runs/new.html.erb @@ -1,7 +1,7 @@
- <%= form_with model: @run, url: backfill_runs_path, local: true, html: { class: "bg-white p-10 md:p-14 rounded-lg", "data-turbo": "false" } do |f| %> + <%= form_with model: @run, url: backfill_runs_path, local: true, html: { class: "bg-white p-10 md:p-14 rounded-lg", data: { turbo: "false", controller: "backfill-form", "backfill-form-options-url-value": backfill_options_backfill_runs_path, action: "submit->backfill-form#disableSubmit" } } do |f| %>

New Backfill Run

<% if @run.errors.any? %>
@@ -20,6 +20,7 @@ list: "backfill_class_options", placeholder: "Search backfill class…", autocomplete: "off", + data: { "backfill-form-target": "classInput", action: "change->backfill-form#classChanged" }, class: "block w-full mt-1 rounded border border-gray-200 focus:ring focus:ring-blue-200 focus:border-blue-400 px-3 py-2" %> <% backfill_class_names.each do |name| %> @@ -28,6 +29,8 @@
+
+
<%= f.label :batch_size, "BATCH SIZE", class: "block text-gray-500 font-semibold mb-2" %> <%= f.number_field :batch_size, class: "block w-full mt-1 rounded border border-gray-200 focus:ring focus:ring-blue-200 focus:border-blue-400 px-3 py-2", min: 1 %> @@ -38,93 +41,17 @@ <%= f.number_field :amount_of_elements, class: "block w-full mt-1 rounded border border-gray-200 focus:ring focus:ring-blue-200 focus:border-blue-400 px-3 py-2", min: 0, step: 10 %>
-
+
<%= f.label :start_at, "RUN SHOULD START AT", class: "block text-gray-500 font-semibold mb-2" %> <%= f.datetime_field :start_at, value: "", class: "block w-full mt-1 rounded border border-gray-200 focus:ring focus:ring-blue-200 focus:border-blue-400 px-3 py-2" %>
- <%= hidden_field_tag :user_timezone, "", id: "user_timezone" %> + <%= hidden_field_tag :user_timezone, "", id: "user_timezone", data: { "backfill-form-target": "timezone" } %>
- <%= f.submit "Create Backfill Run", id: "submit-btn", class: "text-white font-bold py-2 px-4 rounded gradient-btn" %> + <%= f.submit "Create Backfill Run", id: "submit-btn", data: { "backfill-form-target": "submit" }, class: "text-white font-bold py-2 px-4 rounded gradient-btn" %>
<% end %>
- - diff --git a/config/importmap.rb b/config/importmap.rb index 46c444a..dfa1f5d 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true pin "application", to: "data_drip/application.js", preload: true +pin "markdown", to: "data_drip/markdown.js" pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" diff --git a/lib/data_drip/backfill.rb b/lib/data_drip/backfill.rb index 85231b6..4fa3231 100644 --- a/lib/data_drip/backfill.rb +++ b/lib/data_drip/backfill.rb @@ -3,6 +3,10 @@ module DataDrip class Backfill + def self.instructions + nil + end + def self.attribute(name, type = nil, default: nil, **options) raise "Method #{name} already defined in #{self.class.name}" if instance_methods.include?(name.to_sym) diff --git a/lib/generators/data_drip/templates/backfill.rb.erb b/lib/generators/data_drip/templates/backfill.rb.erb index 70a4b5d..9814614 100644 --- a/lib/generators/data_drip/templates/backfill.rb.erb +++ b/lib/generators/data_drip/templates/backfill.rb.erb @@ -5,6 +5,13 @@ class <%= class_name %> < DataDrip::Backfill Elem = type_member { { fixed: YourModel } } RelationType = type_member { { fixed: YourModel::RelationType } } <% end %> + # Describe the purpose of this backfill and how to fill in the options. + # This text is shown in the DataDrip UI when this backfill is selected. + # + # def self.instructions + # "Explain what this backfill does and what values to provide for each option." + # end + <% if @sorbet_enabled %> sig { returns(RelationType]) }<% end %> def scope # YourModel.some_scope.where(something: true) diff --git a/spec/controllers/data_drip/backfill_runs_controller_spec.rb b/spec/controllers/data_drip/backfill_runs_controller_spec.rb index cd8322f..f4aa67e 100644 --- a/spec/controllers/data_drip/backfill_runs_controller_spec.rb +++ b/spec/controllers/data_drip/backfill_runs_controller_spec.rb @@ -45,7 +45,7 @@ post :create, params: { backfill_run: invalid_attributes } end.not_to change(DataDrip::BackfillRun, :count) - expect(response.body).to include("Error") + expect(response.body).to include("There were errors:") end it "renders new template when backfill class name is invalid" do @@ -56,7 +56,7 @@ post :create, params: { backfill_run: invalid_class_attributes } end.not_to change(DataDrip::BackfillRun, :count) - expect(response.body).to include("Error") + expect(response.body).to include("There were errors:") end context "with timezone conversion" do @@ -173,6 +173,38 @@ end end + describe "GET #backfill_options" do + it "returns instructions: nil when backfill class name is blank" do + get :backfill_options, params: { backfill_class_name: "" } + json = JSON.parse(response.body) + expect(json["instructions"]).to be_nil + end + + it "returns instructions: nil when backfill class is not found" do + get :backfill_options, params: { backfill_class_name: "NonExistentClass" } + json = JSON.parse(response.body) + expect(json["instructions"]).to be_nil + end + + it "returns instructions from the backfill class" do + get :backfill_options, params: { backfill_class_name: "AddRoleToEmployee" } + json = JSON.parse(response.body) + expect(json["instructions"]).to include("intern") + end + + it "returns instructions: nil for a backfill with no instructions" do + stub_const("NoInstructionsBackfill", Class.new(DataDrip::Backfill) { + def scope; Employee.none; end + }) + allow(DataDrip).to receive(:all).and_return( + DataDrip.all + [ NoInstructionsBackfill ] + ) + get :backfill_options, params: { backfill_class_name: "NoInstructionsBackfill" } + json = JSON.parse(response.body) + expect(json["instructions"]).to be_nil + end + end + describe "#backfill_class_names" do it "returns sorted and unique backfill class names" do expect(controller.send(:backfill_class_names)).to include( diff --git a/spec/lib/data_drip/backfill_spec.rb b/spec/lib/data_drip/backfill_spec.rb index d9a4daa..bdd5587 100644 --- a/spec/lib/data_drip/backfill_spec.rb +++ b/spec/lib/data_drip/backfill_spec.rb @@ -5,6 +5,26 @@ RSpec.describe DataDrip::Backfill, type: :model do let(:test_backfill_class) { AddRoleToEmployee } + describe ".instructions" do + it "returns nil by default for a class without instructions" do + klass = Class.new(DataDrip::Backfill) + expect(klass.instructions).to be_nil + end + + it "returns the instructions when overridden" do + klass = Class.new(DataDrip::Backfill) do + def self.instructions + "These are test instructions" + end + end + expect(klass.instructions).to eq("These are test instructions") + end + + it "returns the instructions from AddRoleToEmployee" do + expect(AddRoleToEmployee.instructions).to include("intern") + end + end + describe ".attribute" do it "defines attribute methods on the class" do expect(test_backfill_class.new).to respond_to(:age) diff --git a/spec/test_app/app/backfills/add_birthday_to_employee.rb b/spec/test_app/app/backfills/add_birthday_to_employee.rb index 48fefa5..a82b41d 100644 --- a/spec/test_app/app/backfills/add_birthday_to_employee.rb +++ b/spec/test_app/app/backfills/add_birthday_to_employee.rb @@ -1,6 +1,25 @@ class AddBirthdayToEmployee < DataDrip::Backfill attribute :employee_id, :integer + def self.instructions + <<~INSTRUCTIONS + Sets today's date as the **birthday** for all employees missing one. + + ## Options + - `employee_id`: Target a **single employee** by ID (optional) + + ## Finding an employee ID + You can get the ID from an email with: + ``` + SELECT id FROM employees + WHERE email = 'jane@example.com'; + ``` + + ## Notes + - Uses `update_all` for fast batch processing + - Safe to re-run — only touches employees where `birthday` is `nil` + INSTRUCTIONS + end def scope Employee.where(birthday: nil) end diff --git a/spec/test_app/app/backfills/add_role_to_employee.rb b/spec/test_app/app/backfills/add_role_to_employee.rb index 5f7ac6a..15dab33 100644 --- a/spec/test_app/app/backfills/add_role_to_employee.rb +++ b/spec/test_app/app/backfills/add_role_to_employee.rb @@ -1,4 +1,19 @@ class AddRoleToEmployee < DataDrip::Backfill + def self.instructions + <<~INSTRUCTIONS + # Assign default role + Sets the **intern** role on all employees that don't have one yet. + + ## Options + - `age`: Filter employees by age (optional) + - `name`: Filter employees by exact name match (optional) + + ## Important + - This backfill is **idempotent** — safe to re-run + - Triggers `on_run_completed` and `on_batch_completed` hooks + INSTRUCTIONS + end + attribute :age, :integer attribute :name, :string