Skip to content
Closed
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
7 changes: 4 additions & 3 deletions app/controllers/data_drip/backfill_runs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -160,15 +160,15 @@ 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

backfill_class =
DataDrip.all.find { |klass| klass.name == backfill_class_name }

if backfill_class.nil?
render json: { html: "" }
render json: { html: "", instructions: nil }
return
end

Expand All @@ -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
Expand Down
67 changes: 67 additions & 0 deletions app/javascript/data_drip/controllers/backfill_form_controller.js
Original file line number Diff line number Diff line change
@@ -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 =
'<div style="margin-bottom:24px;padding:16px;border-radius:8px;background:#eff6ff;border:1px solid #bfdbfe">' +
'<h3 style="font-size:12px;font-weight:600;color:#1d4ed8;margin-bottom:8px;letter-spacing:0.05em">INSTRUCTIONS</h3>' +
'<div style="color:#374151;font-size:13px;line-height:1.5">' + renderInstructions(data.instructions) + "</div>" +
"</div>"
}
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")
}
}
84 changes: 84 additions & 0 deletions app/javascript/data_drip/markdown.js
Original file line number Diff line number Diff line change
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
}

function inlineFormat(text) {
return text
.replace(/\*\*(.+?)\*\*/g, '<strong style="font-weight:600;color:#1e293b">$1</strong>')
.replace(/`(.+?)`/g, '<code style="padding:1px 5px;border-radius:4px;background:#dbeafe;color:#1e40af;font-size:12px;font-family:monospace">$1</code>')
}

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 += '<pre style="margin:6px 0;padding:10px 12px;border-radius:6px;background:#1e293b;color:#e2e8f0;font-size:12px;font-family:monospace;line-height:1.5;overflow-x:auto">' + esc(codeLines.join("\n")) + "</pre>"
codeLines = []
inCodeBlock = false
} else {
if (inList) { html += "</ul>"; inList = false }
inCodeBlock = true
}
continue
}

if (inCodeBlock) {
codeLines.push(line)
continue
}

if (line.trim() === "") {
if (inList) { html += "</ul>"; inList = false }
html += '<div style="height:6px"></div>'
continue
}

const headerMatch = line.match(/^(#{1,3})\s+(.+)$/)
if (headerMatch) {
if (inList) { html += "</ul>"; 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 += '<div style="' + style + '">' + inlineFormat(esc(headerMatch[2])) + "</div>"
continue
}

const bulletMatch = line.match(/^[-*]\s+(.+)$/)
if (bulletMatch) {
if (!inList) { html += '<ul style="list-style:disc;padding-left:20px;margin:4px 0">'; inList = true }
html += '<li style="margin-bottom:2px">' + inlineFormat(esc(bulletMatch[1])) + "</li>"
continue
}

if (inList) { html += "</ul>"; inList = false }
html += '<p style="margin-bottom:3px">' + inlineFormat(esc(line)) + "</p>"
}

if (inList) html += "</ul>"
return html
}
87 changes: 7 additions & 80 deletions app/views/data_drip/backfill_runs/new.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

<div class="min-h-screen flex items-center justify-center m-8 bg-gradient-to-r from-slate-700 to-gray-900">
<div class="w-full max-w-lg">
<%= 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| %>
<h1 class="text-4xl md:text-5xl font-semibold text-center mb-10 text-gray-700">New Backfill Run</h1>
<% if @run.errors.any? %>
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
Expand All @@ -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" %>
<datalist id="backfill_class_options">
<% backfill_class_names.each do |name| %>
Expand All @@ -28,6 +29,8 @@
</datalist>
</div>

<div id="backfill-instructions-container" data-backfill-form-target="instructionsContainer"></div>

<div class="mb-6">
<%= 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 %>
Expand All @@ -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 %>
</div>

<div id="backfill-options-container"></div>
<div id="backfill-options-container" data-backfill-form-target="optionsContainer"></div>

<div class="mb-6">
<%= 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" %>
</div>
<%= hidden_field_tag :user_timezone, "", id: "user_timezone" %>
<%= hidden_field_tag :user_timezone, "", id: "user_timezone", data: { "backfill-form-target": "timezone" } %>
<div class="flex justify-center">
<%= 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" %>
</div>
<% end %>
</div>
</div>
<script>
function handleBackfillClassChange() {
var selectedClass = this.value;
var optionsContainer = document.getElementById("backfill-options-container");

if (!optionsContainer) return;

optionsContainer.innerHTML = "";

if (selectedClass && selectedClass !== "Select a backfill class") {
fetch("<%= backfill_options_backfill_runs_path %>?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(function (response) {
if (!response.ok) throw new Error("Network response was not ok: " + response.status);
return response.json();
})
.then(function (data) {
if (data.html) {
optionsContainer.innerHTML = data.html;
optionsContainer.querySelectorAll("script").forEach(function (oldScript) {
var newScript = document.createElement("script");
newScript.textContent = oldScript.textContent;
oldScript.parentNode.replaceChild(newScript, oldScript);
});
}
})
.catch(function (error) {
console.error("Error fetching backfill options:", error);
});
}
}

function initializeBackfillForm() {
var timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
var timeZoneInput = document.getElementById("user_timezone");
if (timeZoneInput) {
timeZoneInput.value = timeZone;
}

var classInput = document.getElementById("backfill_run_backfill_class_name");
var optionsContainer = document.getElementById("backfill-options-container");

if (classInput && optionsContainer) {
classInput.removeEventListener("change", handleBackfillClassChange);
classInput.addEventListener("change", handleBackfillClassChange);
} else {
setTimeout(initializeBackfillForm, 100);
}
}

if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeBackfillForm);
} else {
initializeBackfillForm();
}

document.addEventListener("DOMContentLoaded", function () {
var form = document.querySelector('form[action*="backfill_runs"]');
var submitBtn = document.getElementById("submit-btn");

if (form && submitBtn) {
form.addEventListener("submit", function () {
submitBtn.disabled = true;
submitBtn.textContent = "Creating...";
submitBtn.classList.add("opacity-50", "cursor-not-allowed");
});
}
});
</script>


1 change: 1 addition & 0 deletions config/importmap.rb
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 4 additions & 0 deletions lib/data_drip/backfill.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions lib/generators/data_drip/templates/backfill.rb.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 34 additions & 2 deletions spec/controllers/data_drip/backfill_runs_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading