diff --git a/README.md b/README.md
index 49fd3cc..2a4edfe 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
@@ -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`
@@ -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`.
@@ -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 |
@@ -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.
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/controllers/discourse_workflow/workflow_action_controller.rb b/app/controllers/discourse_workflow/workflow_action_controller.rb
index 04bd63a..5eb19c2 100644
--- a/app/controllers/discourse_workflow/workflow_action_controller.rb
+++ b/app/controllers/discourse_workflow/workflow_action_controller.rb
@@ -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
@@ -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
diff --git a/app/models/discourse_workflow/workflow.rb b/app/models/discourse_workflow/workflow.rb
index a5bd34d..ecaf473 100644
--- a/app/models/discourse_workflow/workflow.rb
+++ b/app/models/discourse_workflow/workflow.rb
@@ -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 = []
@@ -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
#
diff --git a/app/models/discourse_workflow/workflow_step.rb b/app/models/discourse_workflow/workflow_step.rb
index 341d23f..f396947 100644
--- a/app/models/discourse_workflow/workflow_step.rb
+++ b/app/models/discourse_workflow/workflow_step.rb
@@ -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
diff --git a/app/serializers/discourse_workflow/workflow_serializer.rb b/app/serializers/discourse_workflow/workflow_serializer.rb
index 07ace56..08c05ca 100644
--- a/app/serializers/discourse_workflow/workflow_serializer.rb
+++ b/app/serializers/discourse_workflow/workflow_serializer.rb
@@ -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,
@@ -34,5 +36,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..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,43 @@ 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}}
+
+
+
+ {{#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}}
{
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();
});
},
});
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 8c7898c..a7a3830 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
@@ -1,19 +1,55 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
+import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
-import { action } from "@ember/object";
+import { action, get, set } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
+import categoryColorVariable from "discourse/helpers/category-color-variable";
+import discourseTags from "discourse/helpers/discourse-tags";
+import { ajax } from "discourse/lib/ajax";
+import { extractError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
const STORAGE_KEY = "discourse_workflow_quick_filters";
export default class WorkflowQuickFiltersConnector extends Component {
+ @service dialog;
+ @service discovery;
@service router;
@tracked stepPosition = "";
+ @tracked workflowView = null;
+ @tracked draggedTopicId = null;
+ @tracked draggedFromPosition = null;
+ @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);
+
+ if (normalizedTopicId) {
+ this.recentlyDraggedTopicId = normalizedTopicId;
+ setTimeout(() => {
+ if (Number(this.recentlyDraggedTopicId) === normalizedTopicId) {
+ this.recentlyDraggedTopicId = null;
+ }
+ }, 150);
+ }
+
+ this.draggedTopicId = null;
+ this.draggedFromPosition = null;
+ }
sanitizeFilters(filters) {
const sanitized = {};
@@ -34,16 +70,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 +125,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 +149,204 @@ 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 showKanbanTags() {
+ return this.topicListMetadata?.workflow_kanban_show_tags !== false;
+ }
+
+ 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}`);
+ }
+
+ 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;
+ }
+
+ 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";
+ }
+
+ 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 = this.getTopicListSafe();
+
+ 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"),
+ tags: get(topic, "tags") || [],
+ 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(" "),
+ column_style: step.category_color
+ ? categoryColorVariable(step.category_color)
+ : null,
+ 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 +384,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 +394,9 @@ 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 +480,224 @@ 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
+ 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();
+
+ 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
+ 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);
+ });
+ }
+
+ async transitionTopic(topicId, fromPosition, toPosition) {
+ if (
+ !topicId ||
+ !fromPosition ||
+ !toPosition ||
+ toPosition === fromPosition
+ ) {
+ return false;
+ }
+
+ const optionSlug = this.optionSlugForTransition(fromPosition, toPosition);
+ if (!optionSlug) {
+ return false;
+ }
+
+ this.transitionInFlightTopicId = topicId;
+
+ try {
+ await ajax(`/discourse-workflow/act/${topicId}`, {
+ type: "POST",
+ data: { option: optionSlug },
+ });
+
+ this.updateTopicTransitionState(topicId, toPosition);
+ return true;
+ } catch (error) {
+ await this.dialog.alert(extractError(error));
+ this.router.refresh();
+ return false;
+ } finally {
+ this.transitionInFlightTopicId = null;
+ }
+ }
+
+ 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() {
- 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);
}
{{#if this.isWorkflowRoute}}
+ {{#if this.showKanbanToggle}}
+
+ {{/if}}
+
+ {{#if this.showKanbanToggle}}
+
+
+
+
+ {{#each this.kanbanColumns key="position" as |column|}}
+
+
+
+ {{column.position}}
+
+ {{column.name}}
+
+ {{column.topic_count_label}}
+
+
+
+
+ {{#if column.topics.length}}
+ {{#each column.topics key="id" as |topic|}}
+
+
+ {{topic.title}}
+
+ {{#if this.showKanbanTags}}
+ {{#if topic.tags.length}}
+ {{discourseTags
+ null
+ tags=topic.tags
+ style="box"
+ tagName="span"
+ className="workflow-kanban__tags"
+ }}
+ {{/if}}
+ {{/if}}
+ {{#if topic.workflow_overdue}}
+
+ {{i18n "discourse_workflow.overdue_indicator"}}
+
+ {{/if}}
+
+ {{/each}}
+ {{else}}
+
+ {{i18n "discourse_workflow.kanban.empty_step"}}
+
+ {{/if}}
+
+
+ {{/each}}
+
+
+ {{/if}}
{{/if}}
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..c767d77 100644
--- a/assets/stylesheets/common/workflow_common.scss
+++ b/assets/stylesheets/common/workflow_common.scss
@@ -103,6 +103,230 @@ body.workflow-topic {
.workflow-quick-filters__step-input {
max-width: 90px;
+ margin-bottom: 0;
+ }
+}
+
+.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 {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--category-badge-color, var(--primary-low));
+ border-radius: 8px;
+ background: var(--secondary);
+ overflow: visible;
+ }
+
+ .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;
+ margin: 1px 1px 0;
+ padding: 0.5rem 0.75rem;
+ border-radius: 8px 8px 0 0;
+ 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;
+ 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;
+ }
+
+ .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__tags.discourse-tags {
+ margin-top: 0.15rem;
+ gap: 0.3rem;
+ }
+
+ .workflow-kanban__tags .discourse-tag.box {
+ font-size: var(--font-down-2);
+ line-height: 1.4;
+ }
+
+ .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);
}
}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 040c21b..0942106 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,13 @@ en:
description: "Description"
overdue_days: "Overdue Days Override"
overdue_days_help: "Blank uses the global default. 0 disables overdue behavior for this workflow."
+ show_kanban_tags: "Show Tags In Kanban"
+ show_kanban_tags_help: "When enabled, Kanban cards display topic tags below the title."
+ kanban_compatibility:
+ label: "Kanban Compatibility"
+ compatible: "Compatible"
+ incompatible: "Not compatible"
+ 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/config/locales/server.en.yml b/config/locales/server.en.yml
index b716d02..b22acb6 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -6,6 +6,7 @@ en:
topic_transition_action_description: 'Moved from "%{starting_step_name}" to "%{ending_step_name}" with action: "%{step_option_name}" by @%{username}'
errors:
no_midway_error: "You cannot create a Topic midway through a workflow and this Category is part of a workflow."
+ transition_failed_stale_state_refreshing: "Transition Failed: probably due to stale UI state - please try again after refresh - refreshing!"
options:
start: "Start"
ready: "Ready"
diff --git a/db/migrate/20260221120000_add_show_kanban_tags_to_workflows.rb b/db/migrate/20260221120000_add_show_kanban_tags_to_workflows.rb
new file mode 100644
index 0000000..ca2ae5c
--- /dev/null
+++ b/db/migrate/20260221120000_add_show_kanban_tags_to_workflows.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddShowKanbanTagsToWorkflows < ActiveRecord::Migration[7.2]
+ def change
+ add_column :workflows, :show_kanban_tags, :boolean, default: true, null: false
+ end
+end
diff --git a/plugin.rb b/plugin.rb
index e0cf70d..7dd0ea0 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# name: discourse-workflow
# about: A topic-based workflow engine for Discourse
-# version: 0.2.0
+# version: 0.3.0
# authors: Robert Barrow
# contact_emails: robert@pavilion.tech
# url: https://github.com/merefield/discourse-workflow
@@ -28,29 +28,24 @@ module ::DiscourseWorkflow
ListController.prepend(DiscourseWorkflow::ListControllerExtension)
TopicQuery.prepend(DiscourseWorkflow::TopicQueryExtension)
Topic.prepend(DiscourseWorkflow::TopicExtension)
- Notification.singleton_class.prepend(
- DiscourseWorkflow::NotificationExtension
- )
+ Notification.singleton_class.prepend(DiscourseWorkflow::NotificationExtension)
end
- register_topic_preloader_associations(
- { workflow_state: %i[workflow workflow_step] }
- ) { SiteSetting.workflow_enabled }
+ register_topic_preloader_associations({ workflow_state: %i[workflow workflow_step] }) do
+ SiteSetting.workflow_enabled
+ end
Discourse.top_menu_items.push(:workflow)
Discourse.anonymous_top_menu_items.push(:workflow)
Discourse.filters.push(:workflow)
Discourse.anonymous_filters.push(:workflow)
- SeedFu.fixture_paths << Rails
- .root
- .join("plugins", "discourse-workflow", "db", "fixtures")
- .to_s
+ SeedFu.fixture_paths << Rails.root.join("plugins", "discourse-workflow", "db", "fixtures").to_s
add_admin_route(
"admin.discourse_workflow.title",
"discourse-workflow",
- { use_new_show_route: true }
+ { use_new_show_route: true },
)
add_to_class(:category, :workflow_enabled) do
@@ -58,11 +53,7 @@ module ::DiscourseWorkflow
end
add_to_class(:category, :workflow_slug) do
- Workflow
- .joins(:workflow_steps)
- .where(workflow_steps: { category_id: self.id })
- .first
- &.slug
+ Workflow.joins(:workflow_steps).where(workflow_steps: { category_id: self.id }).first&.slug
end
# prevent non-staff from changing category on a workflow topic
@@ -71,15 +62,12 @@ module ::DiscourseWorkflow
tc.record_change("category_id", tc.topic.category_id, category_id)
tc.topic.category_id = category_id
else
- if ::DiscourseWorkflow::WorkflowState.find_by(
- topic_id: tc.topic.id
- ).present?
+ if ::DiscourseWorkflow::WorkflowState.find_by(topic_id: tc.topic.id).present?
# TODO get this to work and add a translation
tc.topic.errors.add(
:base,
:workflow,
- message:
- "you can't change category on a workflow topic unless you are staff"
+ message: "you can't change category on a workflow topic unless you are staff",
)
next
else
@@ -93,17 +81,11 @@ module ::DiscourseWorkflow
add_to_class(:topic, :workflow_name) { workflow_state&.workflow&.name }
- add_to_class(:topic, :workflow_step_slug) do
- workflow_state&.workflow_step&.slug
- end
+ add_to_class(:topic, :workflow_step_slug) { workflow_state&.workflow_step&.slug }
- add_to_class(:topic, :workflow_step_name) do
- workflow_state&.workflow_step&.name
- end
+ add_to_class(:topic, :workflow_step_name) { workflow_state&.workflow_step&.name }
- add_to_class(:topic, :workflow_step_position) do
- workflow_state&.workflow_step&.position
- end
+ add_to_class(:topic, :workflow_step_position) { workflow_state&.workflow_step&.position }
add_to_class(:topic, :workflow_step_options) do
step = workflow_state&.workflow_step
@@ -120,16 +102,12 @@ module ::DiscourseWorkflow
step = workflow_state&.workflow_step
return [] unless step
- step_options =
- step
- .workflow_step_options
- .includes(:workflow_option)
- .order(:position)
+ step_options = step.workflow_step_options.includes(:workflow_option).order(:position)
target_steps =
- DiscourseWorkflow::WorkflowStep
- .where(id: step_options.map(&:target_step_id).compact.uniq)
- .index_by(&:id)
+ DiscourseWorkflow::WorkflowStep.where(
+ id: step_options.map(&:target_step_id).compact.uniq,
+ ).index_by(&:id)
step_options.map do |workflow_step_option|
option = workflow_step_option.workflow_option
@@ -139,14 +117,12 @@ module ::DiscourseWorkflow
slug: option&.slug,
option_name: option&.name,
target_step_name: target_step&.name,
- target_step_position: target_step&.position
+ target_step_position: target_step&.position,
}
end
end
- add_to_class(:topic, :workflow_step_entered_at) do
- workflow_state&.updated_at
- end
+ add_to_class(:topic, :workflow_step_entered_at) { workflow_state&.updated_at }
add_to_class(:topic, :workflow_overdue_days_threshold) do
state = workflow_state
@@ -174,43 +150,115 @@ 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: [
+ { category: :parent_category },
+ { 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_show_tags) do
+ workflow = workflow_kanban_workflow
+ workflow.present? && workflow.show_kanban_tags != false
+ 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|
+ category = step.category
+ {
+ id: step.id,
+ position: step.position,
+ name: step.name,
+ category_color: category&.color || category&.parent_category&.color,
+ }
+ 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,
- include_condition: -> { object.topic.workflow_slug.present? }
+ include_condition: -> { object.topic.workflow_slug.present? },
) { object.topic.workflow_slug }
add_to_serializer(
:topic_view,
:workflow_name,
- include_condition: -> { object.topic.workflow_name.present? }
+ include_condition: -> { object.topic.workflow_name.present? },
) { object.topic.workflow_name }
add_to_serializer(
:topic_view,
:workflow_step_slug,
- include_condition: -> { object.topic.workflow_step_slug.present? }
+ include_condition: -> { object.topic.workflow_step_slug.present? },
) { object.topic.workflow_step_slug }
add_to_serializer(
:topic_view,
:workflow_step_name,
- include_condition: -> { object.topic.workflow_step_name.present? }
+ include_condition: -> { object.topic.workflow_step_name.present? },
) { object.topic.workflow_step_name }
add_to_serializer(
:topic_view,
:workflow_step_position,
- include_condition: -> { object.topic.workflow_step_position.present? }
+ include_condition: -> { object.topic.workflow_step_position.present? },
) { object.topic.workflow_step_position }
add_to_serializer(
:topic_view,
:workflow_step_options,
- include_condition: -> {
+ include_condition: -> do
@workflow_step_options ||= object.topic.workflow_step_options
@workflow_step_options.present?
- }
+ end,
) do
@workflow_step_options ||= object.topic.workflow_step_options
@workflow_step_options
@@ -219,18 +267,16 @@ module ::DiscourseWorkflow
add_to_serializer(
:topic_view,
:workflow_step_actions,
- include_condition: -> {
+ include_condition: -> do
@workflow_step_actions ||= object.topic.workflow_step_actions
@workflow_step_actions.present?
- }
- ) do
- @workflow_step_actions ||= object.topic.workflow_step_actions
- end
+ end,
+ ) { @workflow_step_actions ||= object.topic.workflow_step_actions }
add_to_serializer(
:topic_view,
:workflow_can_act,
- include_condition: -> { object.topic.workflow_name.present? }
+ include_condition: -> { object.topic.workflow_name.present? },
) do
begin
scope.ensure_can_create_topic_on_category!(object.topic.category_id)
@@ -243,50 +289,104 @@ module ::DiscourseWorkflow
add_to_serializer(
:topic_view,
:workflow_step_entered_at,
- include_condition: -> { object.topic.workflow_step_entered_at.present? }
+ include_condition: -> { object.topic.workflow_step_entered_at.present? },
) { object.topic.workflow_step_entered_at }
add_to_serializer(
:topic_list_item,
:workflow_name,
- include_condition: -> { object.workflow_name.present? }
+ include_condition: -> { object.workflow_name.present? },
) { object.workflow_name }
add_to_serializer(
:topic_list_item,
:workflow_step_position,
- include_condition: -> { object.workflow_step_position.present? }
+ include_condition: -> { object.workflow_step_position.present? },
) { object.workflow_step_position.to_i }
add_to_serializer(
:topic_list_item,
:workflow_step_name,
- include_condition: -> { object.workflow_step_name.present? }
+ include_condition: -> { object.workflow_step_name.present? },
) { object.workflow_step_name }
add_to_serializer(
:topic_list_item,
:workflow_overdue,
- include_condition: -> { object.workflow_name.present? }
+ 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
+ # 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(
+ :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_show_tags,
+ include_condition: -> { object.topics.any? { |topic| topic.workflow_state.present? } },
+ ) { object.workflow_kanban_show_tags }
+
+ 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
if SiteSetting.workflow_enabled
workflow_step =
- DiscourseWorkflow::WorkflowStep
- .joins(:workflow)
- .find_by(
- category_id: topic.category_id,
- position: 1,
- workflows: { enabled: true }
- )
+ DiscourseWorkflow::WorkflowStep.joins(:workflow).find_by(
+ category_id: topic.category_id,
+ position: 1,
+ workflows: {
+ enabled: true,
+ },
+ )
if workflow_step
DiscourseWorkflow::WorkflowState.create!(
topic_id: topic.id,
workflow_id: workflow_step.workflow_id,
- workflow_step_id: workflow_step.id
+ workflow_step_id: workflow_step.id,
)
end
end
diff --git a/spec/lib/workflow_serializer_spec.rb b/spec/lib/workflow_serializer_spec.rb
index a676571..7e4c048 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.as_json }.not_to raise_error
+ expect(serializer.kanban_compatible).to eq(false)
+ expect(serializer.show_kanban_tags).to eq(true)
+ 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..502390d 100644
--- a/spec/lib/workflow_spec.rb
+++ b/spec/lib/workflow_spec.rb
@@ -11,4 +11,72 @@
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 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)
+ 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..d7fedae
--- /dev/null
+++ b/spec/requests/admin/workflows_controller_spec.rb
@@ -0,0 +1,61 @@
+# 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)
+ expect(payload["show_kanban_tags"]).to eq(true)
+ 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
+
+ it "updates show_kanban_tags on a workflow" do
+ put "/admin/plugins/discourse-workflow/workflows/#{workflow.id}.json",
+ params: { workflow: { show_kanban_tags: false } }
+
+ expect(response.status).to eq(200)
+ expect(workflow.reload.show_kanban_tags).to eq(false)
+ end
+end
diff --git a/spec/requests/workflow_action_controller_spec.rb b/spec/requests/workflow_action_controller_spec.rb
index f88dea9..f4f1290 100644
--- a/spec/requests/workflow_action_controller_spec.rb
+++ b/spec/requests/workflow_action_controller_spec.rb
@@ -4,7 +4,7 @@
RSpec.describe DiscourseWorkflow::WorkflowActionController, type: :request do
fab!(:user) { Fabricate(:user, trust_level: TrustLevel[1], refresh_auto_groups: true) }
- fab!(:workflow) { Fabricate(:workflow, name: "Cooldown Workflow") }
+ fab!(:workflow) { Fabricate(:workflow, name: "Transition Workflow") }
fab!(:category_1, :category)
fab!(:category_2, :category)
fab!(:step_1) do
@@ -26,6 +26,7 @@
)
end
fab!(:next_option) { Fabricate(:workflow_option, slug: "next", name: "Next") }
+ fab!(:back_option) { Fabricate(:workflow_option, slug: "back", name: "Back") }
fab!(:step_1_option) do
Fabricate(
:workflow_step_option,
@@ -39,7 +40,7 @@
Fabricate(
:workflow_step_option,
workflow_step_id: step_2.id,
- workflow_option_id: next_option.id,
+ workflow_option_id: back_option.id,
target_step_id: step_1.id,
position: 1,
)
@@ -56,7 +57,6 @@
before do
SiteSetting.workflow_enabled = true
- DiscourseWorkflow::Transition.any_instance.stubs(:transition).returns(true)
category_1.set_permissions(everyone: :full, staff: :full)
category_2.set_permissions(everyone: :full, staff: :full)
category_1.save!
@@ -65,15 +65,27 @@
Discourse.redis.del("discourse-workflow-transition-#{user.id}-#{topic.id}")
end
- it "blocks immediate repeated transitions for the same user and topic" do
+ it "returns 200 for a valid transition" do
post "/discourse-workflow/act/#{topic.id}.json", params: { option: "next" }
+
expect(response.status).to eq(200)
expect(response.parsed_body["success"]).to eq("OK")
+ expect(workflow_state.reload.workflow_step_id).to eq(step_2.id)
+ end
+
+ it "returns conflict when trying a stale transition option" do
+ post "/discourse-workflow/act/#{topic.id}.json", params: { option: "next" }
+ expect(response.status).to eq(200)
- expect do
- post "/discourse-workflow/act/#{topic.id}.json", params: { option: "next" }
- end.not_to change { DiscourseWorkflow::WorkflowAuditLog.count }
+ # clear cooldown so we can assert stale-state behavior instead of cooldown
+ Discourse.redis.del("discourse-workflow-transition-#{user.id}-#{topic.id}")
+ post "/discourse-workflow/act/#{topic.id}.json", params: { option: "next" }
+
+ expect(response.status).to eq(409)
expect(response.parsed_body["failed"]).to eq("FAILED")
+ expect(response.parsed_body["message"]).to eq(
+ I18n.t("discourse_workflow.errors.transition_failed_stale_state_refreshing"),
+ )
end
end
diff --git a/spec/requests/workflow_list_filters_spec.rb b/spec/requests/workflow_list_filters_spec.rb
index f573e63..081633f 100644
--- a/spec/requests/workflow_list_filters_spec.rb
+++ b/spec/requests/workflow_list_filters_spec.rb
@@ -8,19 +8,18 @@
fab!(:category_a, :category)
fab!(:category_b, :category)
fab!(:step_1) do
- Fabricate(
- :workflow_step,
- workflow_id: workflow.id,
- category_id: category_a.id,
- position: 1,
- )
+ Fabricate(:workflow_step, workflow_id: workflow.id, category_id: category_a.id, position: 1)
end
fab!(:step_2) do
+ Fabricate(:workflow_step, workflow_id: workflow.id, category_id: category_b.id, position: 2)
+ end
+ fab!(:next_option) { Fabricate(:workflow_option, slug: "next", name: "Next") }
+ fab!(:step_transition) do
Fabricate(
- :workflow_step,
- workflow_id: workflow.id,
- category_id: category_b.id,
- position: 2,
+ :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) }
@@ -78,4 +77,63 @@
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"]
+ steps = topic_list["workflow_kanban_steps"]
+ step_positions = topic_list["workflow_kanban_steps"].map { |step| step["position"] }
+ steps_by_position = steps.index_by { |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(topic_list["workflow_kanban_show_tags"]).to eq(true)
+ expect(step_positions).to eq([1, 2])
+ expect(steps_by_position[1]["category_color"]).to eq(category_a.color)
+ expect(steps_by_position[2]["category_color"]).to eq(category_b.color)
+ 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 "serializes workflow_kanban_show_tags false when disabled on the workflow" do
+ workflow.update!(show_kanban_tags: false)
+
+ get "/workflow.json"
+
+ topic_list = response.parsed_body["topic_list"]
+ expect(topic_list["workflow_kanban_show_tags"]).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..1a99d4e 100644
--- a/spec/system/page_objects/pages/workflow_discovery.rb
+++ b/spec/system/page_objects/pages/workflow_discovery.rb
@@ -28,6 +28,195 @@ 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 kanban_column_border_color(position)
+ page.evaluate_script(<<~JS)
+ (() => {
+ const column = document.querySelector(
+ '.workflow-kanban__column[data-workflow-step-position="#{position}"]'
+ );
+ if (!column) {
+ return null;
+ }
+
+ return window.getComputedStyle(column).borderTopColor;
+ })();
+ JS
+ 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_tag_for_topic?(topic_id, tag_name)
+ has_css?(
+ ".workflow-kanban__card[data-topic-id='#{topic_id}'] .workflow-kanban__tags .discourse-tag[data-tag-name='#{tag_name}']",
+ )
+ end
+
+ def has_no_kanban_tag_for_topic?(topic_id, tag_name)
+ has_no_css?(
+ ".workflow-kanban__card[data-topic-id='#{topic_id}'] .workflow-kanban__tags .discourse-tag[data-tag-name='#{tag_name}']",
+ )
+ 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, topic_id, position)
+ 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
+ self
+ end
+
+ def start_drag_on_kanban_card(topic_id)
+ page.execute_script(<<~JS, topic_id)
+ 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
+ self
+ end
+
+ def end_drag_on_kanban_card(topic_id)
+ page.execute_script(<<~JS, topic_id)
+ 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
+ self
+ end
+
+ def move_kanban_card_with_key(topic_id, key)
+ page.execute_script(<<~JS, topic_id, key)
+ 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
+ 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..d0212df 100644
--- a/spec/system/workflow_quick_filters_spec.rb
+++ b/spec/system/workflow_quick_filters_spec.rb
@@ -4,25 +4,38 @@
fab!(:workflow_discovery_page) { PageObjects::Pages::WorkflowDiscovery.new }
fab!(:user) { Fabricate(:user, trust_level: TrustLevel[1], refresh_auto_groups: true) }
fab!(:workflow) { Fabricate(:workflow, name: "Quick Filter Workflow") }
+ fab!(:kanban_tag) { Fabricate(:tag, name: "kanban-tag") }
fab!(:category_1, :category)
fab!(:category_2, :category)
+ fab!(:category_3, :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
+ 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,
- workflow_id: workflow.id,
- category_id: category_1.id,
- position: 1,
+ :workflow_step_option,
+ workflow_step_id: step_1.id,
+ workflow_option_id: next_option.id,
+ target_step_id: step_2.id,
)
end
- fab!(:step_2) do
+ fab!(:step_transition_2) do
Fabricate(
- :workflow_step,
- workflow_id: workflow.id,
- category_id: category_2.id,
- position: 2,
+ :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_1) { Fabricate(:topic_with_op, category: category_1, user: user, tags: [kanban_tag]) }
fab!(:topic_2) { Fabricate(:topic_with_op, category: category_1, user: user) }
fab!(:workflow_state_1) do
Fabricate(
@@ -44,10 +57,13 @@
before do
enable_current_plugin
SiteSetting.workflow_enabled = true
+ SiteSetting.tagging_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)
@@ -100,10 +116,7 @@
expect(workflow_discovery_page).to have_quick_filters
workflow_discovery_page.set_step_filter(2)
- expect(page).to have_current_path(
- %r{/workflow\?.*workflow_step_position=2},
- url: true,
- )
+ expect(page).to have_current_path(%r{/workflow\?.*workflow_step_position=2}, url: true)
expect(page).to have_content(topic_2.title)
expect(page).to have_no_content(topic_1.title)
expect(page).to have_css(".workflow-quick-filters__apply-step.btn-primary")
@@ -115,10 +128,7 @@
expect(page).to have_css(".workflow-quick-filters__apply-step.btn-default")
workflow_discovery_page.set_step_filter(2)
- expect(page).to have_current_path(
- %r{/workflow\?.*workflow_step_position=2},
- url: true,
- )
+ expect(page).to have_current_path(%r{/workflow\?.*workflow_step_position=2}, url: true)
expect(page).to have_css(".workflow-quick-filters__apply-step.btn-primary")
workflow_discovery_page.set_step_filter(2)
@@ -166,11 +176,157 @@
workflow_discovery_page.visit_workflow
expect(page).to have_css("th.workflow-overdue-column")
- expect(
- page,
- ).to have_css("tr[data-topic-id='#{topic_1.id}'] .workflow-overdue-indicator")
- expect(
- page,
- ).to have_no_css("tr[data-topic-id='#{topic_2.id}'] .workflow-overdue-indicator")
+ expect(page).to have_css("tr[data-topic-id='#{topic_1.id}'] .workflow-overdue-indicator")
+ expect(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 "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 "uses step category colors for kanban column borders" do
+ category_1.update_columns(color: "112233")
+ category_2.update_columns(color: "445566")
+ category_3.update_columns(color: "778899")
+
+ workflow_discovery_page.visit_workflow
+ workflow_discovery_page.toggle_workflow_view
+
+ expect(workflow_discovery_page.kanban_column_border_color(1)).to eq(
+ css_rgb_for_hex(category_1.reload.color),
+ )
+ expect(workflow_discovery_page.kanban_column_border_color(2)).to eq(
+ css_rgb_for_hex(category_2.reload.color),
+ )
+ expect(workflow_discovery_page.kanban_column_border_color(3)).to eq(
+ css_rgb_for_hex(category_3.reload.color),
+ )
+ end
+
+ it "refreshes kanban view after stale transition errors to re-sync backend state" 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)
+
+ # Simulate another actor advancing this item after the client has loaded.
+ workflow_state_1.update_columns(workflow_step_id: step_2.id)
+
+ workflow_discovery_page.drag_kanban_card_to_step(topic_1.id, 2)
+
+ expect(page).to have_css(
+ ".dialog-body",
+ text:
+ "Transition Failed: probably due to stale UI state - please try again after refresh - refreshing!",
+ )
+ find("#dialog-holder .btn-primary").click
+ 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 "shows kanban card tags when enabled on the workflow and hides them when disabled" do
+ workflow_discovery_page.visit_workflow
+ workflow_discovery_page.toggle_workflow_view
+
+ expect(workflow_discovery_page).to have_kanban_tag_for_topic(topic_1.id, "kanban-tag")
+
+ workflow.update!(show_kanban_tags: false)
+ workflow_discovery_page.visit_workflow
+ workflow_discovery_page.toggle_workflow_view
+
+ expect(workflow_discovery_page).to have_no_kanban_tag_for_topic(topic_1.id, "kanban-tag")
+ 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
+
+ def css_rgb_for_hex(hex)
+ normalized = hex.delete("#")
+ normalized =
+ normalized.chars.map { |channel| "#{channel}#{channel}" }.join if normalized.length == 3
+ red, green, blue = normalized.scan(/../).map { |channel| channel.to_i(16) }
+ "rgb(#{red}, #{green}, #{blue})"
end
end