Skip to content
Merged
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
43 changes: 35 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ If you are new to the terminology, see:
- Transition actions presented as buttons per step option
- Permission model aligned to native Discourse category permissions
- Workflow discovery list (`/workflow`) with quick filters
- Kanban view toggle for compatible single-workflow lists
- Drag/drop transitions in Kanban with legal/illegal drop-zone highlighting
- Keyboard Kanban transitions on focused cards (`ArrowLeft` / `ArrowRight`) when legal
- Workflow-level Kanban tags toggle (`show_kanban_tags`, default `true`)
- Overdue behavior with hierarchy:
- global default (`workflow_overdue_days_default`)
- workflow override
Expand All @@ -37,7 +41,8 @@ If you are new to the terminology, see:
2. Go to `Admin -> Plugins -> Discourse Workflow`, create a Workflow, then save it.
3. Add Workflow Steps (Categories in journey order), then add Step Options (actions/transitions).
4. Create a Topic in the first step Category and transition it through actions from the topic banner.
5. Use `/workflow` to view queue state, apply quick filters, and visualize progress.
5. Use `/workflow` to view queue state, apply quick filters, toggle `List`/`Kanban`, and visualize progress.
6. In Kanban, click a card to open the topic, drag cards to legal target steps, or use keyboard arrows on focused cards.

## Setup

Expand All @@ -61,6 +66,10 @@ You can change the label of an Option in `Admin -> Customize -> Text`.

A good range of Options is seeded by default, but you can customize the text as needed.

Workflow-level Kanban controls:

- `show_kanban_tags`: controls whether tags render on Kanban cards below the title. Default is enabled.

### Overdue setup hierarchy

- Global default: `workflow_overdue_days_default`
Expand All @@ -69,6 +78,21 @@ A good range of Options is seeded by default, but you can customize the text as

Resolution order is `step -> workflow -> global`. A value of `0` means overdue behavior is disabled at that level.

### Kanban compatibility and behavior

Kanban view is shown when the current `/workflow` list is scoped to a single compatible workflow.

Compatibility requires:

- at least one step
- a single start step at position `1`
- unique step positions
- valid target step IDs
- all steps reachable from the start step
- unique directed transition mapping per step pair (`from_step -> to_step`)

For each directed edge, Kanban drag/keyboard transitions are option-agnostic and deterministic.

### AI actions

You can leverage AI to handle a step. You need `workflow_openai_api_key`, AI enabled on the step, and a prompt including both `{{options}}` and `{{topic}}`. You can also tune behavior with `workflow_ai_model` and `workflow_ai_prompt_system`.
Expand Down Expand Up @@ -150,9 +174,11 @@ Permissioning principle:

| Area | Capability | Status | Notes |
| ----------- | ------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- |
| Definition | Workflow definitions (steps/options mapped to categories) | Implemented | Core admin CRUD is available |
| Definition | Workflow definitions (steps/options mapped to categories) | Implemented | Core admin CRUD plus workflow-level display controls (for example `show_kanban_tags`) |
| Runtime | Topic transitions with audit posts | Implemented | Transition actions are logged in-topic |
| Discovery | Workflow list with quick filters and step filtering | Implemented | `/workflow` includes semantic quick filters |
| Discovery | Workflow list with quick filters, list/kanban toggle, and step filtering | Implemented | `/workflow` supports SPA quick filters plus list/kanban switching |
| Discovery | Real-time workflow state-change notifier with refresh CTA | Planned | Wire MessageBus updates into `/workflow` with a core-style “press to refresh” flow |
| Kanban | Card transitions (drag/drop and keyboard arrows) | Implemented | Legal transitions only; deterministic directed edge mapping |
| SLA | Overdue thresholds (step -> workflow -> global, `0` disables) | Implemented | Includes overdue list indicator |
| Permissions | Native Discourse category permissions for acting/commenting | Implemented | Transition authority still aligns with category create access |
| Permissions | Step/action-level transition permissions | Partial | Deliberately lower priority to preserve simple, core-aligned permissioning |
Expand All @@ -167,8 +193,9 @@ Permissioning principle:

### Priority Roadmap

1. Add transition preconditions and clearer per-action validation feedback.
2. Add escalation automation (reminders/alerts) on top of existing overdue thresholds.
3. Add first-class reporting and assignment integration for operational workflows.
4. Add definition lifecycle tooling (import/export/versioning) for safe environment promotion.
5. Keep advanced step/action permission granularity as a lower-priority enhancement to avoid unnecessary complexity versus native Discourse permissioning.
1. Add MessageBus-driven workflow state-change notifications with a core-style refresh CTA in `/workflow`.
2. Add transition preconditions and clearer per-action validation feedback.
3. Add escalation automation (reminders/alerts) on top of existing overdue thresholds.
4. Add first-class reporting and assignment integration for operational workflows.
5. Add definition lifecycle tooling (import/export/versioning) for safe environment promotion.
6. Keep advanced step/action permission granularity as a lower-priority enhancement to avoid unnecessary complexity versus native Discourse permissioning.
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def workflow_params
:description,
:enabled,
:overdue_days,
:show_kanban_tags,
)

permitted
Expand Down
16 changes: 10 additions & 6 deletions app/controllers/discourse_workflow/workflow_action_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ def act
user_id = current_user.id
option = params[:option]
cooldown_key = nil
successful_transition = false

if user_id.present? && option.present? && topic.present?
cooldown_key = "discourse-workflow-transition-#{user_id}-#{topic.id}"
cooldown_acquired =
Discourse.redis.set(cooldown_key, "1", ex: 5, nx: true)
cooldown_acquired = Discourse.redis.set(cooldown_key, "1", ex: 5, nx: true)

if !cooldown_acquired
render json: failed_json
Expand All @@ -23,14 +24,17 @@ def act
successful_transition = Transition.new.transition(user_id, topic, option)
end

if !successful_transition && cooldown_key.present?
Discourse.redis.del(cooldown_key)
end
Discourse.redis.del(cooldown_key) if !successful_transition && cooldown_key.present?

if successful_transition
render json: success_json
else
render json: failed_json
render json:
failed_json.merge(
message:
I18n.t("discourse_workflow.errors.transition_failed_stale_state_refreshing"),
),
status: :conflict
end
end
end
Expand Down
64 changes: 56 additions & 8 deletions app/models/discourse_workflow/workflow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,53 @@ 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)
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|
target_step_id = step_option.target_step_id
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

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 = []

Expand Down Expand Up @@ -103,12 +150,13 @@ def slug_generation_required?
#
# Table name: workflows
#
# id :bigint not null, primary key
# description :text
# enabled :boolean default(TRUE)
# name :string
# overdue_days :integer
# slug :string
# created_at :datetime not null
# updated_at :datetime not null
# id :bigint not null, primary key
# description :text
# 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
#
3 changes: 2 additions & 1 deletion app/models/discourse_workflow/workflow_step.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

module ::DiscourseWorkflow
class WorkflowStep < ActiveRecord::Base
self.table_name = 'workflow_steps'
self.table_name = "workflow_steps"
belongs_to :workflow
belongs_to :category
has_many :workflow_step_options
has_many :workflow_states

Expand Down
6 changes: 6 additions & 0 deletions app/serializers/discourse_workflow/workflow_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ class WorkflowSerializer < ApplicationSerializer
:description,
:enabled,
:overdue_days,
:show_kanban_tags,
:kanban_compatible,
:workflow_steps_count,
:starting_category_id,
:final_category_id,
Expand All @@ -34,5 +36,9 @@ def final_category_id
def validation_warnings
object.validation_warnings
end

def kanban_compatible
object.kanban_compatible?
end
end
end
45 changes: 45 additions & 0 deletions assets/javascripts/discourse/admin/components/workflow-editor.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -220,6 +228,43 @@ export default class WorkflowEditor extends Component {
/>
<p>{{i18n "admin.discourse_workflow.workflows.overdue_days_help"}}</p>
</div>
<div class="control-group">
<DToggleSwitch
class="workflow-editor__show-kanban-tags"
@state={{this.editingModel.show_kanban_tags}}
@label="admin.discourse_workflow.workflows.show_kanban_tags"
@disabled={{this.editingModel.system}}
{{on "click" this.toggleShowKanbanTags}}
/>
<p>{{i18n
"admin.discourse_workflow.workflows.show_kanban_tags_help"
}}</p>
</div>
{{#if @workflow.id}}
<div class="control-group">
<label>{{i18n
"admin.discourse_workflow.workflows.kanban_compatibility.label"
}}</label>
<p>
{{#if @workflow.kanban_compatible}}
<span class="workflow-editor__kanban-compatible">
{{i18n
"admin.discourse_workflow.workflows.kanban_compatibility.compatible"
}}
</span>
{{else}}
<span class="workflow-editor__kanban-incompatible">
{{i18n
"admin.discourse_workflow.workflows.kanban_compatibility.incompatible"
}}
</span>
{{/if}}
</p>
<p>{{i18n
"admin.discourse_workflow.workflows.kanban_compatibility.help"
}}</p>
</div>
{{/if}}
{{#if this.showSteps}}
<div class="control-group">
<WorkflowStepListEditor
Expand Down
8 changes: 7 additions & 1 deletion assets/javascripts/discourse/admin/models/workflow.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import RestModel from "discourse/models/rest";

const CREATE_ATTRIBUTES = ["name", "description", "enabled", "overdue_days"];
const CREATE_ATTRIBUTES = [
"name",
"description",
"enabled",
"overdue_days",
"show_kanban_tags",
];

export default class Workflow extends RestModel {
updateProperties() {
Expand Down
7 changes: 4 additions & 3 deletions assets/javascripts/discourse/components/workflow-buttons.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { extractError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";

export default class WorkflowButtonsComponent extends Component {
Expand Down Expand Up @@ -55,9 +55,10 @@ export default class WorkflowButtonsComponent extends Component {
.then(() => {
this.router.transitionTo("/c/" + this.args.category_id);
})
.catch((err) => {
.catch(async (err) => {
this.transitioningOption = null;
popupAjaxError(err);
await this.dialog.alert(extractError(err));
this.router.refresh();
});
},
});
Expand Down
Loading