Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6580add
Hook up workflows submit to wizard continue button
mrmir Jul 27, 2026
99618df
Extract persisting of the Matrix into a service
klaustopher Jul 28, 2026
b496c78
Extract the workflows matrix template into a view component
klaustopher Jul 28, 2026
f86022c
Introduce a MatrixContext to wrap the setup for the workflows editor
klaustopher Jul 28, 2026
1030a13
Add a Matrix controller that independently from the rendered context …
klaustopher Jul 28, 2026
0b3c872
Remove special behavior for workflows and integrate them like the oth…
klaustopher Jul 28, 2026
1efd819
Ensure that the save unchanged dialog does not submit the external fo…
klaustopher Jul 28, 2026
509b44f
Do not hard code a random id for tests, but instead derive it from cu…
klaustopher Jul 28, 2026
f1630f9
Merge branch 'dev' into suggestion-for-fnd-180
klaustopher Jul 28, 2026
4fd7e51
Simplify PageComponent and put all logic in the StepEditor
klaustopher Jul 28, 2026
74ff556
Improve comment for arguing what we do in the Workflows form
klaustopher Jul 28, 2026
af3fc1b
Inline tab data generation into the component
klaustopher Jul 28, 2026
4289555
Get rid of WorkflowHelper and inline the linked? check
klaustopher Jul 28, 2026
7bc15f8
Pass the `MatrixContext` into the MatrixTableComponent
klaustopher Jul 28, 2026
1b91b41
Better comment for MatrixController
klaustopher Jul 28, 2026
bd31476
Hand over the `MatrixContext` to the dialogs and do all calculations …
klaustopher Jul 28, 2026
6d1934e
Cleanup routes
klaustopher Jul 28, 2026
c7b1e81
Reintroduce concept of currently displayed statuses, with a better wo…
klaustopher Jul 28, 2026
d77ba91
Fix styling issues
klaustopher Jul 28, 2026
6934be6
Get rid of superfluous comments
klaustopher Jul 28, 2026
63bc514
Optimize lookup if transisiton exists
klaustopher Jul 28, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<%= within_step_frame do %>
<%= reuse_mode_banner %>
<%= primer_form_with(**step_form_options) do |f| %>
<%= render(step_editor.form_class.new(f)) %>
<%= render(step_editor.editor(f)) %>
<% end %>
<% end %>
<% else %>
Expand Down
10 changes: 7 additions & 3 deletions app/components/work_package_types/wizard/page_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,13 @@ def step_form_options
def within_step_frame(&)
return capture(&) unless step_editor.linkable_aspect?

render(WorkPackageTypes::ReloadableConfigurationFrameComponent.new(reload_url: step_url), &)
render(
WorkPackageTypes::ReloadableConfigurationFrameComponent.new(
reload_url: step_url,
reload_from_location: step_editor.reload_from_location?
),
&
)
end

def reuse_mode_banner
Expand All @@ -126,8 +132,6 @@ def step_body
case current_step
when :form_configuration
FormConfigurationStepComponent.new(type:)
when :workflows
WorkflowsStepComponent.new(type:)
when :project_attributes
ProjectAttributesStepComponent.new(type:)
when :projects
Expand Down
25 changes: 22 additions & 3 deletions app/components/work_package_types/wizard/step_editors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def self.for(step, type)
case step
when :details then Details.new(type)
when :defaults then Defaults.new(type)
when :workflows then Workflows.new(type)
end
end

Expand All @@ -55,18 +56,23 @@ def linkable_aspect? = aspect.present?

def model = type

# Data attributes for the form element, e.g. Stimulus wiring.
def editor(_builder)
raise SubclassResponsibilityError
end

def form_data = {}

def reload_from_location? = false

def readonly? = linkable_aspect? && type.linked?(aspect)
end

class Details < Base
def form_class = WorkPackageTypes::DetailsForm
def editor(builder) = WorkPackageTypes::DetailsForm.new(builder)
end

class Defaults < Base
def form_class = WorkPackageTypes::DefaultsForm
def editor(builder) = WorkPackageTypes::DefaultsForm.new(builder)

def aspect = Type::ConfigurationLink::DEFAULTS

Expand All @@ -77,6 +83,19 @@ def model
# Without this the pattern input cannot be toggled as the subject mode changes.
def form_data = readonly? ? {} : model.stimulus_data
end

class Workflows < Base
def aspect = Type::ConfigurationLink::WORKFLOWS

# The workflow matrix editor is not using a primer form, thus it does not consume the builder
# It can internally switch what tab it is editing, those trigger a submit to its own controller action.
# The submit of the final page happens through the wizard's continue button
def editor(_builder) = WorkflowsStepComponent.new(type:)

# The matrix keeps the selected roles and transition tab in the page URL, which a
# reload from the step path would discard.
def reload_from_location? = true
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ See COPYRIGHT and LICENSE files for more details.

++#%>

<%= render(WorkPackageTypes::ReloadableConfigurationFrameComponent.new(reload_url:, reload_from_location: true)) do %>
<%= render(WorkPackageTypes::ReuseModeBannerComponent.new(type:, aspect: Type::ConfigurationLink::WORKFLOWS)) %>

<% if roles.any? %>
<%= helpers.turbo_frame_tag "workflow-table", src: helpers.edit_type_workflow_tab_path(type, current_tab, role_ids: roles.map(&:id)) %>
<% end %>
<% if roles.any? %>
<%= helpers.turbo_frame_tag "workflow-table", src: matrix_url %>
<% end %>
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@

module WorkPackageTypes
module Wizard
# The matrix renders no form of its own, so its inputs are submitted by the wizard form
# that PageComponent wraps around this, and persisted by CreationWizardController.
class WorkflowsStepComponent < ApplicationComponent
include OpPrimer::ComponentHelpers

Expand All @@ -41,12 +43,15 @@ def initialize(type:)

def type = model

def reload_url
helpers.type_creation_wizard_path(model, step: :workflows)
def matrix_url
helpers.type_workflow_matrix_path(
type,
tab: helpers.params[:tab],
role_ids: roles.map(&:id),
status_ids: helpers.params[:status_ids]
)
end

def current_tab = helpers.params[:tab].presence || "always"

def roles
Workflow.selected_roles(helpers.params[:role_ids])
end
Expand Down
4 changes: 2 additions & 2 deletions app/components/workflows/blankslate_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ See COPYRIGHT and LICENSE files for more details.
render(Primer::Beta::Blankslate.new(border: true)) do |blankslate|
blankslate.with_heading(tag: :h2).with_content(t("admin.workflows.blankslate.title"))
blankslate.with_description_content(t(description_key))
unless read_only?
unless readonly?
blankslate.with_primary_action(
href: helpers.status_dialog_type_workflow_tab_path(@type, @tab, role_ids: @roles.map(&:id)),
href: helpers.status_dialog_type_workflow_matrix_path(type, tab:, role_ids: roles.map(&:id)),
scheme: :secondary,
data: { controller: "async-dialog" }
) do |button|
Expand Down
12 changes: 6 additions & 6 deletions app/components/workflows/blankslate_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,19 @@ module Workflows
class BlankslateComponent < ApplicationComponent
include OpPrimer::ComponentHelpers

def initialize(roles:, type:, tab:)
def initialize(context:)
super
@roles = roles
@type = type
@tab = tab
@context = context
end

private

def read_only? = helpers.workflow_linked?(@type)
attr_reader :context

delegate :type, :tab, :roles, :readonly?, to: :context

def description_key
read_only? ? "admin.workflows.blankslate.linked_description" : "admin.workflows.blankslate.description"
readonly? ? "admin.workflows.blankslate.linked_description" : "admin.workflows.blankslate.description"
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ See COPYRIGHT and LICENSE files for more details.
++#%>

<%= component_wrapper do %>
<% transition_tabs = helpers.workflow_tabs(@type) %>
<% current_transition_tab = helpers.selected_tab(transition_tabs) %>
<%=
render Primer::OpenProject::SubHeader.new do |subheader|
subheader.with_filter_component do
Expand All @@ -44,21 +42,21 @@ See COPYRIGHT and LICENSE files for more details.
helpers.tab_label(current_transition_tab)
end

transition_tabs.each do |tab|
transition_tabs.each do |transition_tab|
menu.with_item(
label: helpers.tab_label(tab),
label: helpers.tab_label(transition_tab),
tag: :a,
href: tab[:path],
active: current_transition_tab == tab,
content_arguments: { data: tab[:data] }
href: transition_tab[:path],
active: transition_tab[:name] == tab,
content_arguments: { data: transition_tab[:data] }
) do |item|
item.with_description { tab[:description] }
item.with_description { transition_tab[:description] }
end
end
end
end

if @type && @available_roles.any?
if eligible_roles.any?
flex.with_column(ml: 2) do
render(
Primer::Alpha::SelectPanel.new(
Expand All @@ -70,18 +68,18 @@ See COPYRIGHT and LICENSE files for more details.
) do |panel|
panel.with_show_button(scheme: :secondary) do |button|
button.with_trailing_visual_icon(icon: :"triangle-down")
if @roles.many?
t("admin.workflows.role_selector.roles", count: @roles.size)
elsif @roles.one?
t("admin.workflows.role_selector.label", role: @roles.first.name)
if roles.many?
t("admin.workflows.role_selector.roles", count: roles.size)
elsif roles.one?
t("admin.workflows.role_selector.label", role: roles.first.name)
else
t("admin.workflows.role_selector.no_role")
end
end
@available_roles.each do |available_role|
eligible_roles.each do |available_role|
panel.with_item(
label: available_role.name,
active: @roles.include?(available_role),
active: roles.include?(available_role),
item_id: available_role.id
)
end
Expand All @@ -99,13 +97,13 @@ See COPYRIGHT and LICENSE files for more details.
end
end

unless read_only?
unless readonly?
subheader.with_action_button(
tag: :a,
scheme: :secondary,
leading_icon: :plus,
label: t("admin.workflows.status_button"),
href: helpers.status_dialog_type_workflow_tab_path(@type, @tab, role_ids: @roles.map(&:id), status_ids: @statuses.pluck(:id).presence),
href: helpers.status_dialog_type_workflow_matrix_path(type, tab:, role_ids: roles.map(&:id), status_ids: statuses.pluck(:id).presence),
data: { controller: "async-dialog" }
) do
t("admin.workflows.status_button")
Expand All @@ -116,7 +114,7 @@ See COPYRIGHT and LICENSE files for more details.
scheme: :secondary,
leading_icon: :copy,
label: t(:button_copy),
href: helpers.new_type_workflow_copy_path(@type, source_role_id: @roles.first&.id),
href: helpers.new_type_workflow_copy_path(type, source_role_id: roles.first&.id),
aria: { label: t(:button_copy) },
title: t(:button_copy),
data: { controller: "async-dialog", "admin--workflow-checkbox-state-confirmation-trigger": "click" }
Expand All @@ -127,64 +125,49 @@ See COPYRIGHT and LICENSE files for more details.
end
%>

<% if @statuses.any? %>
<%= form_tag(
type_workflow_tab_path(@type),
id: form_id,
method: :patch,
autocomplete: "off",
data: {
controller: "admin--workflow-checkbox-state",
"admin--workflow-checkbox-state-has-status-changes-value": @has_status_changes
}
) do %>
<%= hidden_field_tag "type_id", @type.id %>
<% @roles.each do |role| %>
<%= hidden_field_tag "role_ids[]", role.id %>
<% end %>
<%= hidden_field_tag "tab", @tab %>

<%= helpers.render_tabs helpers.workflow_tabs(@type) %>

<% unless read_only? %>
<div class="workflow-save-bar">
<%=
render Primer::Beta::Button.new(scheme: :primary, type: :submit, mt: 3, ml: 3, mb: 4) do
t(:button_save)
end
%>
</div>

<%=
render(
Primer::OpenProject::FeedbackDialog.new(
title: t("admin.workflows.leave_confirmation.title"),
data: {
"admin--workflow-checkbox-state-target": "confirmationDialog"
}
)
) do |dialog|
dialog.with_feedback_message(icon_arguments: { icon: :none }) do |message|
message.with_heading(tag: :h2) { t("admin.workflows.leave_confirmation.title") }
message.with_description_content(t("admin.workflows.leave_confirmation.description"))
end
<% if statuses.any? %>
<%# The enclosing form belongs to the host page; this only groups what the dirty-state
controller needs. %>
<%= tag.div(id: state_id, data: state_data) do %>
<%= hidden_field_tag "type_id", type.id %>
<% roles.each do |role| %>
<%= hidden_field_tag "role_ids[]", role.id %>
<% end %>
<%= hidden_field_tag "tab", tab %>

<%= render(Workflows::MatrixTableComponent.new(context:)) %>

<% unless readonly? %>
<%=
render(
Primer::OpenProject::FeedbackDialog.new(
title: t("admin.workflows.leave_confirmation.title"),
data: {
"admin--workflow-checkbox-state-target": "confirmationDialog"
}
)
) do |dialog|
dialog.with_feedback_message(icon_arguments: { icon: :none }) do |message|
message.with_heading(tag: :h2) { t("admin.workflows.leave_confirmation.title") }
message.with_description_content(t("admin.workflows.leave_confirmation.description"))
end

dialog.with_footer do
component_collection do |footer|
footer.with_component(
Primer::Beta::Button.new(scheme: :danger, data: { "admin--workflow-checkbox-state-target": "ignoreButton" })
) { t("admin.workflows.leave_confirmation.ignore") }
dialog.with_footer do
component_collection do |footer|
footer.with_component(
Primer::Beta::Button.new(scheme: :danger, data: { "admin--workflow-checkbox-state-target": "ignoreButton" })
) { t("admin.workflows.leave_confirmation.ignore") }

footer.with_component(
Primer::Beta::Button.new(scheme: :primary, data: { "admin--workflow-checkbox-state-target": "saveButton" })
) { t("admin.workflows.leave_confirmation.save") }
end
footer.with_component(
Primer::Beta::Button.new(scheme: :primary, data: { "admin--workflow-checkbox-state-target": "saveButton" })
) { t("admin.workflows.leave_confirmation.save") }
end
end
%>
<% end %>
end
%>
<% end %>
<% end %>
<% else %>
<%= render Workflows::BlankslateComponent.new(roles: @roles, type: @type, tab: @tab) %>
<%= render Workflows::BlankslateComponent.new(context:) %>
<% end %>
<% end %>
Loading
Loading