From efaefbe3f584822181c78d9990c88cee4f484545 Mon Sep 17 00:00:00 2001 From: Harshit Soora Date: Thu, 30 Apr 2026 17:17:51 -0400 Subject: [PATCH 1/4] Workflows now support Advanced features to override launcher related params and environment variables --- .../app/controllers/workflows_controller.rb | 47 ++++- .../app/javascript/workflow_advanced.js | 160 ++++++++++++++++++ apps/dashboard/app/models/workflow.rb | 48 +++++- .../app/views/workflows/_advanced.html.erb | 84 +++++++++ .../app/views/workflows/_form.html.erb | 46 ++++- apps/dashboard/config/locales/en.yml | 4 + 6 files changed, 371 insertions(+), 18 deletions(-) create mode 100644 apps/dashboard/app/javascript/workflow_advanced.js create mode 100644 apps/dashboard/app/views/workflows/_advanced.html.erb diff --git a/apps/dashboard/app/controllers/workflows_controller.rb b/apps/dashboard/app/controllers/workflows_controller.rb index e72b8219e7..46ab30d741 100644 --- a/apps/dashboard/app/controllers/workflows_controller.rb +++ b/apps/dashboard/app/controllers/workflows_controller.rb @@ -16,12 +16,14 @@ def show def new @workflow = Workflow.new(index_params) @launchers = Launcher.all(project_directory) + @workflow_overrides = @workflow.overrides_attributes end # GET /projects/:id/workflows/edit def edit return unless load_project_and_workflow_objects @launchers = Launcher.all(project_directory) + @workflow_overrides = @workflow.overrides_attributes end # TODO to remove this with launcher_ids as we will need them after new UI @@ -54,9 +56,11 @@ def clone cloned = @workflow.deep_dup cloned.name += " (Copy)" session[:cloned_metadata] = cloned.metadata.to_h.deep_stringify_keys + session[:cloned_advanced_overrides] = cloned.advanced_overrides.to_h.deep_stringify_keys @workflow = cloned @launchers = Launcher.all(project_directory) + @workflow_overrides = @workflow.overrides_attributes render :new end @@ -146,16 +150,46 @@ def workflow_id end def permit_params - params + base = params .require(:workflow) .permit(:name, :description, :id, :sync_key_enabled, launcher_ids: []) - .merge(project_dir: project_directory, metadata: session.delete(:cloned_metadata) || {}) + + base.merge( + project_dir: project_directory, + metadata: session.delete(:cloned_metadata) || {}, + advanced_overrides: extract_advanced_overrides || + session.delete(:cloned_advanced_overrides) || {} + ) end def update_params - params + base = params .require(:workflow) .permit(:name, :description, :id, :sync_key_enabled, launcher_ids: []) + .to_h + + base[:advanced_overrides] = extract_advanced_overrides || {} + base + end + + # The override form re-uses the per-launcher smart-attribute widgets, which + # render their fields with name="launcher[]". So when + # the workflow form is submitted, the overrides arrive at params[:launcher], + # not under params[:workflow]. We pull them out here. + # + # Drops blank values and the *_min / *_max / *_exclude / *_fixed config + # keys (those are launcher-edit-time only). Returns nil when no launcher + # hash was submitted at all, so callers can fall back to cloned values. + def extract_advanced_overrides + raw = params[:launcher] + return nil if raw.blank? + raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h) + raw.to_h.each_with_object({}) do |(k, v), h| + key = k.to_s + next if key.end_with?('_min', '_max', '_exclude', '_fixed') + next if v.nil? || v.to_s.strip.empty? + h[key] = v + end end def project_directory @@ -163,7 +197,9 @@ def project_directory end def permit_json_data - params.permit(:project_id, :id, :zoom, :saved_at, :start_launcher, boxes: [:id, :title, :row, :col], edges: [:from, :to]).to_h + params.permit(:project_id, :id, :zoom, :saved_at, :start_launcher, + boxes: [:id, :title, :row, :col], + edges: [:from, :to]).to_h end def metadata_params(json) @@ -191,6 +227,7 @@ def handle_workflow_error(operation) flash.now[:alert] = message @launchers = Launcher.all(project_directory) + @workflow_overrides = @workflow.overrides_attributes render operation == :create ? :new : :edit end -end \ No newline at end of file +end diff --git a/apps/dashboard/app/javascript/workflow_advanced.js b/apps/dashboard/app/javascript/workflow_advanced.js new file mode 100644 index 0000000000..0bff0d1510 --- /dev/null +++ b/apps/dashboard/app/javascript/workflow_advanced.js @@ -0,0 +1,160 @@ +'use strict'; + +/* + * Workflow advanced overrides — dropdown filter & repeatable fields. + * + * launcher_edit.js drives the "Add new option" dropdown from its own + * newFieldData object, which lists every launcher field. On the workflow + * page we only want to expose six of them, so we filter the dropdown + * after launcher_edit.js has populated it. The same hook also re-evaluates + * the Add/No-more-options button label based on the filtered list. + * + * auto_environment_variable is a special case: a launcher can carry + * arbitrarily many env vars (each one's id gets the variable name + * appended once the user types into the name field). To support adding + * multiple env vars on the workflow page too, we treat env var as + * always-available — never filtered out of the dropdown, never used to + * decide that "no more options" remain. + */ + +const ALLOWED = [ + 'auto_accounts', + 'auto_environment_variable', + 'auto_queues', + 'auto_batch_clusters', + 'bc_num_hours', + 'auto_job_name' +]; + +// Fields that may be added repeatedly. Their dropdown entry is never +// filtered out, and they don't count toward the "no more options" +// check — so the Add button stays enabled even after one is on the page. +const REPEATABLE = ['auto_environment_variable']; + +// Mirrors the labels in launcher_edit.js's newFieldData, used when we +// need to re-insert a repeatable option that launcher_edit.js skipped. +const REPEATABLE_LABELS = { + auto_environment_variable: 'Environment Variable' +}; + +function filterDropdown(selectEl) { + Array.from(selectEl.options).forEach((opt) => { + if (ALLOWED.indexOf(opt.value) === -1) { + opt.remove(); + } + }); + + // launcher_edit.js's updateNewFieldOptions skips a field if + // #launcher_ already exists. For repeatable fields we want + // them to keep showing up, so re-add any that got skipped. + REPEATABLE.forEach((id) => { + if (ALLOWED.indexOf(id) === -1) return; + const present = Array.from(selectEl.options).some((opt) => opt.value === id); + if (!present) { + const opt = document.createElement('option'); + opt.value = id; + opt.text = REPEATABLE_LABELS[id] || id; + selectEl.add(opt); + } + }); +} + +function refreshAddButtonLabel() { + const btn = document.getElementById('add_new_field_button'); + if (!btn) return; + + // Repeatable fields always count as "still addable". For the rest, + // an option remains as long as #launcher_ isn't on the page yet. + const remaining = ALLOWED.some((id) => { + if (REPEATABLE.indexOf(id) !== -1) return true; + return document.getElementById('launcher_' + id) === null; + }); + btn.textContent = remaining ? 'Add new option' : 'No more options'; + btn.disabled = !remaining; +} + +// launcher_edit.js wires its remove/edit click handlers like: +// $('.new_launcher').find('.editable-form-field').find('.btn-danger')... +// The workflow form has no `.new_launcher` ancestor, so server-rendered +// fields (the ones loaded from saved advanced_overrides on edit) never +// get those handlers — meaning their Remove button does nothing. +// Newly-added fields are unaffected because addInProgressField attaches +// handlers inline as it inserts each field. +// +// We re-bind here, scoped to the advanced overrides card, so existing +// fields behave the same as freshly-added ones. +function wireExistingFieldHandlers() { + const card = document.getElementById('workflow_advanced_card'); + if (!card) return; + + // Remove (trash) buttons: drop the field from the DOM, then refresh + // the Add-button label. Removed fields aren't submitted, so the + // controller's extract_advanced_overrides will leave them out of the + // saved hash on the next save. + card.querySelectorAll('.editable-form-field .btn-danger').forEach((btn) => { + btn.addEventListener('click', (event) => { + const entireDiv = event.target.parentElement; + entireDiv.remove(); + refreshAddButtonLabel(); + }); + }); + + // Edit (pencil) buttons toggle the edit panel open, mirroring + // launcher_edit.js's showEditField/saveEdit pair. + card.querySelectorAll('.editable-form-field .btn-primary').forEach((editBtn) => { + editBtn.addEventListener('click', (event) => { + const entireDiv = event.target.parentElement; + const editField = entireDiv.querySelector('.edit-group'); + if (editField) editField.classList.remove('d-none'); + + const saveButton = entireDiv.querySelector('.btn-success'); + if (saveButton) saveButton.classList.remove('d-none'); + event.target.disabled = true; + + if (saveButton) { + saveButton.onclick = (e) => { + const div = e.target.parentElement; + const ef = div.querySelector('.edit-group'); + if (ef) ef.classList.add('d-none'); + const sb = div.querySelector('.btn-success'); + const eb = div.querySelector('.btn-primary'); + if (sb) sb.classList.add('d-none'); + if (eb) eb.disabled = false; + }; + } + }); + }); +} + +document.addEventListener('DOMContentLoaded', () => { + const btn = document.getElementById('add_new_field_button'); + if (!btn) return; + + // launcher_edit.js attaches a click handler that builds the