From 8007d9f2c1668e54d0615edf2f8dfc2516a1c1bb Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 20 Feb 2026 19:00:13 +0000 Subject: [PATCH 01/16] working kanban board --- app/models/discourse_workflow/workflow.rb | 39 ++ .../discourse_workflow/workflow_serializer.rb | 5 + .../admin/components/workflow-editor.gjs | 25 + .../workflow-quick-filters.gjs | 534 +++++++++++++++++- .../discourse/initializers/init-workflow.gjs | 4 + .../stylesheets/common/workflow_common.scss | 210 +++++++ config/locales/client.en.yml | 14 + plugin.rb | 113 ++++ spec/lib/workflow_serializer_spec.rb | 15 + spec/lib/workflow_spec.rb | 45 ++ .../admin/workflows_controller_spec.rb | 52 ++ spec/requests/workflow_list_filters_spec.rb | 58 ++ .../page_objects/pages/workflow_discovery.rb | 152 +++++ spec/system/workflow_quick_filters_spec.rb | 108 ++++ 14 files changed, 1364 insertions(+), 10 deletions(-) create mode 100644 spec/requests/admin/workflows_controller_spec.rb diff --git a/app/models/discourse_workflow/workflow.rb b/app/models/discourse_workflow/workflow.rb index a5bd34d..8899a05 100644 --- a/app/models/discourse_workflow/workflow.rb +++ b/app/models/discourse_workflow/workflow.rb @@ -20,6 +20,45 @@ class Workflow < ActiveRecord::Base scope :ordered, -> { order("lower(name) ASC") } + def kanban_compatible? + steps = workflow_steps.includes(:workflow_step_options).to_a + return false if steps.blank? + + positions = steps.map { |step| step.position.to_i } + return false if positions.uniq.size != positions.size + + start_steps = steps.select { |step| step.position.to_i == 1 } + return false unless start_steps.one? + + step_ids = steps.map(&:id) + step_lookup = step_ids.index_with(true) + edges = Hash.new { |hash, key| hash[key] = [] } + + steps.each do |step| + step.workflow_step_options.each do |step_option| + target_step_id = step_option.target_step_id + next if target_step_id.blank? + return false if !step_lookup[target_step_id] + + edges[step.id] << target_step_id + end + end + + start_step_id = start_steps.first.id + visited = {} + stack = [start_step_id] + + until stack.empty? + current_step_id = stack.pop + next if visited[current_step_id] + + visited[current_step_id] = true + edges[current_step_id].each { |target_step_id| stack << target_step_id } + end + + visited.size == step_ids.size + end + def validation_warnings warnings = [] diff --git a/app/serializers/discourse_workflow/workflow_serializer.rb b/app/serializers/discourse_workflow/workflow_serializer.rb index 07ace56..d65392c 100644 --- a/app/serializers/discourse_workflow/workflow_serializer.rb +++ b/app/serializers/discourse_workflow/workflow_serializer.rb @@ -9,6 +9,7 @@ class WorkflowSerializer < ApplicationSerializer :description, :enabled, :overdue_days, + :kanban_compatible, :workflow_steps_count, :starting_category_id, :final_category_id, @@ -34,5 +35,9 @@ def final_category_id def validation_warnings object.validation_warnings end + + def kanban_compatible + object.kanban_compatible? + end end end diff --git a/assets/javascripts/discourse/admin/components/workflow-editor.gjs b/assets/javascripts/discourse/admin/components/workflow-editor.gjs index e2d5b8f..489156c 100644 --- a/assets/javascripts/discourse/admin/components/workflow-editor.gjs +++ b/assets/javascripts/discourse/admin/components/workflow-editor.gjs @@ -220,6 +220,31 @@ export default class WorkflowEditor extends Component { />

{{i18n "admin.discourse_workflow.workflows.overdue_days_help"}}

+ {{#if @workflow.id}} +
+ +

+ {{#if @workflow.kanban_compatible}} + + {{i18n + "admin.discourse_workflow.workflows.kanban_compatibility.compatible" + }} + + {{else}} + + {{i18n + "admin.discourse_workflow.workflows.kanban_compatibility.incompatible" + }} + + {{/if}} +

+

{{i18n + "admin.discourse_workflow.workflows.kanban_compatibility.help" + }}

+
+ {{/if}} {{#if this.showSteps}}
{ + if (Number(this.recentlyDraggedTopicId) === normalizedTopicId) { + this.recentlyDraggedTopicId = null; + } + }, 150); + } + + this.draggedTopicId = null; + this.draggedFromPosition = null; + } + + willDestroy(...args) { + super.willDestroy(...args); + + if (typeof document !== "undefined") { + document.body.classList.remove("workflow-kanban-view"); + } + } sanitizeFilters(filters) { const sanitized = {}; @@ -34,16 +67,51 @@ export default class WorkflowQuickFiltersConnector extends Component { sanitized.workflow_step_position = String(filters.workflow_step_position); } + if (filters?.workflow_view === "kanban") { + sanitized.workflow_view = "kanban"; + } + return sanitized; } - get currentLocation() { + get routeTopicList() { + const routeAttributes = this.router.currentRoute?.attributes; + return ( - this.router.currentURL || - `${window.location.pathname}${window.location.search}` + routeAttributes?.list || + routeAttributes?.model?.list || + routeAttributes?.model || + routeAttributes ); } + get topicList() { + return this.routeTopicList || this.discovery.currentTopicList; + } + + get topicListMetadata() { + try { + return this.topicList?.topic_list || this.topicList; + } catch { + return this.topicList; + } + } + + get hasWorkflowFilter() { + return this.topicList?.filter?.toString() === "workflow"; + } + + get currentLocation() { + // Consume router state so this getter recomputes on in-app transitions. + this.router.currentURL; + + if (typeof window !== "undefined") { + return `${window.location.pathname}${window.location.search}`; + } + + return this.router.currentURL || ""; + } + get currentPathname() { return this.currentLocation.split("?")[0]; } @@ -54,7 +122,12 @@ export default class WorkflowQuickFiltersConnector extends Component { } get isWorkflowRoute() { - return this.currentPathname.startsWith("/workflow"); + return ( + this.hasWorkflowFilter || + this.router.currentRouteName?.startsWith("discovery.workflow") || + this.currentPathname.startsWith("/workflow") || + this.currentPathname.startsWith("/filter/workflow") + ); } get hasMyCategoriesFilter() { @@ -73,16 +146,182 @@ export default class WorkflowQuickFiltersConnector extends Component { return !!this.currentSearchParams.get("workflow_step_position"); } + get isKanbanView() { + return this.workflowView === "kanban"; + } + + get canUseKanbanView() { + try { + return this.topicListMetadata?.workflow_kanban_compatible === true; + } catch { + return false; + } + } + + get showKanbanToggle() { + return this.isKanbanView || this.canUseKanbanView; + } + + get workflowViewLabel() { + return this.isKanbanView + ? "discourse_workflow.quick_filters.list_view" + : "discourse_workflow.quick_filters.kanban_view"; + } + + get kanbanWorkflowName() { + try { + return this.topicListMetadata?.workflow_kanban_workflow_name; + } catch { + return null; + } + } + + get kanbanSteps() { + try { + const steps = this.topicListMetadata?.workflow_kanban_steps || []; + return [...steps].sort((left, right) => left.position - right.position); + } catch { + return []; + } + } + + get kanbanTransitionMap() { + try { + const transitions = + this.topicListMetadata?.workflow_kanban_transitions || []; + const transitionMap = new Map(); + + transitions.forEach((transition) => { + const fromPosition = Number(transition.from_position); + const toPosition = Number(transition.to_position); + const optionSlug = transition.option_slug; + + if (!fromPosition || !toPosition || !optionSlug) { + return; + } + + transitionMap.set(`${fromPosition}:${toPosition}`, optionSlug); + }); + + return transitionMap; + } catch { + return new Map(); + } + } + + get kanbanStepNames() { + return this.kanbanSteps.reduce((accumulator, step) => { + accumulator[Number(step.position)] = step.name; + return accumulator; + }, {}); + } + + optionSlugForTransition(fromPosition, toPosition) { + return this.kanbanTransitionMap.get(`${fromPosition}:${toPosition}`); + } + + isColumnLegalDropTarget(position) { + if (!this.draggedTopicId || !this.draggedFromPosition) { + return false; + } + + if (Number(position) === Number(this.draggedFromPosition)) { + return true; + } + + return !!this.optionSlugForTransition( + Number(this.draggedFromPosition), + Number(position) + ); + } + + dropStateForColumn(position) { + if (!this.draggedTopicId || !this.draggedFromPosition) { + return null; + } + + if (Number(position) === Number(this.draggedFromPosition)) { + return "source"; + } + + return this.isColumnLegalDropTarget(position) ? "legal" : "illegal"; + } + + get kanbanColumns() { + const topics = (() => { + try { + const topicCollection = + this.discovery.currentTopicList?.topics || + this.topicList?.topics || + this.topicListMetadata?.topics || + this.topicListMetadata?.topic_list?.topics || + []; + return Array.isArray(topicCollection) ? topicCollection : []; + } catch { + return []; + } + })(); + + return this.kanbanSteps.map((step) => { + const position = Number(step.position); + const stepTopics = topics + .filter( + (topic) => + Number( + get(topic, "workflow_step_position") || + get(topic, "workflowStepPosition") + ) === position + ) + .map((topic) => ({ + id: get(topic, "id"), + title: get(topic, "title"), + workflow_step_position: Number( + get(topic, "workflow_step_position") || + get(topic, "workflowStepPosition") + ), + workflow_overdue: !!get(topic, "workflow_overdue"), + workflow_can_act: !!get(topic, "workflow_can_act"), + workflow_topic_url: + get(topic, "url") || + (get(topic, "slug") + ? `/t/${get(topic, "slug")}/${get(topic, "id")}` + : `/t/${get(topic, "id")}`), + is_transitioning: + Number(get(topic, "id")) === Number(this.transitionInFlightTopicId), + is_dragging: Number(get(topic, "id")) === Number(this.draggedTopicId), + })); + + const dropState = this.dropStateForColumn(position); + const columnClasses = ["workflow-kanban__column"]; + + if (dropState) { + columnClasses.push(`workflow-kanban__column--${dropState}`); + } + + return { + ...step, + drop_state: dropState, + column_class: columnClasses.join(" "), + topics: stepTopics, + topic_count_label: i18n("discourse_workflow.kanban.topic_count", { + count: stepTopics.length, + }), + }; + }); + } + @action initializeFilters() { const params = this.currentSearchParams; this.stepPosition = params.get("workflow_step_position") || ""; + this.workflowView = params.get("workflow_view") || null; if ( params.has("my_categories") || params.has("overdue") || params.has("overdue_days") || - params.has("workflow_step_position") + params.has("workflow_step_position") || + params.has("workflow_view") ) { return; } @@ -120,6 +359,7 @@ export default class WorkflowQuickFiltersConnector extends Component { overdue: sanitized.overdue || null, overdue_days: sanitized.overdue_days || null, workflow_step_position: sanitized.workflow_step_position || null, + workflow_view: sanitized.workflow_view || null, }; const currentParams = this.currentSearchParams; const unchanged = @@ -129,7 +369,8 @@ export default class WorkflowQuickFiltersConnector extends Component { (currentParams.get("overdue_days") || null) === queryParams.overdue_days && (currentParams.get("workflow_step_position") || null) === - queryParams.workflow_step_position; + queryParams.workflow_step_position && + (currentParams.get("workflow_view") || null) === queryParams.workflow_view; if (unchanged) { return; @@ -213,17 +454,179 @@ export default class WorkflowQuickFiltersConnector extends Component { this.navigateWithFilters({}); } + @action + toggleWorkflowView() { + const params = new URLSearchParams(this.currentSearchParams.toString()); + + if (this.isKanbanView) { + params.delete("workflow_view"); + this.workflowView = null; + } else { + params.set("workflow_view", "kanban"); + this.workflowView = "kanban"; + } + + this.syncBodyClass(); + this.navigateWithFilters(Object.fromEntries(params.entries())); + } + + @action + cardDragStart(topic, event) { + if (!topic.workflow_can_act || this.transitionInFlightTopicId) { + event.preventDefault(); + return; + } + + this.draggedTopicId = Number(topic.id); + this.draggedFromPosition = Number(topic.workflow_step_position); + event.dataTransfer.setData("text/plain", String(topic.id)); + event.dataTransfer.dropEffect = "move"; + event.dataTransfer.effectAllowed = "move"; + } + + @action + cardDragEnd(topic) { + this.clearDragState(topic?.id); + } + + @action + openKanbanTopic(topic, event) { + if ( + this.transitionInFlightTopicId || + this.draggedTopicId || + Number(this.recentlyDraggedTopicId) === Number(topic.id) + ) { + event.preventDefault(); + return; + } + + if (event.metaKey || event.ctrlKey) { + window.open(topic.workflow_topic_url, "_blank", "noopener,noreferrer"); + return; + } + + this.router.transitionTo(topic.workflow_topic_url); + } + + @action + openKanbanTopicWithKeyboard(topic, event) { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + + event.preventDefault(); + this.openKanbanTopic(topic, event); + } + + @action + columnDragOver(column, event) { + if (!this.isColumnLegalDropTarget(column.position)) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = + Number(column.position) === Number(this.draggedFromPosition) + ? "none" + : "move"; + } + + updateTopicTransitionState(topicId, targetPosition) { + const topicCollections = [ + this.discovery.currentTopicList?.topics, + this.topicList?.topics, + this.topicListMetadata?.topics, + this.topicListMetadata?.topic_list?.topics, + ]; + + topicCollections.forEach((topicCollection) => { + if (!Array.isArray(topicCollection)) { + return; + } + + const topic = topicCollection.find((candidate) => Number(candidate.id) === topicId); + if (!topic) { + return; + } + + set(topic, "workflow_step_position", targetPosition); + set(topic, "workflow_step_name", this.kanbanStepNames[targetPosition]); + set(topic, "workflow_overdue", false); + }); + } + + @action + async columnDrop(column, event) { + event.preventDefault(); + + const topicId = + Number(event.dataTransfer.getData("text/plain")) || this.draggedTopicId; + const fromPosition = Number(this.draggedFromPosition); + const toPosition = Number(column.position); + + if (!topicId || !fromPosition || !toPosition) { + this.clearDragState(topicId); + return; + } + + if (toPosition === fromPosition) { + this.clearDragState(topicId); + return; + } + + const optionSlug = this.optionSlugForTransition(fromPosition, toPosition); + if (!optionSlug) { + this.clearDragState(topicId); + return; + } + + this.transitionInFlightTopicId = topicId; + + try { + await ajax(`/discourse-workflow/act/${topicId}`, { + type: "POST", + data: { option: optionSlug }, + }); + + this.updateTopicTransitionState(topicId, toPosition); + } catch (error) { + popupAjaxError(error); + } finally { + this.transitionInFlightTopicId = null; + this.clearDragState(topicId); + } + } + @action syncStepPositionFromUrl() { - this.stepPosition = - this.currentSearchParams.get("workflow_step_position") || ""; + const params = this.currentSearchParams; + this.stepPosition = params.get("workflow_step_position") || ""; + this.workflowView = params.get("workflow_view") || null; + this.syncBodyClass(); + } + + @action + syncBodyClass() { + if (typeof document === "undefined") { + return; + } + + document.body.classList.toggle("workflow-kanban-view", this.isKanbanView); + document + .querySelector("#list-area .contents") + ?.classList.toggle("workflow-kanban-hide-topics", this.isKanbanView); } diff --git a/assets/javascripts/discourse/initializers/init-workflow.gjs b/assets/javascripts/discourse/initializers/init-workflow.gjs index bcd8f87..415a694 100644 --- a/assets/javascripts/discourse/initializers/init-workflow.gjs +++ b/assets/javascripts/discourse/initializers/init-workflow.gjs @@ -110,6 +110,10 @@ export default { replace: true, refreshModel: true, }); + addDiscoveryQueryParam("workflow_view", { + replace: true, + refreshModel: false, + }); withPluginApi((api) => { api.addAdminPluginConfigurationNav("discourse-workflow", [ diff --git a/assets/stylesheets/common/workflow_common.scss b/assets/stylesheets/common/workflow_common.scss index b822e8f..d948e44 100644 --- a/assets/stylesheets/common/workflow_common.scss +++ b/assets/stylesheets/common/workflow_common.scss @@ -106,6 +106,216 @@ body.workflow-topic { } } +.admin-interface { + .workflow-editor__kanban-compatible { + color: var(--success); + font-weight: 600; + } + + .workflow-editor__kanban-incompatible { + color: var(--danger); + font-weight: 600; + } +} + +.workflow-quick-filters { + .workflow-kanban { + display: none; + } +} + +.workflow-quick-filters.workflow-quick-filters--kanban-active { + flex-wrap: wrap; + + .workflow-kanban { + display: block; + flex-basis: 100%; + width: 100%; + } +} + +.workflow-quick-filters.workflow-quick-filters--kanban-active ~ + .discovery-topics-list { + .topic-list, + .load-more, + .topic-list-bottom { + display: none; + } +} + +body.workflow-kanban-view { + .discovery-topics-list { + .topic-list, + .load-more, + .topic-list-bottom { + display: none; + } + } +} + +.contents.workflow-kanban-hide-topics { + .topic-list, + .load-more, + .topic-list-bottom { + display: none; + } +} + +.workflow-kanban { + margin: 0.75rem 0 1rem; + + .workflow-kanban__header { + margin-bottom: 0.75rem; + } + + .workflow-kanban__title { + margin: 0; + font-size: var(--font-up-1); + } + + .workflow-kanban__workflow-name { + margin: 0.25rem 0 0; + color: var(--primary-medium); + font-size: var(--font-down-1); + } + + .workflow-kanban__columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem; + align-items: start; + } + + .workflow-kanban__column { + border: 1px solid var(--primary-low); + border-radius: 8px; + background: var(--secondary); + min-height: 180px; + overflow: hidden; + } + + .workflow-kanban__column--source { + border-color: var(--primary-medium); + } + + .workflow-kanban__column--legal { + border-color: var(--success); + + .workflow-kanban__cards { + background: color-mix(in srgb, var(--success) 14%, transparent); + } + } + + .workflow-kanban__column--illegal { + .workflow-kanban__cards { + background-image: repeating-linear-gradient( + 135deg, + transparent, + transparent 8px, + color-mix(in srgb, var(--danger) 14%, transparent) 8px, + color-mix(in srgb, var(--danger) 14%, transparent) 16px + ); + opacity: 0.85; + } + } + + .workflow-kanban__column-header { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.5rem; + align-items: center; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--primary-low); + background: var(--primary-very-low); + } + + .workflow-kanban__step-position { + font-weight: 700; + color: var(--primary-medium); + } + + .workflow-kanban__step-name { + font-weight: 600; + } + + .workflow-kanban__topic-count { + color: var(--primary-medium); + font-size: var(--font-down-1); + } + + .workflow-kanban__cards { + display: grid; + gap: 0.5rem; + padding: 0.75rem; + min-height: 120px; + transition: background-color 120ms ease, opacity 120ms ease; + } + + .workflow-kanban__card { + display: grid; + gap: 0.35rem; + border: 1px solid color-mix(in srgb, var(--primary-low) 80%, black); + border-radius: 8px; + padding: 0.65rem 0.75rem; + text-decoration: none; + color: var(--primary); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--secondary) 88%, white), + var(--secondary) + ); + box-shadow: 0 1px 2px color-mix(in srgb, var(--primary) 10%, transparent); + cursor: grab; + user-select: none; + transition: + transform 100ms ease, + box-shadow 100ms ease, + border-color 100ms ease; + } + + .workflow-kanban__card:hover { + border-color: var(--primary-medium); + box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 20%, transparent); + transform: translateY(-1px); + } + + .workflow-kanban__card--draggable:active { + cursor: grabbing; + } + + .workflow-kanban__card--dragging { + opacity: 0.55; + transform: scale(0.98); + } + + .workflow-kanban__card--transitioning { + opacity: 0.7; + cursor: progress; + } + + .workflow-kanban__card--locked { + opacity: 0.7; + cursor: not-allowed; + } + + .workflow-kanban__card-title { + font-weight: 500; + overflow-wrap: anywhere; + } + + .workflow-kanban__card-overdue { + color: var(--danger); + font-size: var(--font-down-1); + font-weight: 600; + } + + .workflow-kanban__empty-step { + margin: 0; + color: var(--primary-medium); + font-size: var(--font-down-1); + } +} + #workflow-visualisation { svg { z-index: 1000; diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 040c21b..281aa47 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -52,7 +52,16 @@ en: overdue: "Overdue" step_placeholder: "Step #" apply_step: "Step = X" + kanban_view: "Kanban" + list_view: "List" clear: "Clear" + kanban: + title: "Workflow Kanban" + workflow_name: "Workflow: %{workflow_name}" + empty_step: "No topics in this step" + topic_count: + one: "%{count} topic" + other: "%{count} topics" overdue_indicator: "Overdue" options: submit: @@ -115,6 +124,11 @@ en: description: "Description" overdue_days: "Overdue Days Override" overdue_days_help: "Blank uses the global default. 0 disables overdue behavior for this workflow." + kanban_compatibility: + label: "Kanban Compatibility" + compatible: "Compatible" + incompatible: "Not compatible" + help: "Kanban view requires one start step, unique positions, valid step targets, and reachability from the start step. Backward/cyclic paths are supported." workflow: short_title: "Workflow" editing: diff --git a/plugin.rb b/plugin.rb index e0cf70d..23c89ff 100644 --- a/plugin.rb +++ b/plugin.rb @@ -174,6 +174,76 @@ module ::DiscourseWorkflow entered_at <= threshold_days.days.ago end + add_to_class(:topic_list, :workflow_kanban_workflow) do + return @workflow_kanban_workflow if defined?(@workflow_kanban_workflow) + + workflow_ids = + topics + .map { |topic| topic.workflow_state&.workflow_id } + .compact + .uniq + + @workflow_kanban_workflow = + if workflow_ids.length == 1 + DiscourseWorkflow::Workflow + .includes(workflow_steps: { workflow_step_options: :workflow_option }) + .find_by(id: workflow_ids.first) + end + end + + add_to_class(:topic_list, :workflow_kanban_compatible) do + workflow = workflow_kanban_workflow + workflow.present? && workflow.kanban_compatible? + end + + add_to_class(:topic_list, :workflow_kanban_steps) do + return [] if !workflow_kanban_compatible + + workflow_kanban_workflow + .workflow_steps + .order(:position) + .map do |step| + { + id: step.id, + position: step.position, + name: step.name, + } + end + end + + add_to_class(:topic_list, :workflow_kanban_transitions) do + return [] if !workflow_kanban_compatible + + workflow = workflow_kanban_workflow + steps = workflow.workflow_steps.to_a + steps_by_id = steps.index_by(&:id) + first_option_for_edge = {} + + steps.each do |step| + step + .workflow_step_options + .sort_by { |option| option.position.to_i } + .each do |step_option| + target_step = steps_by_id[step_option.target_step_id] + next if target_step.blank? + + option_slug = step_option.workflow_option&.slug + next if option_slug.blank? + + edge_key = [step.position.to_i, target_step.position.to_i] + first_option_for_edge[edge_key] ||= option_slug + end + end + + first_option_for_edge.map do |(from_position, to_position), option_slug| + { + from_position: from_position, + to_position: to_position, + option_slug: option_slug, + } + end + end + add_to_serializer( :topic_view, :workflow_slug, @@ -270,6 +340,49 @@ module ::DiscourseWorkflow include_condition: -> { object.workflow_name.present? } ) { object.workflow_overdue } + add_to_serializer( + :topic_list_item, + :workflow_can_act, + include_condition: -> { object.workflow_name.present? } + ) do + begin + scope.ensure_can_create_topic_on_category!(object.category_id) + true + rescue Discourse::InvalidAccess + false + end + end + + add_to_serializer( + :topic_list, + :workflow_kanban_compatible, + include_condition: -> { + object.topics.any? { |topic| topic.workflow_state.present? } + } + ) { object.workflow_kanban_compatible } + + add_to_serializer( + :topic_list, + :workflow_kanban_workflow_name, + include_condition: -> { object.workflow_kanban_compatible } + ) { object.workflow_kanban_workflow.name } + + add_to_serializer( + :topic_list, + :workflow_kanban_steps, + include_condition: -> { + object.topics.any? { |topic| topic.workflow_state.present? } + } + ) { object.workflow_kanban_steps } + + add_to_serializer( + :topic_list, + :workflow_kanban_transitions, + include_condition: -> { + object.topics.any? { |topic| topic.workflow_state.present? } + } + ) { object.workflow_kanban_transitions } + on(:topic_created) do |*params| topic, opts = params diff --git a/spec/lib/workflow_serializer_spec.rb b/spec/lib/workflow_serializer_spec.rb index a676571..f753066 100644 --- a/spec/lib/workflow_serializer_spec.rb +++ b/spec/lib/workflow_serializer_spec.rb @@ -30,6 +30,21 @@ expect(serializer.workflow_steps_count).to eq(2) expect(serializer.starting_category_id).to eq(category_1.id) expect(serializer.final_category_id).to eq(category_2.id) + expect(serializer.kanban_compatible).to eq(false) expect { serializer.as_json }.not_to raise_error end + + it "serializes kanban compatibility when the workflow graph is compatible" do + option = Fabricate(:workflow_option, slug: "next", name: "Next") + Fabricate( + :workflow_step_option, + workflow_step_id: step_1.id, + workflow_option_id: option.id, + target_step_id: step_2.id, + ) + + serializer = described_class.new(workflow, scope: Guardian.new(admin)) + + expect(serializer.kanban_compatible).to eq(true) + end end diff --git a/spec/lib/workflow_spec.rb b/spec/lib/workflow_spec.rb index 4b6c6a2..0c1e334 100644 --- a/spec/lib/workflow_spec.rb +++ b/spec/lib/workflow_spec.rb @@ -11,4 +11,49 @@ expect(workflow.reload.slug).to eq(original_slug) end + + it "is kanban compatible for a connected workflow" do + workflow = Fabricate(:workflow, name: "Kanban Compatible") + step_1 = Fabricate(:workflow_step, workflow_id: workflow.id, position: 1) + step_2 = Fabricate(:workflow_step, workflow_id: workflow.id, position: 2) + option = Fabricate(:workflow_option, slug: "next") + Fabricate( + :workflow_step_option, + workflow_step_id: step_1.id, + workflow_option_id: option.id, + target_step_id: step_2.id, + ) + + expect(workflow.kanban_compatible?).to eq(true) + end + + it "is kanban compatible when there is a cycle through backward transitions" do + workflow = Fabricate(:workflow, name: "Kanban Cycle") + step_1 = Fabricate(:workflow_step, workflow_id: workflow.id, position: 1) + step_2 = Fabricate(:workflow_step, workflow_id: workflow.id, position: 2) + option_1 = Fabricate(:workflow_option, slug: "next") + option_2 = Fabricate(:workflow_option, slug: "back") + Fabricate( + :workflow_step_option, + workflow_step_id: step_1.id, + workflow_option_id: option_1.id, + target_step_id: step_2.id, + ) + Fabricate( + :workflow_step_option, + workflow_step_id: step_2.id, + workflow_option_id: option_2.id, + target_step_id: step_1.id, + ) + + expect(workflow.kanban_compatible?).to eq(true) + end + + it "is not kanban compatible when steps are disconnected from start" do + workflow = Fabricate(:workflow, name: "Kanban Disconnected") + Fabricate(:workflow_step, workflow_id: workflow.id, position: 1) + Fabricate(:workflow_step, workflow_id: workflow.id, position: 2) + + expect(workflow.kanban_compatible?).to eq(false) + end end diff --git a/spec/requests/admin/workflows_controller_spec.rb b/spec/requests/admin/workflows_controller_spec.rb new file mode 100644 index 0000000..f2d6c72 --- /dev/null +++ b/spec/requests/admin/workflows_controller_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require_relative "../../plugin_helper" + +describe DiscourseWorkflow::Admin::WorkflowsController do + fab!(:admin) + fab!(:workflow) { Fabricate(:workflow, name: "Controller Workflow") } + fab!(:category_1, :category) + fab!(:category_2, :category) + fab!(:step_1) do + Fabricate( + :workflow_step, + workflow_id: workflow.id, + category_id: category_1.id, + position: 1, + ) + end + fab!(:step_2) do + Fabricate( + :workflow_step, + workflow_id: workflow.id, + category_id: category_2.id, + position: 2, + ) + end + + before { sign_in(admin) } + + it "serializes kanban compatibility for incompatible workflows" do + get "/admin/plugins/discourse-workflow/workflows.json" + + expect(response.status).to eq(200) + payload = response.parsed_body["workflows"].find { |w| w["id"] == workflow.id } + expect(payload["kanban_compatible"]).to eq(false) + end + + it "serializes kanban compatibility for compatible workflows" do + option = Fabricate(:workflow_option, slug: "next", name: "Next") + Fabricate( + :workflow_step_option, + workflow_step_id: step_1.id, + workflow_option_id: option.id, + target_step_id: step_2.id, + ) + + get "/admin/plugins/discourse-workflow/workflows.json" + + expect(response.status).to eq(200) + payload = response.parsed_body["workflows"].find { |w| w["id"] == workflow.id } + expect(payload["kanban_compatible"]).to eq(true) + end +end diff --git a/spec/requests/workflow_list_filters_spec.rb b/spec/requests/workflow_list_filters_spec.rb index f573e63..160f377 100644 --- a/spec/requests/workflow_list_filters_spec.rb +++ b/spec/requests/workflow_list_filters_spec.rb @@ -23,6 +23,15 @@ position: 2, ) end + fab!(:next_option) { Fabricate(:workflow_option, slug: "next", name: "Next") } + fab!(:step_transition) do + Fabricate( + :workflow_step_option, + workflow_step_id: step_1.id, + workflow_option_id: next_option.id, + target_step_id: step_2.id, + ) + end fab!(:topic_a) { Fabricate(:topic, category: category_a) } fab!(:topic_b) { Fabricate(:topic, category: category_a) } fab!(:state_a) do @@ -78,4 +87,53 @@ expect(topic_ids).to include(topic_b.id) expect(topic_ids).not_to include(topic_a.id) end + + it "serializes kanban metadata when the visible list is a single compatible workflow" do + get "/workflow.json" + + topic_list = response.parsed_body["topic_list"] + step_positions = topic_list["workflow_kanban_steps"].map { |step| step["position"] } + transitions = + topic_list["workflow_kanban_transitions"].map do |transition| + [ + transition["from_position"], + transition["to_position"], + transition["option_slug"], + ] + end + topics_by_id = topic_list["topics"].index_by { |topic| topic["id"] } + + expect(topic_list["workflow_kanban_compatible"]).to eq(true) + expect(topic_list["workflow_kanban_workflow_name"]).to eq(workflow.name) + expect(step_positions).to eq([1, 2]) + expect(transitions).to contain_exactly([1, 2, "next"]) + expect(topics_by_id[topic_a.id]["workflow_can_act"]).to eq(true) + expect(topics_by_id[topic_b.id]["workflow_can_act"]).to eq(false) + end + + it "does not mark kanban compatibility when multiple workflows are visible" do + other_workflow = Fabricate(:workflow, name: "Secondary Workflow") + other_step = + Fabricate( + :workflow_step, + workflow_id: other_workflow.id, + category_id: category_a.id, + position: 1, + ) + other_topic = Fabricate(:topic, category: category_a) + Fabricate( + :workflow_state, + topic_id: other_topic.id, + workflow_id: other_workflow.id, + workflow_step_id: other_step.id, + ) + + get "/workflow.json" + + topic_list = response.parsed_body["topic_list"] + expect(topic_list["workflow_kanban_compatible"]).to eq(false) + expect(topic_list["workflow_kanban_workflow_name"]).to be_nil + expect(topic_list["workflow_kanban_steps"]).to eq([]) + expect(topic_list["workflow_kanban_transitions"]).to eq([]) + end end diff --git a/spec/system/page_objects/pages/workflow_discovery.rb b/spec/system/page_objects/pages/workflow_discovery.rb index b6b1330..9a44cf1 100644 --- a/spec/system/page_objects/pages/workflow_discovery.rb +++ b/spec/system/page_objects/pages/workflow_discovery.rb @@ -28,6 +28,158 @@ def set_step_filter(step) self end + def has_workflow_view_toggle? + has_css?(".workflow-quick-filters__workflow-view") + end + + def has_no_workflow_view_toggle? + has_no_css?(".workflow-quick-filters__workflow-view") + end + + def toggle_workflow_view + find(".workflow-quick-filters__workflow-view").click + self + end + + def has_kanban_board? + has_css?(".workflow-kanban") + end + + def has_kanban_column_for_step?(position) + has_css?(".workflow-kanban__column[data-workflow-step-position='#{position}']") + end + + def has_kanban_card_for_topic?(topic_id) + has_css?(".workflow-kanban__card[data-topic-id='#{topic_id}']") + end + + def has_no_kanban_card_for_topic?(topic_id) + has_no_css?(".workflow-kanban__card[data-topic-id='#{topic_id}']") + end + + def has_kanban_card_for_topic_in_step?(topic_id, position) + has_css?( + ".workflow-kanban__column[data-workflow-step-position='#{position}'] .workflow-kanban__card[data-topic-id='#{topic_id}']" + ) + end + + def has_no_kanban_card_for_topic_in_step?(topic_id, position) + has_no_css?( + ".workflow-kanban__column[data-workflow-step-position='#{position}'] .workflow-kanban__card[data-topic-id='#{topic_id}']" + ) + end + + def has_kanban_legal_drop_target_for_step?(position) + has_css?( + ".workflow-kanban__column--legal[data-workflow-step-position='#{position}']" + ) + end + + def has_kanban_illegal_drop_target_for_step?(position) + has_css?( + ".workflow-kanban__column--illegal[data-workflow-step-position='#{position}']" + ) + end + + def drag_kanban_card_to_step(topic_id, position) + page.execute_script( + <<~JS, + const topicId = arguments[0]; + const stepPosition = arguments[1]; + const card = document.querySelector( + `.workflow-kanban__card[data-topic-id="${topicId}"]` + ); + const target = document.querySelector( + `.workflow-kanban__column[data-workflow-step-position="${stepPosition}"] .workflow-kanban__cards` + ); + + if (!card || !target) { + return; + } + + const dataTransfer = new DataTransfer(); + card.dispatchEvent( + new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + dataTransfer, + }) + ); + target.dispatchEvent( + new DragEvent("dragover", { + bubbles: true, + cancelable: true, + dataTransfer, + }) + ); + target.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer, + }) + ); + card.dispatchEvent( + new DragEvent("dragend", { + bubbles: true, + cancelable: true, + }) + ); + JS + topic_id, + position + ) + self + end + + def start_drag_on_kanban_card(topic_id) + page.execute_script( + <<~JS, + const topicId = arguments[0]; + const card = document.querySelector( + `.workflow-kanban__card[data-topic-id="${topicId}"]` + ); + + if (!card) { + return; + } + + const dataTransfer = new DataTransfer(); + const event = new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + dataTransfer, + }); + card.dispatchEvent(event); + JS + topic_id + ) + self + end + + def end_drag_on_kanban_card(topic_id) + page.execute_script( + <<~JS, + const topicId = arguments[0]; + const card = document.querySelector( + `.workflow-kanban__card[data-topic-id="${topicId}"]` + ); + + if (!card) { + return; + } + + const event = new DragEvent("dragend", { + bubbles: true, + cancelable: true, + }); + card.dispatchEvent(event); + JS + topic_id + ) + self + end + def current_url page.current_url end diff --git a/spec/system/workflow_quick_filters_spec.rb b/spec/system/workflow_quick_filters_spec.rb index fe25450..3cb83bc 100644 --- a/spec/system/workflow_quick_filters_spec.rb +++ b/spec/system/workflow_quick_filters_spec.rb @@ -6,6 +6,7 @@ fab!(:workflow) { Fabricate(:workflow, name: "Quick Filter Workflow") } fab!(:category_1, :category) fab!(:category_2, :category) + fab!(:category_3, :category) fab!(:step_1) do Fabricate( :workflow_step, @@ -22,6 +23,32 @@ position: 2, ) end + fab!(:step_3) do + Fabricate( + :workflow_step, + workflow_id: workflow.id, + category_id: category_3.id, + position: 3, + ) + end + fab!(:next_option) { Fabricate(:workflow_option, slug: "next", name: "Next") } + fab!(:finish_option) { Fabricate(:workflow_option, slug: "finish", name: "Finish") } + fab!(:step_transition) do + Fabricate( + :workflow_step_option, + workflow_step_id: step_1.id, + workflow_option_id: next_option.id, + target_step_id: step_2.id, + ) + end + fab!(:step_transition_2) do + Fabricate( + :workflow_step_option, + workflow_step_id: step_2.id, + workflow_option_id: finish_option.id, + target_step_id: step_3.id, + ) + end fab!(:topic_1) { Fabricate(:topic_with_op, category: category_1, user: user) } fab!(:topic_2) { Fabricate(:topic_with_op, category: category_1, user: user) } fab!(:workflow_state_1) do @@ -46,8 +73,10 @@ SiteSetting.workflow_enabled = true category_1.set_permissions(everyone: :full, staff: :full) category_2.set_permissions(everyone: :readonly, staff: :full) + category_3.set_permissions(everyone: :readonly, staff: :full) category_1.save! category_2.save! + category_3.save! topic_2.update_columns(category_id: category_2.id) workflow_state_1.update_columns(updated_at: 5.days.ago) sign_in(user) @@ -173,4 +202,83 @@ page, ).to have_no_css("tr[data-topic-id='#{topic_2.id}'] .workflow-overdue-indicator") end + + it "shows kanban toggle only when the current list is a single compatible workflow" do + workflow_discovery_page.visit_workflow + + expect(workflow_discovery_page).to have_workflow_view_toggle + end + + it "toggles between workflow list and kanban board view" do + workflow_discovery_page.visit_workflow + expect(page).to have_css(".topic-list") + expect(page).to have_no_css(".workflow-kanban") + + workflow_discovery_page.toggle_workflow_view + + expect(page).to have_current_path( + %r{/workflow\?.*workflow_view=kanban}, + url: true, + ) + expect(workflow_discovery_page).to have_kanban_board + expect(workflow_discovery_page).to have_kanban_column_for_step(1) + expect(workflow_discovery_page).to have_kanban_column_for_step(2) + expect(workflow_discovery_page).to have_kanban_column_for_step(3) + expect(workflow_discovery_page).to have_kanban_card_for_topic(topic_1.id) + expect(workflow_discovery_page).to have_kanban_card_for_topic(topic_2.id) + expect(page).to have_no_css(".topic-list") + expect(page).to have_css(".workflow-quick-filters__workflow-view.btn-primary") + + workflow_discovery_page.toggle_workflow_view + + expect(page).to have_current_path("/workflow", url: false) + expect(page).to have_css(".topic-list") + expect(page).to have_no_css(".workflow-kanban") + expect(page).to have_css(".workflow-quick-filters__workflow-view.btn-default") + end + + it "supports drag-drop transitions with legal and illegal column highlighting" do + workflow_discovery_page.visit_workflow + workflow_discovery_page.toggle_workflow_view + + expect(workflow_discovery_page).to have_kanban_card_for_topic_in_step(topic_1.id, 1) + expect(workflow_discovery_page).to have_kanban_card_for_topic_in_step(topic_2.id, 2) + + workflow_discovery_page.start_drag_on_kanban_card(topic_1.id) + + expect(workflow_discovery_page).to have_kanban_legal_drop_target_for_step(2) + expect(workflow_discovery_page).to have_kanban_illegal_drop_target_for_step(3) + + workflow_discovery_page.end_drag_on_kanban_card(topic_1.id) + workflow_discovery_page.drag_kanban_card_to_step(topic_1.id, 3) + + expect(workflow_discovery_page).to have_kanban_card_for_topic_in_step(topic_1.id, 1) + + workflow_discovery_page.drag_kanban_card_to_step(topic_1.id, 2) + + expect(workflow_discovery_page).to have_no_kanban_card_for_topic_in_step(topic_1.id, 1) + expect(workflow_discovery_page).to have_kanban_card_for_topic_in_step(topic_1.id, 2) + end + + it "does not show kanban toggle when the workflow list includes multiple workflows" do + other_workflow = Fabricate(:workflow, name: "Second Workflow") + other_step = + Fabricate( + :workflow_step, + workflow_id: other_workflow.id, + category_id: category_1.id, + position: 1, + ) + other_topic = Fabricate(:topic_with_op, category: category_1, user: user) + Fabricate( + :workflow_state, + topic_id: other_topic.id, + workflow_id: other_workflow.id, + workflow_step_id: other_step.id, + ) + + workflow_discovery_page.visit_workflow + + expect(workflow_discovery_page).to have_no_workflow_view_toggle + end end From 3b3c5bc5b4b2fa31c74b3e655351abefa332291d Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 20 Feb 2026 19:05:22 +0000 Subject: [PATCH 02/16] fixed vertical height limit --- assets/stylesheets/common/workflow_common.scss | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/assets/stylesheets/common/workflow_common.scss b/assets/stylesheets/common/workflow_common.scss index d948e44..345e2d8 100644 --- a/assets/stylesheets/common/workflow_common.scss +++ b/assets/stylesheets/common/workflow_common.scss @@ -187,11 +187,12 @@ body.workflow-kanban-view { } .workflow-kanban__column { + display: flex; + flex-direction: column; border: 1px solid var(--primary-low); border-radius: 8px; background: var(--secondary); - min-height: 180px; - overflow: hidden; + overflow: visible; } .workflow-kanban__column--source { @@ -245,8 +246,10 @@ body.workflow-kanban-view { .workflow-kanban__cards { display: grid; + grid-auto-rows: min-content; gap: 0.5rem; padding: 0.75rem; + flex: 1 0 auto; min-height: 120px; transition: background-color 120ms ease, opacity 120ms ease; } From ac5bcbb76a37b969bca586f46373db453748a69b Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 20 Feb 2026 19:11:21 +0000 Subject: [PATCH 03/16] fix styling of filter row input --- assets/stylesheets/common/workflow_common.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/stylesheets/common/workflow_common.scss b/assets/stylesheets/common/workflow_common.scss index 345e2d8..f5fb402 100644 --- a/assets/stylesheets/common/workflow_common.scss +++ b/assets/stylesheets/common/workflow_common.scss @@ -103,6 +103,7 @@ body.workflow-topic { .workflow-quick-filters__step-input { max-width: 90px; + margin-bottom: 0; } } From 86689b39469f581735652abe91afd3fb93ac5964 Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 20 Feb 2026 22:11:20 +0000 Subject: [PATCH 04/16] linting --- .../workflow-quick-filters.gjs | 11 ++++++++--- assets/stylesheets/common/workflow_common.scss | 8 +++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs b/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs index 955e313..5c8a2ae 100644 --- a/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs +++ b/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs @@ -370,7 +370,8 @@ export default class WorkflowQuickFiltersConnector extends Component { queryParams.overdue_days && (currentParams.get("workflow_step_position") || null) === queryParams.workflow_step_position && - (currentParams.get("workflow_view") || null) === queryParams.workflow_view; + (currentParams.get("workflow_view") || null) === + queryParams.workflow_view; if (unchanged) { return; @@ -544,7 +545,9 @@ export default class WorkflowQuickFiltersConnector extends Component { return; } - const topic = topicCollection.find((candidate) => Number(candidate.id) === topicId); + const topic = topicCollection.find( + (candidate) => Number(candidate.id) === topicId + ); if (!topic) { return; } @@ -722,7 +725,9 @@ export default class WorkflowQuickFiltersConnector extends Component { {{column.position}} - {{column.name}} + {{column.name}} {{column.topic_count_label}} diff --git a/assets/stylesheets/common/workflow_common.scss b/assets/stylesheets/common/workflow_common.scss index f5fb402..83180a4 100644 --- a/assets/stylesheets/common/workflow_common.scss +++ b/assets/stylesheets/common/workflow_common.scss @@ -135,8 +135,8 @@ body.workflow-topic { } } -.workflow-quick-filters.workflow-quick-filters--kanban-active ~ - .discovery-topics-list { +.workflow-quick-filters.workflow-quick-filters--kanban-active + ~ .discovery-topics-list { .topic-list, .load-more, .topic-list-bottom { @@ -271,9 +271,7 @@ body.workflow-kanban-view { box-shadow: 0 1px 2px color-mix(in srgb, var(--primary) 10%, transparent); cursor: grab; user-select: none; - transition: - transform 100ms ease, - box-shadow 100ms ease, + transition: transform 100ms ease, box-shadow 100ms ease, border-color 100ms ease; } From 9821a12d6aa33509cb1dd6269bae0f6667b568a5 Mon Sep 17 00:00:00 2001 From: Robert <35533304+merefield@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:26:20 +0000 Subject: [PATCH 05/16] Reduce code duplication Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../workflow-quick-filters.gjs | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs b/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs index 5c8a2ae..9819cfb 100644 --- a/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs +++ b/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs @@ -247,20 +247,22 @@ export default class WorkflowQuickFiltersConnector extends Component { return this.isColumnLegalDropTarget(position) ? "legal" : "illegal"; } + getTopicListSafe() { + try { + const topicCollection = + this.discovery.currentTopicList?.topics || + this.topicList?.topics || + this.topicListMetadata?.topics || + this.topicListMetadata?.topic_list?.topics || + []; + return Array.isArray(topicCollection) ? topicCollection : []; + } catch { + return []; + } + } + get kanbanColumns() { - const topics = (() => { - try { - const topicCollection = - this.discovery.currentTopicList?.topics || - this.topicList?.topics || - this.topicListMetadata?.topics || - this.topicListMetadata?.topic_list?.topics || - []; - return Array.isArray(topicCollection) ? topicCollection : []; - } catch { - return []; - } - })(); + const topics = this.getTopicListSafe(); return this.kanbanSteps.map((step) => { const position = Number(step.position); From 5ae18fc014867152d4e2369d93f45df260ee00d9 Mon Sep 17 00:00:00 2001 From: Robert <35533304+merefield@users.noreply.github.com> Date: Sat, 21 Feb 2026 06:32:34 +0000 Subject: [PATCH 06/16] Avoid repeated permissions checks on same category Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- plugin.rb | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/plugin.rb b/plugin.rb index 23c89ff..4b3fff3 100644 --- a/plugin.rb +++ b/plugin.rb @@ -345,12 +345,23 @@ module ::DiscourseWorkflow :workflow_can_act, include_condition: -> { object.workflow_name.present? } ) do - begin - scope.ensure_can_create_topic_on_category!(object.category_id) - true - rescue Discourse::InvalidAccess - false + # Cache permission checks per category on the scope to avoid repeated work + permissions_cache = + scope.instance_variable_get(:@workflow_can_act_category_permissions) || + scope.instance_variable_set(:@workflow_can_act_category_permissions, {}) + + category_id = object.category_id + + unless permissions_cache.key?(category_id) + begin + scope.ensure_can_create_topic_on_category!(category_id) + permissions_cache[category_id] = true + rescue Discourse::InvalidAccess + permissions_cache[category_id] = false + end end + + permissions_cache[category_id] end add_to_serializer( From 274cf095cb8dafed8cf7f714511e9436f6d1d0f2 Mon Sep 17 00:00:00 2001 From: merefield Date: Sat, 21 Feb 2026 09:28:02 +0000 Subject: [PATCH 07/16] limit kanban compatibility to workflows with only one option per direction step to step --- app/models/discourse_workflow/workflow.rb | 8 ++++++++ config/locales/client.en.yml | 2 +- spec/lib/workflow_spec.rb | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/models/discourse_workflow/workflow.rb b/app/models/discourse_workflow/workflow.rb index 8899a05..cec3d33 100644 --- a/app/models/discourse_workflow/workflow.rb +++ b/app/models/discourse_workflow/workflow.rb @@ -32,7 +32,9 @@ def kanban_compatible? step_ids = steps.map(&:id) step_lookup = step_ids.index_with(true) + steps_by_id = steps.index_by(&:id) edges = Hash.new { |hash, key| hash[key] = [] } + edge_lookup = {} steps.each do |step| step.workflow_step_options.each do |step_option| @@ -40,6 +42,12 @@ def kanban_compatible? next if target_step_id.blank? return false if !step_lookup[target_step_id] + from_position = step.position.to_i + to_position = steps_by_id[target_step_id].position.to_i + edge_key = [from_position, to_position] + return false if edge_lookup[edge_key] + + edge_lookup[edge_key] = true edges[step.id] << target_step_id end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 281aa47..afded73 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -128,7 +128,7 @@ en: label: "Kanban Compatibility" compatible: "Compatible" incompatible: "Not compatible" - help: "Kanban view requires one start step, unique positions, valid step targets, and reachability from the start step. Backward/cyclic paths are supported." + help: "Kanban view requires one start step, unique positions, valid step targets, reachability from the start step, and a unique directed transition mapping per step pair. Backward/cyclic paths are supported." workflow: short_title: "Workflow" editing: diff --git a/spec/lib/workflow_spec.rb b/spec/lib/workflow_spec.rb index 0c1e334..502390d 100644 --- a/spec/lib/workflow_spec.rb +++ b/spec/lib/workflow_spec.rb @@ -49,6 +49,29 @@ expect(workflow.kanban_compatible?).to eq(true) end + it "is not kanban compatible when a directed edge has multiple options" do + workflow = Fabricate(:workflow, name: "Kanban Duplicate Directed Edge") + step_1 = Fabricate(:workflow_step, workflow_id: workflow.id, position: 1) + step_2 = Fabricate(:workflow_step, workflow_id: workflow.id, position: 2) + option_1 = Fabricate(:workflow_option, slug: "next") + option_2 = Fabricate(:workflow_option, slug: "skip") + + Fabricate( + :workflow_step_option, + workflow_step_id: step_1.id, + workflow_option_id: option_1.id, + target_step_id: step_2.id, + ) + Fabricate( + :workflow_step_option, + workflow_step_id: step_1.id, + workflow_option_id: option_2.id, + target_step_id: step_2.id, + ) + + expect(workflow.kanban_compatible?).to eq(false) + end + it "is not kanban compatible when steps are disconnected from start" do workflow = Fabricate(:workflow, name: "Kanban Disconnected") Fabricate(:workflow_step, workflow_id: workflow.id, position: 1) From 0f86271459d84e2faa3b76473e2c7cb900e3aa11 Mon Sep 17 00:00:00 2001 From: merefield Date: Sat, 21 Feb 2026 09:44:15 +0000 Subject: [PATCH 08/16] allow left right arrow keys to move focussed workflow items --- .../workflow-quick-filters.gjs | 111 +++++++++++++----- .../page_objects/pages/workflow_discovery.rb | 28 +++++ spec/system/workflow_quick_filters_spec.rb | 13 ++ 3 files changed, 121 insertions(+), 31 deletions(-) diff --git a/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs b/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs index 9819cfb..b652269 100644 --- a/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs +++ b/assets/javascripts/discourse/connectors/discovery-list-container-top/workflow-quick-filters.gjs @@ -24,6 +24,14 @@ export default class WorkflowQuickFiltersConnector extends Component { @tracked transitionInFlightTopicId = null; @tracked recentlyDraggedTopicId = null; + willDestroy(...args) { + super.willDestroy(...args); + + if (typeof document !== "undefined") { + document.body.classList.remove("workflow-kanban-view"); + } + } + clearDragState(topicId = null) { const normalizedTopicId = Number(topicId || this.draggedTopicId); @@ -40,14 +48,6 @@ export default class WorkflowQuickFiltersConnector extends Component { this.draggedFromPosition = null; } - willDestroy(...args) { - super.willDestroy(...args); - - if (typeof document !== "undefined") { - document.body.classList.remove("workflow-kanban-view"); - } - } - sanitizeFilters(filters) { const sanitized = {}; @@ -220,6 +220,18 @@ export default class WorkflowQuickFiltersConnector extends Component { return this.kanbanTransitionMap.get(`${fromPosition}:${toPosition}`); } + adjacentStepPosition(fromPosition, direction) { + const positions = this.kanbanSteps.map((step) => Number(step.position)); + const index = positions.indexOf(Number(fromPosition)); + + if (index === -1) { + return null; + } + + const nextPosition = positions[index + direction]; + return Number.isInteger(nextPosition) ? nextPosition : null; + } + isColumnLegalDropTarget(position) { if (!this.draggedTopicId || !this.draggedFromPosition) { return false; @@ -512,13 +524,41 @@ export default class WorkflowQuickFiltersConnector extends Component { } @action - openKanbanTopicWithKeyboard(topic, event) { - if (event.key !== "Enter" && event.key !== " ") { + async openKanbanTopicWithKeyboard(topic, event) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this.openKanbanTopic(topic, event); + return; + } + + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { return; } event.preventDefault(); - this.openKanbanTopic(topic, event); + + if (!topic.workflow_can_act || this.transitionInFlightTopicId) { + return; + } + + const fromPosition = Number(topic.workflow_step_position); + const direction = event.key === "ArrowRight" ? 1 : -1; + const targetPosition = this.adjacentStepPosition(fromPosition, direction); + const topicId = Number(topic.id); + + if (!targetPosition) { + return; + } + + const didTransition = await this.transitionTopic( + topicId, + fromPosition, + targetPosition + ); + + if (didTransition) { + this.focusKanbanCard(topicId); + } } @action @@ -560,29 +600,14 @@ export default class WorkflowQuickFiltersConnector extends Component { }); } - @action - async columnDrop(column, event) { - event.preventDefault(); - - const topicId = - Number(event.dataTransfer.getData("text/plain")) || this.draggedTopicId; - const fromPosition = Number(this.draggedFromPosition); - const toPosition = Number(column.position); - - if (!topicId || !fromPosition || !toPosition) { - this.clearDragState(topicId); - return; - } - - if (toPosition === fromPosition) { - this.clearDragState(topicId); - return; + async transitionTopic(topicId, fromPosition, toPosition) { + if (!topicId || !fromPosition || !toPosition || toPosition === fromPosition) { + return false; } const optionSlug = this.optionSlugForTransition(fromPosition, toPosition); if (!optionSlug) { - this.clearDragState(topicId); - return; + return false; } this.transitionInFlightTopicId = topicId; @@ -594,14 +619,38 @@ export default class WorkflowQuickFiltersConnector extends Component { }); this.updateTopicTransitionState(topicId, toPosition); + return true; } catch (error) { popupAjaxError(error); + return false; } finally { this.transitionInFlightTopicId = null; - this.clearDragState(topicId); } } + focusKanbanCard(topicId) { + if (typeof document === "undefined") { + return; + } + + document + .querySelector(`.workflow-kanban__card[data-topic-id="${topicId}"]`) + ?.focus(); + } + + @action + async columnDrop(column, event) { + event.preventDefault(); + + const topicId = + Number(event.dataTransfer.getData("text/plain")) || this.draggedTopicId; + const fromPosition = Number(this.draggedFromPosition); + const toPosition = Number(column.position); + + await this.transitionTopic(topicId, fromPosition, toPosition); + this.clearDragState(topicId); + } + @action syncStepPositionFromUrl() { const params = this.currentSearchParams; diff --git a/spec/system/page_objects/pages/workflow_discovery.rb b/spec/system/page_objects/pages/workflow_discovery.rb index 9a44cf1..f8bb2ed 100644 --- a/spec/system/page_objects/pages/workflow_discovery.rb +++ b/spec/system/page_objects/pages/workflow_discovery.rb @@ -180,6 +180,34 @@ def end_drag_on_kanban_card(topic_id) self end + def move_kanban_card_with_key(topic_id, key) + page.execute_script( + <<~JS, + const id = arguments[0]; + const key = arguments[1]; + const card = document.querySelector( + `.workflow-kanban__card[data-topic-id="${id}"]` + ); + + if (!card) { + return; + } + + card.focus(); + card.dispatchEvent( + new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }) + ); + JS + topic_id, + key + ) + self + end + def current_url page.current_url end diff --git a/spec/system/workflow_quick_filters_spec.rb b/spec/system/workflow_quick_filters_spec.rb index 3cb83bc..28f9ddf 100644 --- a/spec/system/workflow_quick_filters_spec.rb +++ b/spec/system/workflow_quick_filters_spec.rb @@ -260,6 +260,19 @@ expect(workflow_discovery_page).to have_kanban_card_for_topic_in_step(topic_1.id, 2) end + it "supports keyboard arrow transitions for focused kanban cards when legal" do + workflow_discovery_page.visit_workflow + workflow_discovery_page.toggle_workflow_view + + expect(workflow_discovery_page).to have_kanban_card_for_topic_in_step(topic_1.id, 1) + + workflow_discovery_page.move_kanban_card_with_key(topic_1.id, "ArrowRight") + expect(workflow_discovery_page).to have_kanban_card_for_topic_in_step(topic_1.id, 2) + + workflow_discovery_page.move_kanban_card_with_key(topic_1.id, "ArrowLeft") + expect(workflow_discovery_page).to have_kanban_card_for_topic_in_step(topic_1.id, 2) + end + it "does not show kanban toggle when the workflow list includes multiple workflows" do other_workflow = Fabricate(:workflow, name: "Second Workflow") other_step = From b54804439faa0c9efd4cedd988ca0c9d16c15630 Mon Sep 17 00:00:00 2001 From: merefield Date: Sat, 21 Feb 2026 13:31:53 +0000 Subject: [PATCH 09/16] Add workflow-level Kanban tag display toggle --- .../admin/workflows_controller.rb | 1 + app/models/discourse_workflow/workflow.rb | 1 + .../discourse_workflow/workflow_serializer.rb | 1 + .../admin/components/workflow-editor.gjs | 20 +++++++++++++++++++ .../discourse/admin/models/workflow.js | 8 +++++++- .../workflow-quick-filters.gjs | 17 ++++++++++++++++ .../stylesheets/common/workflow_common.scss | 10 ++++++++++ config/locales/client.en.yml | 2 ++ ...20000_add_show_kanban_tags_to_workflows.rb | 7 +++++++ plugin.rb | 13 ++++++++++++ spec/lib/workflow_serializer_spec.rb | 2 +- .../admin/workflows_controller_spec.rb | 9 +++++++++ spec/requests/workflow_list_filters_spec.rb | 10 ++++++++++ .../page_objects/pages/workflow_discovery.rb | 12 +++++++++++ spec/system/workflow_quick_filters_spec.rb | 19 +++++++++++++++++- 15 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20260221120000_add_show_kanban_tags_to_workflows.rb diff --git a/app/controllers/discourse_workflow/admin/workflows_controller.rb b/app/controllers/discourse_workflow/admin/workflows_controller.rb index 5cf3bd4..de95411 100644 --- a/app/controllers/discourse_workflow/admin/workflows_controller.rb +++ b/app/controllers/discourse_workflow/admin/workflows_controller.rb @@ -61,6 +61,7 @@ def workflow_params :description, :enabled, :overdue_days, + :show_kanban_tags, ) permitted diff --git a/app/models/discourse_workflow/workflow.rb b/app/models/discourse_workflow/workflow.rb index cec3d33..7254bd7 100644 --- a/app/models/discourse_workflow/workflow.rb +++ b/app/models/discourse_workflow/workflow.rb @@ -155,6 +155,7 @@ def slug_generation_required? # enabled :boolean default(TRUE) # name :string # overdue_days :integer +# show_kanban_tags :boolean default(TRUE), not null # slug :string # created_at :datetime not null # updated_at :datetime not null diff --git a/app/serializers/discourse_workflow/workflow_serializer.rb b/app/serializers/discourse_workflow/workflow_serializer.rb index d65392c..08c05ca 100644 --- a/app/serializers/discourse_workflow/workflow_serializer.rb +++ b/app/serializers/discourse_workflow/workflow_serializer.rb @@ -9,6 +9,7 @@ class WorkflowSerializer < ApplicationSerializer :description, :enabled, :overdue_days, + :show_kanban_tags, :kanban_compatible, :workflow_steps_count, :starting_category_id, diff --git a/assets/javascripts/discourse/admin/components/workflow-editor.gjs b/assets/javascripts/discourse/admin/components/workflow-editor.gjs index 489156c..1f56227 100644 --- a/assets/javascripts/discourse/admin/components/workflow-editor.gjs +++ b/assets/javascripts/discourse/admin/components/workflow-editor.gjs @@ -81,6 +81,14 @@ export default class WorkflowEditor extends Component { await this.toggleField("enabled"); } + @action + toggleShowKanbanTags() { + this.editingModel.set( + "show_kanban_tags", + !this.editingModel.show_kanban_tags + ); + } + async toggleField(field, sortWorkflows) { this.args.workflow.set(field, !this.args.workflow[field]); this.editingModel.set(field, this.args.workflow[field]); @@ -220,6 +228,18 @@ export default class WorkflowEditor extends Component { />

{{i18n "admin.discourse_workflow.workflows.overdue_days_help"}}

+
+ +

{{i18n + "admin.discourse_workflow.workflows.show_kanban_tags_help" + }}

+
{{#if @workflow.id}}