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); } 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