From f74d4bae6e1da6add3b6489f5bfb42870266c7dc Mon Sep 17 00:00:00 2001 From: merefield Date: Wed, 13 May 2026 23:21:19 +0100 Subject: [PATCH 01/22] WIP: graphical edit --- .../admin/workflow_steps_controller.rb | 16 + .../admin/components/workflow-editor.gjs | 66 +- .../components/workflow-overview-editor.gjs | 2276 +++++++++++++++++ .../stylesheets/common/workflow_common.scss | 195 ++ config/locales/client.en.yml | 16 +- .../workflow_step_options_controller_spec.rb | 15 + .../pages/workflow_admin_overview.rb | 1096 ++++++++ spec/system/workflow_admin_overview_spec.rb | 196 ++ 8 files changed, 3867 insertions(+), 9 deletions(-) create mode 100644 assets/javascripts/discourse/admin/components/workflow-overview-editor.gjs create mode 100644 spec/system/page_objects/pages/workflow_admin_overview.rb create mode 100644 spec/system/workflow_admin_overview_spec.rb diff --git a/app/controllers/discourse_workflow/admin/workflow_steps_controller.rb b/app/controllers/discourse_workflow/admin/workflow_steps_controller.rb index 96fd11e..1b3dad4 100644 --- a/app/controllers/discourse_workflow/admin/workflow_steps_controller.rb +++ b/app/controllers/discourse_workflow/admin/workflow_steps_controller.rb @@ -19,6 +19,7 @@ def index records: @workflow_steps, associations: [:category, { workflow_step_options: :workflow_option }], ).call + workflow_categories = categories_for_overview(@workflow_steps) render_json_dump( { workflow_steps: @@ -26,6 +27,11 @@ def index @workflow_steps, each_serializer: DiscourseWorkflow::WorkflowStepSerializer, ), + workflow_categories: + ActiveModel::ArraySerializer.new( + workflow_categories, + each_serializer: DiscourseWorkflow::WorkflowCategorySerializer, + ), }, ) end @@ -117,6 +123,16 @@ def workflow_step_params ) end + def categories_for_overview(workflow_steps) + categories = workflow_steps.filter_map(&:category) + parent_category_ids = + categories.map { |category| category.parent_category_id || category.id } + category_ids = categories.map(&:id) + category_ids.concat(Category.where(parent_category_id: parent_category_ids).pluck(:id)) + + Category.where(id: category_ids.uniq).order(:position) + end + def ensure_admin # Your admin constraint logic here end diff --git a/assets/javascripts/discourse/admin/components/workflow-editor.gjs b/assets/javascripts/discourse/admin/components/workflow-editor.gjs index 1f56227..a63f524 100644 --- a/assets/javascripts/discourse/admin/components/workflow-editor.gjs +++ b/assets/javascripts/discourse/admin/components/workflow-editor.gjs @@ -14,6 +14,7 @@ import Textarea from "discourse/components/d-textarea"; import DToggleSwitch from "discourse/components/d-toggle-switch"; import { popupAjaxError } from "discourse/lib/ajax-error"; import I18n, { i18n } from "discourse-i18n"; +import WorkflowOverviewEditor from "./workflow-overview-editor"; import WorkflowStepListEditor from "./workflow-step-list-editor"; export default class WorkflowEditor extends Component { @@ -26,6 +27,25 @@ export default class WorkflowEditor extends Component { @tracked isSaving = false; @tracked editingModel = null; @tracked showDelete = false; + @tracked stepsView = "list"; + + get showingStepsList() { + return this.stepsView === "list"; + } + + get showingStepsOverview() { + return this.stepsView === "overview"; + } + + @action + showStepsList() { + this.stepsView = "list"; + } + + @action + showStepsOverview() { + this.stepsView = "overview"; + } @action updateModel() { @@ -266,13 +286,45 @@ export default class WorkflowEditor extends Component { {{/if}} {{#if this.showSteps}} -
- +
+
+ + +
+ + {{#if this.showingStepsList}} + + {{else}} + + {{/if}}
{{/if}}
diff --git a/assets/javascripts/discourse/admin/components/workflow-overview-editor.gjs b/assets/javascripts/discourse/admin/components/workflow-overview-editor.gjs new file mode 100644 index 0000000..629701a --- /dev/null +++ b/assets/javascripts/discourse/admin/components/workflow-overview-editor.gjs @@ -0,0 +1,2276 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { Input } from "@ember/component"; +import { fn } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } 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 { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { bind } from "discourse/lib/decorators"; +import CategoryChooser from "discourse/select-kit/components/category-chooser"; +import { i18n } from "discourse-i18n"; + +export default class WorkflowOverviewEditor extends Component { + @service dialog; + + @tracked workflowSteps = []; + @tracked workflowOptions = []; + @tracked workflowCategories = []; + @tracked edgeLayouts = []; + @tracked previewPath = null; + @tracked draggedStepId = null; + @tracked draggedOptionId = null; + @tracked linkSourceStepId = null; + @tracked connectorDragMode = null; + @tracked connectorSourceStepId = null; + @tracked connectorSourceSide = null; + @tracked connectorTargetStepId = null; + @tracked connectorTargetSide = null; + @tracked newStepName = ""; + @tracked newStepCategoryId = null; + @tracked isLoading = false; + boardElement = null; + + get sortedSteps() { + return [...this.workflowSteps].sort((a, b) => { + return (a.position || 0) - (b.position || 0); + }); + } + + get lanes() { + const lanesById = new Map( + this.workflowCategories.map((category) => [ + category.id, + { + ...category, + steps: [], + }, + ]) + ); + + for (const step of this.sortedSteps) { + const category = step.category; + const categoryId = step.category_id || category?.id; + + if (!categoryId) { + continue; + } + + if (lanesById.has(categoryId)) { + const lane = lanesById.get(categoryId); + lane.name = category?.name || lane.name; + lane.color = category?.color || lane.color; + } else { + lanesById.set(categoryId, { + id: categoryId, + name: + category?.name || + i18n( + "admin.discourse_workflow.workflows.overview.unknown_category" + ), + color: category?.color, + steps: [], + }); + } + + lanesById.get(categoryId).steps.push(step); + } + + return [...lanesById.values()]; + } + + get hasSteps() { + return this.workflowSteps.length > 0; + } + + get connectorSides() { + return ["top", "right", "bottom", "left"]; + } + + get defaultWorkflowOptionId() { + return this.workflowOptions[0]?.id; + } + + get workflowEdges() { + return this.sortedSteps.flatMap((step) => { + return this.stepOptions(step) + .filter((stepOption) => { + return this.workflowSteps.some( + (targetStep) => targetStep.id === stepOption.target_step_id + ); + }) + .map((stepOption) => { + return { + source_step_id: step.id, + target_step_id: stepOption.target_step_id, + step_option: stepOption, + }; + }); + }); + } + + get positionSlots() { + return Array.from({ length: this.nextStepPosition }, (_, index) => { + return index + 1; + }); + } + + get nextStepPosition() { + return ( + Math.max(0, ...this.workflowSteps.map((step) => step.position || 0)) + 1 + ); + } + + @bind + stepOptions(step) { + return [...(step.workflow_step_options || [])].sort((a, b) => { + return (a.position || 0) - (b.position || 0); + }); + } + + @bind + workflowOptionLabel(workflowOptionId) { + const workflowOption = this.workflowOptions.find( + (option) => option.id === workflowOptionId + ); + + if (!workflowOption) { + return i18n( + "admin.discourse_workflow.workflows.steps.options.select_an_option" + ); + } + + return i18n( + `admin.discourse_workflow.workflows.steps.options.actions.${workflowOption.slug}` + ); + } + + @bind + isWorkflowOptionSelected(stepOption, workflowOption) { + return stepOption.workflow_option_id === workflowOption.id; + } + + @bind + stepName(stepId) { + return this.workflowSteps.find((step) => step.id === stepId)?.name; + } + + @bind + stepsForLanePosition(lane, position) { + return lane.steps.filter((step) => step.position === position); + } + + @bind + laneStyle(lane) { + return lane.color ? `border-color: #${lane.color}` : null; + } + + @bind + connectorHandleClass(step, side) { + const classes = [ + "workflow-overview-editor__connector-handle", + `workflow-overview-editor__connector-handle--${side}`, + ]; + + if (this.edgeForHandle(step, side)) { + classes.push("workflow-overview-editor__connector-handle--connected"); + } + + return classes.join(" "); + } + + @bind + connectorHandleLabel(step, side) { + return i18n( + "admin.discourse_workflow.workflows.overview.connector_handle", + { + step: step.name, + side, + } + ); + } + + edgeForHandle(step, side) { + return this.edgeLayouts.find((edge) => { + return ( + (edge.source_step_id === step.id && edge.source_side === side) || + (edge.target_step_id === step.id && edge.target_side === side) + ); + }); + } + + edgeForTargetHandle(step, side) { + return this.edgeLayouts.find((edge) => { + return edge.target_step_id === step.id && edge.target_side === side; + }); + } + + edgeForSourceHandle(step, side) { + return this.edgeLayouts.find((edge) => { + return edge.source_step_id === step.id && edge.source_side === side; + }); + } + + mergeWorkflowCategories(workflowSteps, allCategories = []) { + const categoriesById = new Map( + this.workflowCategories.map((category) => [category.id, category]) + ); + const allCategoriesById = new Map( + allCategories.map((category) => [category.id, category]) + ); + const workflowParentCategoryIds = new Set(); + + for (const step of workflowSteps) { + const category = step.category || allCategoriesById.get(step.category_id); + const categoryId = step.category_id || category?.id; + + if (!categoryId) { + continue; + } + + if (category?.parent_category_id) { + workflowParentCategoryIds.add(category.parent_category_id); + } else { + workflowParentCategoryIds.add(categoryId); + } + + categoriesById.set(categoryId, { + id: categoryId, + name: + category?.name || + categoriesById.get(categoryId)?.name || + i18n("admin.discourse_workflow.workflows.overview.unknown_category"), + color: category?.color || categoriesById.get(categoryId)?.color, + }); + } + + for (const category of allCategories) { + if (workflowParentCategoryIds.has(category.parent_category_id)) { + categoriesById.set(category.id, { + id: category.id, + name: category.name, + color: category.color, + parent_category_id: category.parent_category_id, + }); + } + } + + this.workflowCategories = [...categoriesById.values()]; + } + + updateStep(step, attributes) { + return ajax( + `/admin/plugins/discourse-workflow/workflow_steps/${step.id}.json`, + { + type: "PUT", + data: { + workflow_step: { + workflow_id: step.workflow_id, + position: step.position, + name: step.name, + description: step.description, + category_id: step.category_id, + overdue_days: step.overdue_days, + ai_enabled: step.ai_enabled, + ai_prompt: step.ai_prompt, + ...attributes, + }, + }, + } + ); + } + + updateStepOption(stepOption, attributes) { + return ajax( + `/admin/plugins/discourse-workflow/workflow_step_options/${stepOption.id}.json`, + { + type: "PUT", + data: { + workflow_step_option: { + position: stepOption.position, + workflow_step_id: stepOption.workflow_step_id, + workflow_option_id: stepOption.workflow_option_id, + target_step_id: stepOption.target_step_id, + ...attributes, + }, + }, + } + ); + } + + @action + confirmDeleteStepOption(stepOption) { + if (this.args.disabled) { + return; + } + + return this.dialog.confirm({ + message: i18n( + "admin.discourse_workflow.workflows.overview.confirm_delete_connector" + ), + confirmButtonClass: "btn-danger", + confirmButtonLabel: + "admin.discourse_workflow.workflows.overview.delete_connector", + didConfirm: async () => { + try { + await ajax( + `/admin/plugins/discourse-workflow/workflow_step_options/${stepOption.id}.json`, + { type: "DELETE" } + ); + await this.loadGraph(); + } catch (err) { + popupAjaxError(err); + } + }, + }); + } + + @action + async loadGraph() { + if (!this.args.workflow?.id) { + return; + } + + this.isLoading = true; + + try { + const [workflowStepsResult, workflowOptionsResult] = await Promise.all([ + ajax( + `/admin/plugins/discourse-workflow/workflows/${this.args.workflow.id}/workflow_steps.json` + ), + ajax("/admin/plugins/discourse-workflow/workflow_options.json"), + ]); + + this.workflowSteps = workflowStepsResult.workflow_steps || []; + this.workflowOptions = workflowOptionsResult.workflow_options || []; + this.mergeWorkflowCategories( + this.workflowSteps, + workflowStepsResult.workflow_categories || [] + ); + this.scheduleEdgeLayout(); + } catch (err) { + popupAjaxError(err); + } finally { + this.isLoading = false; + } + } + + @action + captureBoard(element) { + this.boardElement = element; + this.scheduleEdgeLayout(); + } + + @action + scheduleEdgeLayout() { + requestAnimationFrame(() => this.updateEdgeLayouts()); + } + + laneStackBounds(boardRect) { + const laneElements = this.boardElement.querySelectorAll( + ".workflow-overview-editor__lane" + ); + + if (!laneElements.length) { + return null; + } + + const bounds = Array.from(laneElements).map((lane) => { + const rect = lane.getBoundingClientRect(); + const headerRect = lane + .querySelector(".workflow-overview-editor__lane-header") + ?.getBoundingClientRect(); + + return { + left: rect.left - boardRect.left, + right: rect.right - boardRect.left, + top: rect.top - boardRect.top, + bottom: rect.bottom - boardRect.top, + labelTop: (headerRect || rect).top - boardRect.top, + labelBottom: (headerRect || rect).bottom - boardRect.top, + }; + }); + + return { + top: Math.min(...bounds.map((bound) => bound.top)), + bottom: Math.max(...bounds.map((bound) => bound.bottom)), + lanes: bounds, + }; + } + + updateEdgeLayouts() { + if (!this.boardElement) { + return; + } + + const boardRect = this.boardElement.getBoundingClientRect(); + const stepRectById = new Map(); + const laneStackBounds = this.laneStackBounds(boardRect); + + for (const step of this.workflowSteps) { + const element = this.boardElement.querySelector( + `[data-workflow-step-id="${step.id}"]` + ); + + if (!element) { + continue; + } + + const rect = element.getBoundingClientRect(); + stepRectById.set(step.id, { + left: rect.left - boardRect.left, + right: rect.right - boardRect.left, + top: rect.top - boardRect.top, + bottom: rect.bottom - boardRect.top, + width: rect.width, + height: rect.height, + centerX: rect.left - boardRect.left + rect.width / 2, + centerY: rect.top - boardRect.top + rect.height / 2, + }); + } + + const usedEndpoints = new Set(); + const routedSegments = []; + const routedLabels = []; + const routedArrowheads = []; + + this.edgeLayouts = this.workflowEdges + .map((edge, index) => { + const sourceRect = stepRectById.get(edge.source_step_id); + const targetRect = stepRectById.get(edge.target_step_id); + const sourceStep = this.workflowSteps.find( + (step) => step.id === edge.source_step_id + ); + const targetStep = this.workflowSteps.find( + (step) => step.id === edge.target_step_id + ); + + if (!sourceRect || !targetRect || !sourceStep || !targetStep) { + return null; + } + + const route = this.edgeRoute({ + allRects: stepRectById, + index, + sourceRect, + targetRect, + sourceStep, + targetStep, + usedEndpoints, + routedSegments, + routedLabels, + routedArrowheads, + laneStackBounds, + }); + + usedEndpoints.add( + this.endpointKey(edge.source_step_id, route.source_side) + ); + usedEndpoints.add( + this.endpointKey(edge.target_step_id, route.target_side) + ); + routedSegments.push(...route.segments); + routedLabels.push({ x: route.label_x, y: route.label_y }); + routedArrowheads.push({ x: route.arrowhead_x, y: route.arrowhead_y }); + + return { + ...edge, + path: route.path, + label_x: route.label_x, + label_y: route.label_y, + source_side: route.source_side, + target_side: route.target_side, + }; + }) + .filter(Boolean); + } + + edgeRoute({ + allRects, + index, + sourceRect, + targetRect, + sourceStep, + targetStep, + usedEndpoints, + routedSegments, + routedLabels, + routedArrowheads, + laneStackBounds, + }) { + const obstacleRects = this.obstacleRects( + allRects, + sourceStep.id, + targetStep.id + ); + const labelObstacleRects = [...allRects.values()]; + const routes = []; + + for (const [sourceSide, targetSide] of this.edgeSidePairs( + sourceStep, + targetStep + )) { + if ( + usedEndpoints.has(this.endpointKey(sourceStep.id, sourceSide)) || + usedEndpoints.has(this.endpointKey(targetStep.id, targetSide)) + ) { + continue; + } + + const route = this.routeBetweenHandles({ + index, + sourceRect, + sourceSide, + targetRect, + targetSide, + obstacleRects, + labelObstacleRects, + routedSegments, + routedLabels, + routedArrowheads, + laneStackBounds, + routeLengthMultiplier: this.routeLengthMultiplier( + sourceStep, + targetStep + ), + sidePenalty: this.edgeSidePairPenalty( + sourceStep, + targetStep, + sourceSide + ), + }); + + routes.push(route); + } + + const viableRoutes = routes + .filter((route) => !route.collided && route.score !== Infinity) + .sort((a, b) => a.score - b.score); + + if (viableRoutes.length) { + return viableRoutes[0]; + } + + const fallbackRoutes = routes + .filter((route) => route.score !== Infinity) + .sort((a, b) => a.score - b.score); + + return ( + fallbackRoutes[0] || + routes[0] || + this.defaultEdgeRoute({ + index, + sourceRect, + targetRect, + obstacleRects, + labelObstacleRects, + routedSegments, + routedLabels, + routedArrowheads, + laneStackBounds, + }) + ); + } + + defaultEdgeRoute({ + index, + sourceRect, + targetRect, + obstacleRects, + labelObstacleRects, + routedSegments, + routedLabels, + routedArrowheads, + laneStackBounds, + }) { + return this.routeBetweenHandles({ + index, + sourceRect, + sourceSide: "right", + targetRect, + targetSide: "left", + obstacleRects, + labelObstacleRects, + routedSegments, + routedLabels, + routedArrowheads, + laneStackBounds, + sidePenalty: 0, + }); + } + + edgeSidePairs(sourceStep, targetStep) { + const sourcePosition = sourceStep.position || 0; + const targetPosition = targetStep.position || 0; + + if (targetPosition > sourcePosition) { + return this.allSidePairs( + ["right", "bottom", "top", "left"], + ["left", "top", "bottom", "right"] + ); + } + + if (targetPosition < sourcePosition) { + return this.allSidePairs( + ["top", "left", "bottom", "right"], + ["top", "right", "bottom", "left"] + ); + } + + return this.allSidePairs( + ["bottom", "top", "right", "left"], + ["top", "bottom", "left", "right"] + ); + } + + allSidePairs(sourceSides, targetSides) { + return sourceSides.flatMap((sourceSide) => { + return targetSides.map((targetSide) => [sourceSide, targetSide]); + }); + } + + edgeSidePairPenalty(sourceStep, targetStep, sourceSide) { + if ((targetStep.position || 0) <= (sourceStep.position || 0)) { + return 0; + } + + if (sourceSide === "right") { + return 0; + } + + if (sourceSide === "bottom" || sourceSide === "top") { + return 350; + } + + return 1200; + } + + routeLengthMultiplier(sourceStep, targetStep) { + if ((targetStep.position || 0) < (sourceStep.position || 0)) { + return 0.35; + } + + return 1; + } + + routeBetweenHandles({ + index, + sourceRect, + sourceSide, + targetRect, + targetSide, + obstacleRects, + labelObstacleRects = obstacleRects, + routedSegments = [], + routedLabels = [], + routedArrowheads = [], + laneStackBounds = null, + routeLengthMultiplier = 1, + sidePenalty = 0, + }) { + const source = this.connectorPoint(sourceRect, sourceSide); + const target = this.connectorPoint(targetRect, targetSide); + const route = this.routePoints({ + index, + source, + sourceSide, + target, + targetSide, + obstacleRects, + labelObstacleRects, + routedSegments, + routedLabels, + routedArrowheads, + laneStackBounds, + routeLengthMultiplier, + sidePenalty, + }); + + return { + ...route, + source_side: sourceSide, + target_side: targetSide, + }; + } + + routePoints({ + index, + source, + sourceSide, + target, + targetSide, + obstacleRects, + labelObstacleRects = obstacleRects, + routedSegments = [], + routedLabels = [], + routedArrowheads = [], + laneStackBounds = null, + routeLengthMultiplier = 1, + sidePenalty = 0, + }) { + const escapeDistance = 26 + this.routeOffset(index, 8); + const sourceEscape = this.offsetPoint(source, sourceSide, escapeDistance); + const targetEscape = targetSide + ? this.offsetPoint(target, targetSide, escapeDistance) + : target; + const baseMidX = (sourceEscape.x + targetEscape.x) / 2; + const baseMidY = (sourceEscape.y + targetEscape.y) / 2; + const outsideX = + Math.max(source.x, target.x, sourceEscape.x, targetEscape.x) + + 64 + + index * 18; + const outsideY = + Math.min(source.y, target.y, sourceEscape.y, targetEscape.y) - + 40 - + index * 18; + const outsideBottomY = + Math.max(source.y, target.y, sourceEscape.y, targetEscape.y) + + 40 + + index * 18; + const candidates = [ + ...this.routingCandidates(baseMidX, 48).map((midX) => + this.compactPoints([ + source, + sourceEscape, + { x: midX, y: sourceEscape.y }, + { x: midX, y: targetEscape.y }, + targetEscape, + target, + ]) + ), + ...this.routingCandidates(baseMidY, 36).map((midY) => + this.compactPoints([ + source, + sourceEscape, + { x: sourceEscape.x, y: midY }, + { x: targetEscape.x, y: midY }, + targetEscape, + target, + ]) + ), + this.compactPoints([ + source, + sourceEscape, + { x: outsideX, y: sourceEscape.y }, + { x: outsideX, y: targetEscape.y }, + targetEscape, + target, + ]), + this.compactPoints([ + source, + sourceEscape, + { x: sourceEscape.x, y: outsideY }, + { x: targetEscape.x, y: outsideY }, + targetEscape, + target, + ]), + this.compactPoints([ + source, + sourceEscape, + { x: sourceEscape.x, y: outsideBottomY }, + { x: targetEscape.x, y: outsideBottomY }, + targetEscape, + target, + ]), + ]; + const scoredCandidates = candidates + .flatMap((points) => { + const segments = this.pointsToSegments(points); + + return this.labelCandidatesForSegments(segments).map( + (labelCandidate) => ({ + points, + segments, + labelPoint: labelCandidate.point, + score: + this.routeCandidateScore({ + segments, + labelPoint: labelCandidate.point, + obstacleRects, + labelObstacleRects, + routedSegments, + routedLabels, + arrowheadPoints: [...routedArrowheads, target], + laneStackBounds, + routeLengthMultiplier, + sidePenalty, + }) + labelCandidate.penalty, + }) + ); + }) + .filter((candidate) => candidate.score !== Infinity) + .sort((a, b) => a.score - b.score); + const selectedCandidate = scoredCandidates[0] || { + points: candidates[0], + segments: this.pointsToSegments(candidates[0]), + labelPoint: this.labelCandidatesForSegments( + this.pointsToSegments(candidates[0]) + )[0].point, + score: Infinity, + }; + + return { + path: this.pointsToPath(selectedCandidate.points), + label_x: selectedCandidate.labelPoint.x, + label_y: selectedCandidate.labelPoint.y, + arrowhead_x: target.x, + arrowhead_y: target.y, + collided: this.pathCollides(selectedCandidate.segments, obstacleRects), + segments: selectedCandidate.segments, + score: selectedCandidate.score, + }; + } + + connectorPoint(rect, side) { + if (side === "top") { + return { x: rect.centerX, y: rect.top }; + } + + if (side === "right") { + return { x: rect.right, y: rect.centerY }; + } + + if (side === "bottom") { + return { x: rect.centerX, y: rect.bottom }; + } + + return { x: rect.left, y: rect.centerY }; + } + + offsetPoint(point, side, distance) { + if (!side) { + return point; + } + + if (side === "top") { + return { x: point.x, y: point.y - distance }; + } + + if (side === "right") { + return { x: point.x + distance, y: point.y }; + } + + if (side === "bottom") { + return { x: point.x, y: point.y + distance }; + } + + return { x: point.x - distance, y: point.y }; + } + + pointsToSegments(points) { + return points.slice(1).map((point, index) => { + const previous = points[index]; + + return { + x1: previous.x, + y1: previous.y, + x2: point.x, + y2: point.y, + }; + }); + } + + routeCandidateScore({ + segments, + labelPoint, + obstacleRects, + labelObstacleRects, + routedSegments, + routedLabels, + arrowheadPoints = [], + laneStackBounds, + routeLengthMultiplier = 1, + sidePenalty, + }) { + if (this.routeDoublesBack(segments)) { + return Infinity; + } + + if (this.pathCollides(segments, obstacleRects)) { + return Infinity; + } + + const overlapCount = this.segmentOverlapCount(segments, routedSegments); + + if (overlapCount > 0) { + return Infinity; + } + + return ( + this.routeLength(segments) * routeLengthMultiplier + + this.routeTurnCount(segments) * 45 + + this.segmentCrossingCount(segments, routedSegments) * 2000 + + this.horizontalSegmentLabelPenalty(segments, routedLabels) + + this.labelPenalty({ + labelPoint, + routedLabels, + labelObstacleRects, + arrowheadPoints, + routedSegments, + laneStackBounds, + }) + + this.laneBorderTravelPenalty(segments, laneStackBounds) + + this.laneGapTravelPenalty(segments, laneStackBounds) + + this.laneEscapePenalty(segments, laneStackBounds) + + sidePenalty + ); + } + + labelPenalty({ + labelPoint, + routedLabels, + labelObstacleRects, + arrowheadPoints, + routedSegments, + laneStackBounds, + }) { + return ( + this.labelCollisionPenalty(labelPoint, routedLabels) + + this.labelObstaclePenalty(labelPoint, labelObstacleRects) + + this.labelArrowheadPenalty(labelPoint, arrowheadPoints) + + this.labelSegmentPenalty(labelPoint, routedSegments) + + this.labelLaneBoundaryPenalty(labelPoint, laneStackBounds) + ); + } + + laneBorderTravelPenalty(segments, laneStackBounds) { + if (!laneStackBounds?.lanes?.length) { + return 0; + } + + return segments.reduce((penalty, segment) => { + if (segment.y1 !== segment.y2) { + return penalty; + } + + const segmentLeft = Math.min(segment.x1, segment.x2); + const segmentRight = Math.max(segment.x1, segment.x2); + + return ( + penalty + + laneStackBounds.lanes.reduce((lanePenalty, lane) => { + const travelsAlongLaneEdge = + Math.abs(segment.y1 - lane.top) <= 2 || + Math.abs(segment.y1 - lane.bottom) <= 2; + + if (!travelsAlongLaneEdge) { + return lanePenalty; + } + + const overlap = + Math.min(segmentRight, lane.right) - + Math.max(segmentLeft, lane.left); + + return overlap > 0 ? lanePenalty + overlap * 80 : lanePenalty; + }, 0) + ); + }, 0); + } + + laneGapTravelPenalty(segments, laneStackBounds) { + if (!laneStackBounds?.lanes?.length) { + return 0; + } + + const lanes = [...laneStackBounds.lanes].sort((a, b) => a.top - b.top); + + return segments.reduce((penalty, segment) => { + if (segment.y1 !== segment.y2) { + return penalty; + } + + const segmentLeft = Math.min(segment.x1, segment.x2); + const segmentRight = Math.max(segment.x1, segment.x2); + + return ( + penalty + + lanes.slice(1).reduce((gapPenalty, lane, index) => { + const previousLane = lanes[index]; + const gapTop = previousLane.bottom; + const gapBottom = lane.top; + + if (segment.y1 <= gapTop || segment.y1 >= gapBottom) { + return gapPenalty; + } + + const overlap = + Math.min(segmentRight, previousLane.right, lane.right) - + Math.max(segmentLeft, previousLane.left, lane.left); + + return overlap > 0 ? gapPenalty + overlap * 140 : gapPenalty; + }, 0) + ); + }, 0); + } + + laneEscapePenalty(segments, laneStackBounds) { + if (!laneStackBounds) { + return 0; + } + + const connectorGutter = 48; + const upperLimit = laneStackBounds.top - connectorGutter; + const lowerLimit = laneStackBounds.bottom + connectorGutter; + + return segments.reduce((penalty, segment) => { + const escapedAbove = + Math.max(0, upperLimit - segment.y1) + + Math.max(0, upperLimit - segment.y2); + const escapedBelow = + Math.max(0, segment.y1 - lowerLimit) + + Math.max(0, segment.y2 - lowerLimit); + + return penalty + (escapedAbove + escapedBelow) * 240; + }, 0); + } + + labelCollisionPenalty(labelPoint, routedLabels) { + return routedLabels.reduce((penalty, routedLabel) => { + const horizontalDistance = Math.abs(labelPoint.x - routedLabel.x); + const verticalDistance = Math.abs(labelPoint.y - routedLabel.y); + const likelyHorizontalOverlap = horizontalDistance < 240; + + if (likelyHorizontalOverlap && verticalDistance < 40) { + return Infinity; + } + + if (likelyHorizontalOverlap && verticalDistance < 72) { + return penalty + (72 - verticalDistance) * 20; + } + + return penalty; + }, 0); + } + + labelObstaclePenalty(labelPoint, obstacleRects) { + const labelRect = { + left: labelPoint.x - 120, + right: labelPoint.x + 120, + top: labelPoint.y - 20, + bottom: labelPoint.y + 20, + }; + + return obstacleRects.reduce((penalty, rect) => { + const overlaps = + Math.max(labelRect.left, rect.left) < + Math.min(labelRect.right, rect.right) && + Math.max(labelRect.top, rect.top) < + Math.min(labelRect.bottom, rect.bottom); + + return overlaps ? penalty + 10_000 : penalty; + }, 0); + } + + labelArrowheadPenalty(labelPoint, arrowheadPoints) { + const labelRect = { + left: labelPoint.x - 120, + right: labelPoint.x + 120, + top: labelPoint.y - 20, + bottom: labelPoint.y + 20, + }; + + return arrowheadPoints.reduce((penalty, arrowheadPoint) => { + const covered = + arrowheadPoint.x >= labelRect.left && + arrowheadPoint.x <= labelRect.right && + arrowheadPoint.y >= labelRect.top && + arrowheadPoint.y <= labelRect.bottom; + + return covered ? penalty + 10_000 : penalty; + }, 0); + } + + labelSegmentPenalty(labelPoint, routedSegments) { + const labelRect = { + left: labelPoint.x - 120, + right: labelPoint.x + 120, + top: labelPoint.y - 20, + bottom: labelPoint.y + 20, + }; + + return routedSegments.reduce((penalty, segment) => { + const segmentIntersectsLabel = + this.horizontalSegmentIntersectsRect(segment, labelRect) || + this.verticalSegmentIntersectsRect(segment, labelRect); + + return segmentIntersectsLabel ? penalty + 10_000 : penalty; + }, 0); + } + + labelLaneBoundaryPenalty(labelPoint, laneStackBounds) { + if (!laneStackBounds?.lanes?.length) { + return 0; + } + + const labelRect = { + left: labelPoint.x - 120, + right: labelPoint.x + 120, + top: labelPoint.y - 20, + bottom: labelPoint.y + 20, + }; + + return laneStackBounds.lanes.reduce((penalty, lane) => { + const overlapsTop = + labelRect.top <= lane.top && + labelRect.bottom >= lane.top && + labelRect.right >= lane.left && + labelRect.left <= lane.right; + const overlapsBottom = + labelRect.top <= lane.bottom && + labelRect.bottom >= lane.bottom && + labelRect.right >= lane.left && + labelRect.left <= lane.right; + const overlapsLaneLabel = + labelRect.top <= lane.labelBottom && + labelRect.bottom >= lane.labelTop && + labelRect.right >= lane.left && + labelRect.left <= lane.right; + + return ( + penalty + + (overlapsTop || overlapsBottom ? 10_000 : 0) + + (overlapsLaneLabel ? 30_000 : 0) + ); + }, 0); + } + + horizontalSegmentLabelPenalty(segments, routedLabels) { + return segments.reduce((penalty, segment) => { + if (segment.y1 !== segment.y2) { + return penalty; + } + + return ( + penalty + + routedLabels.reduce((labelPenalty, routedLabel) => { + const labelRect = { + left: routedLabel.x - 120, + right: routedLabel.x + 120, + top: routedLabel.y - 20, + bottom: routedLabel.y + 20, + }; + + return this.horizontalSegmentIntersectsRect(segment, labelRect) + ? labelPenalty + 10_000 + : labelPenalty; + }, 0) + ); + }, 0); + } + + horizontalSegmentIntersectsRect(segment, rect) { + if (segment.y1 !== segment.y2) { + return false; + } + + const segmentLeft = Math.min(segment.x1, segment.x2); + const segmentRight = Math.max(segment.x1, segment.x2); + + return ( + segment.y1 >= rect.top && + segment.y1 <= rect.bottom && + Math.min(segmentRight, rect.right) > Math.max(segmentLeft, rect.left) + ); + } + + verticalSegmentIntersectsRect(segment, rect) { + if (segment.x1 !== segment.x2) { + return false; + } + + const segmentTop = Math.min(segment.y1, segment.y2); + const segmentBottom = Math.max(segment.y1, segment.y2); + + return ( + segment.x1 >= rect.left && + segment.x1 <= rect.right && + Math.min(segmentBottom, rect.bottom) > Math.max(segmentTop, rect.top) + ); + } + + routeDoublesBack(segments) { + return segments.slice(1).some((segment, index) => { + const previous = segments[index]; + const bothHorizontal = + previous.y1 === previous.y2 && segment.y1 === segment.y2; + const bothVertical = + previous.x1 === previous.x2 && segment.x1 === segment.x2; + + if (bothHorizontal && previous.y1 === segment.y1) { + return ( + Math.sign(previous.x2 - previous.x1) !== + Math.sign(segment.x2 - segment.x1) + ); + } + + if (bothVertical && previous.x1 === segment.x1) { + return ( + Math.sign(previous.y2 - previous.y1) !== + Math.sign(segment.y2 - segment.y1) + ); + } + + return false; + }); + } + + routeLength(segments) { + return segments.reduce((total, segment) => { + return ( + total + + Math.abs(segment.x2 - segment.x1) + + Math.abs(segment.y2 - segment.y1) + ); + }, 0); + } + + routeTurnCount(segments) { + return segments.slice(1).filter((segment, index) => { + const previous = segments[index]; + const previousHorizontal = previous.y1 === previous.y2; + const currentHorizontal = segment.y1 === segment.y2; + + return previousHorizontal !== currentHorizontal; + }).length; + } + + segmentOverlapCount(segments, routedSegments) { + return segments.reduce((count, segment) => { + return ( + count + + routedSegments.filter((routedSegment) => { + return this.segmentsOverlap(segment, routedSegment); + }).length + ); + }, 0); + } + + segmentCrossingCount(segments, routedSegments) { + return segments.reduce((count, segment) => { + return ( + count + + routedSegments.filter((routedSegment) => { + return this.segmentsCross(segment, routedSegment); + }).length + ); + }, 0); + } + + segmentsOverlap(segment, otherSegment) { + if (segment.y1 === segment.y2 && otherSegment.y1 === otherSegment.y2) { + return ( + segment.y1 === otherSegment.y1 && + this.rangesOverlap( + segment.x1, + segment.x2, + otherSegment.x1, + otherSegment.x2 + ) + ); + } + + if (segment.x1 === segment.x2 && otherSegment.x1 === otherSegment.x2) { + return ( + segment.x1 === otherSegment.x1 && + this.rangesOverlap( + segment.y1, + segment.y2, + otherSegment.y1, + otherSegment.y2 + ) + ); + } + + return false; + } + + segmentsCross(segment, otherSegment) { + if (this.segmentsOverlap(segment, otherSegment)) { + return false; + } + + const segmentHorizontal = segment.y1 === segment.y2; + const otherHorizontal = otherSegment.y1 === otherSegment.y2; + + if (segmentHorizontal === otherHorizontal) { + return false; + } + + const horizontal = segmentHorizontal ? segment : otherSegment; + const vertical = segmentHorizontal ? otherSegment : segment; + + return ( + this.valueBetween(vertical.x1, horizontal.x1, horizontal.x2) && + this.valueBetween(horizontal.y1, vertical.y1, vertical.y2) + ); + } + + rangesOverlap(start, end, otherStart, otherEnd) { + const min = Math.min(start, end); + const max = Math.max(start, end); + const otherMin = Math.min(otherStart, otherEnd); + const otherMax = Math.max(otherStart, otherEnd); + + return Math.max(min, otherMin) < Math.min(max, otherMax); + } + + valueBetween(value, start, end) { + return value > Math.min(start, end) && value < Math.max(start, end); + } + + labelCandidatesForSegments(segments) { + const verticalSegments = segments.filter( + (segment) => segment.x1 === segment.x2 + ); + const candidateSegments = verticalSegments.length + ? verticalSegments + : segments; + const longestSegment = candidateSegments.reduce((longest, segment) => { + return this.segmentLength(segment) > this.segmentLength(longest) + ? segment + : longest; + }, candidateSegments[0]); + + return [ + { ratio: 1 / 2, penalty: 0 }, + { ratio: 1 / 3, penalty: 18 }, + { ratio: 2 / 3, penalty: 18 }, + { ratio: 1 / 4, penalty: 36 }, + { ratio: 3 / 4, penalty: 36 }, + { ratio: 1 / 5, penalty: 54 }, + { ratio: 4 / 5, penalty: 54 }, + { ratio: 1 / 10, penalty: 72 }, + { ratio: 9 / 10, penalty: 72 }, + ].map((candidate) => { + return { + point: { + x: + longestSegment.x1 + + (longestSegment.x2 - longestSegment.x1) * candidate.ratio, + y: + longestSegment.y1 + + (longestSegment.y2 - longestSegment.y1) * candidate.ratio, + }, + penalty: candidate.penalty, + }; + }); + } + + segmentLength(segment) { + return ( + Math.abs(segment.x2 - segment.x1) + Math.abs(segment.y2 - segment.y1) + ); + } + + pointsToPath(points) { + const [firstPoint, ...remainingPoints] = this.compactPoints(points); + const commands = [`M${firstPoint.x},${firstPoint.y}`]; + let previous = firstPoint; + + for (const point of remainingPoints) { + if (point.x === previous.x) { + commands.push(`V${point.y}`); + } else if (point.y === previous.y) { + commands.push(`H${point.x}`); + } else { + commands.push(`L${point.x},${point.y}`); + } + + previous = point; + } + + return commands.join(" "); + } + + compactPoints(points) { + return points.filter((point, index) => { + const previous = points[index - 1]; + return !previous || previous.x !== point.x || previous.y !== point.y; + }); + } + + endpointKey(stepId, side) { + return `${stepId}:${side}`; + } + + connectorEndpointInUse({ stepId, side, ignoredStepOptionId }) { + return this.edgeLayouts.some((edge) => { + return ( + edge.step_option.id !== ignoredStepOptionId && + ((edge.source_step_id === stepId && edge.source_side === side) || + (edge.target_step_id === stepId && edge.target_side === side)) + ); + }); + } + + legacyEdgeRoute({ + allRects, + index, + sourceRect, + targetRect, + sourceStep, + targetStep, + }) { + const sourcePosition = sourceStep.position || 0; + const targetPosition = targetStep.position || 0; + const obstacleRects = this.obstacleRects( + allRects, + sourceStep.id, + targetStep.id + ); + + if (targetPosition > sourcePosition) { + return this.forwardEdgeRoute( + index, + sourceRect, + targetRect, + obstacleRects + ); + } + + if (targetPosition < sourcePosition) { + return this.returnEdgeRoute(index, sourceRect, targetRect, obstacleRects); + } + + return this.sameColumnEdgeRoute( + index, + sourceRect, + targetRect, + obstacleRects + ); + } + + forwardEdgeRoute(index, sourceRect, targetRect, obstacleRects) { + const source = { + x: sourceRect.right, + y: sourceRect.centerY, + }; + const target = { + x: targetRect.left, + y: targetRect.centerY, + }; + const baseMidX = (source.x + target.x) / 2 + this.routeOffset(index, 12); + const candidates = this.routingCandidates(baseMidX, 48); + const midX = + candidates.find((candidate) => { + return !this.pathCollides( + [ + { x1: source.x, y1: source.y, x2: candidate, y2: source.y }, + { x1: candidate, y1: source.y, x2: candidate, y2: target.y }, + { x1: candidate, y1: target.y, x2: target.x, y2: target.y }, + ], + obstacleRects + ); + }) || baseMidX; + + return { + path: `M${source.x},${source.y} H${midX} V${target.y} H${target.x}`, + label_x: midX, + label_y: (source.y + target.y) / 2, + }; + } + + returnEdgeRoute(index, sourceRect, targetRect, obstacleRects) { + const source = { + x: sourceRect.centerX, + y: sourceRect.top, + }; + const target = { + x: targetRect.centerX, + y: targetRect.top, + }; + const baseReturnY = + Math.min(sourceRect.top, targetRect.top) - + 24 - + this.routeOffset(index, 18); + const candidates = Array.from({ length: 8 }, (_, offset) => { + return baseReturnY - offset * 18; + }); + const returnY = + candidates.find((candidate) => { + return !this.pathCollides( + [ + { x1: source.x, y1: source.y, x2: source.x, y2: candidate }, + { x1: source.x, y1: candidate, x2: target.x, y2: candidate }, + { x1: target.x, y1: candidate, x2: target.x, y2: target.y }, + ], + obstacleRects + ); + }) || baseReturnY; + + return { + path: `M${source.x},${source.y} V${returnY} H${target.x} V${target.y}`, + label_x: (source.x + target.x) / 2, + label_y: returnY, + }; + } + + sameColumnEdgeRoute(index, sourceRect, targetRect, obstacleRects) { + const sourceAboveTarget = sourceRect.centerY <= targetRect.centerY; + const source = { + x: sourceRect.centerX, + y: sourceAboveTarget ? sourceRect.bottom : sourceRect.top, + }; + const target = { + x: targetRect.centerX, + y: sourceAboveTarget ? targetRect.top : targetRect.bottom, + }; + const baseSideX = + Math.max(sourceRect.right, targetRect.right) + + 24 + + this.routeOffset(index, 12); + const midY = (source.y + target.y) / 2; + const candidates = Array.from({ length: 8 }, (_, offset) => { + return baseSideX + offset * 18; + }); + const sideX = + candidates.find((candidate) => { + return !this.pathCollides( + [ + { x1: source.x, y1: source.y, x2: source.x, y2: midY }, + { x1: source.x, y1: midY, x2: candidate, y2: midY }, + { x1: candidate, y1: midY, x2: candidate, y2: target.y }, + { x1: candidate, y1: target.y, x2: target.x, y2: target.y }, + ], + obstacleRects + ); + }) || baseSideX; + + return { + path: `M${source.x},${source.y} V${midY} H${sideX} V${target.y} H${target.x}`, + label_x: sideX, + label_y: midY, + }; + } + + obstacleRects(allRects, sourceStepId, targetStepId) { + return [...allRects.entries()] + .filter(([stepId]) => { + return stepId !== sourceStepId && stepId !== targetStepId; + }) + .map(([, rect]) => rect); + } + + routingCandidates(baseValue, stepSize) { + return Array.from({ length: 40 }, (_, index) => { + if (index === 0) { + return baseValue; + } + + const direction = index % 2 === 0 ? -1 : 1; + const multiplier = Math.ceil(index / 2); + return baseValue + direction * multiplier * stepSize; + }); + } + + pathCollides(segments, rects) { + return segments.some((segment) => { + return rects.some((rect) => this.segmentIntersectsRect(segment, rect, 8)); + }); + } + + segmentIntersectsRect(segment, rect, padding) { + const left = rect.left - padding; + const right = rect.right + padding; + const top = rect.top - padding; + const bottom = rect.bottom + padding; + const minX = Math.min(segment.x1, segment.x2); + const maxX = Math.max(segment.x1, segment.x2); + const minY = Math.min(segment.y1, segment.y2); + const maxY = Math.max(segment.y1, segment.y2); + + if (segment.y1 === segment.y2) { + return ( + segment.y1 >= top && + segment.y1 <= bottom && + maxX >= left && + minX <= right + ); + } + + if (segment.x1 === segment.x2) { + return ( + segment.x1 >= left && + segment.x1 <= right && + maxY >= top && + minY <= bottom + ); + } + + return false; + } + + routeOffset(index, size) { + return (index % 3) * size; + } + + @bind + edgeOptionStyle(edge) { + return `left: ${edge.label_x}px; top: ${edge.label_y}px;`; + } + + @action + dragStepStart(step) { + this.draggedStepId = step.id; + } + + @action + dragStepEnd() { + this.draggedStepId = null; + this.scheduleEdgeLayout(); + } + + @action + allowDrop(event) { + event.preventDefault(); + } + + boardPointForEvent(event) { + const boardRect = this.boardElement.getBoundingClientRect(); + + return { + x: event.clientX - boardRect.left, + y: event.clientY - boardRect.top, + }; + } + + stepRect(stepId) { + const boardRect = this.boardElement.getBoundingClientRect(); + const element = this.boardElement.querySelector( + `[data-workflow-step-id="${stepId}"]` + ); + + if (!element) { + return null; + } + + const rect = element.getBoundingClientRect(); + + return { + left: rect.left - boardRect.left, + right: rect.right - boardRect.left, + top: rect.top - boardRect.top, + bottom: rect.bottom - boardRect.top, + width: rect.width, + height: rect.height, + centerX: rect.left - boardRect.left + rect.width / 2, + centerY: rect.top - boardRect.top + rect.height / 2, + }; + } + + updateConnectorPreview(point, targetSide = null) { + if (!this.boardElement || !this.connectorDragMode) { + this.previewPath = null; + return; + } + + const fixedStepId = + this.connectorDragMode === "retarget-source" + ? this.connectorTargetStepId + : this.connectorSourceStepId; + const fixedSide = + this.connectorDragMode === "retarget-source" + ? this.connectorTargetSide + : this.connectorSourceSide; + const fixedRect = this.stepRect(fixedStepId); + + if (!fixedRect) { + this.previewPath = null; + return; + } + + const allRects = new Map(); + const laneStackBounds = this.laneStackBounds( + this.boardElement.getBoundingClientRect() + ); + for (const step of this.workflowSteps) { + const rect = this.stepRect(step.id); + + if (rect) { + allRects.set(step.id, rect); + } + } + + const obstacleRects = [...allRects.entries()] + .filter(([stepId]) => stepId !== fixedStepId) + .map(([, rect]) => rect); + const labelObstacleRects = [...allRects.values()]; + const fixedPoint = this.connectorPoint(fixedRect, fixedSide); + const route = + this.connectorDragMode === "retarget-source" + ? this.routePoints({ + index: this.edgeLayouts.length + 1, + source: point, + sourceSide: targetSide, + target: fixedPoint, + targetSide: fixedSide, + obstacleRects, + labelObstacleRects, + laneStackBounds, + }) + : this.routePoints({ + index: this.edgeLayouts.length + 1, + source: fixedPoint, + sourceSide: fixedSide, + target: point, + targetSide, + obstacleRects, + labelObstacleRects, + laneStackBounds, + }); + + this.previewPath = route.path; + } + + clearConnectorDrag() { + this.previewPath = null; + this.draggedOptionId = null; + this.linkSourceStepId = null; + this.connectorDragMode = null; + this.connectorSourceStepId = null; + this.connectorSourceSide = null; + this.connectorTargetStepId = null; + this.connectorTargetSide = null; + } + + @action + dragConnectorOverBoard(event) { + if (!this.connectorDragMode) { + return; + } + + event.preventDefault(); + this.updateConnectorPreview(this.boardPointForEvent(event)); + } + + @action + dragConnectorHandleStart(step, side, event) { + event.stopPropagation(); + + const targetEdge = this.edgeForTargetHandle(step, side); + const sourceEdge = this.edgeForSourceHandle(step, side); + + if (targetEdge) { + this.connectorDragMode = "retarget-target"; + this.draggedOptionId = targetEdge.step_option.id; + this.connectorSourceStepId = targetEdge.source_step_id; + this.connectorSourceSide = targetEdge.source_side; + this.connectorTargetStepId = step.id; + this.connectorTargetSide = side; + } else if (sourceEdge) { + this.connectorDragMode = "retarget-source"; + this.draggedOptionId = sourceEdge.step_option.id; + this.connectorSourceStepId = step.id; + this.connectorSourceSide = side; + this.connectorTargetStepId = sourceEdge.target_step_id; + this.connectorTargetSide = sourceEdge.target_side; + } else { + this.connectorDragMode = "create"; + this.linkSourceStepId = step.id; + this.connectorSourceStepId = step.id; + this.connectorSourceSide = side; + } + + this.updateConnectorPreview(this.boardPointForEvent(event)); + } + + @action + dragConnectorHandleOver(step, side, event) { + if (!this.connectorDragMode) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const rect = this.stepRect(step.id); + + if (!rect) { + return; + } + + this.updateConnectorPreview(this.connectorPoint(rect, side), side); + } + + @action + async dropConnectorHandle(targetStep, targetSide, event) { + if (!this.connectorDragMode) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const sourceStepId = + this.connectorDragMode === "retarget-source" + ? targetStep.id + : this.connectorSourceStepId; + const sourceSide = + this.connectorDragMode === "retarget-source" + ? targetSide + : this.connectorSourceSide; + const finalTargetStepId = + this.connectorDragMode === "retarget-source" + ? this.connectorTargetStepId + : targetStep.id; + const finalTargetSide = + this.connectorDragMode === "retarget-source" + ? this.connectorTargetSide + : targetSide; + + if ( + sourceStepId === finalTargetStepId || + this.connectorEndpointInUse({ + stepId: sourceStepId, + side: sourceSide, + ignoredStepOptionId: this.draggedOptionId, + }) || + this.connectorEndpointInUse({ + stepId: finalTargetStepId, + side: finalTargetSide, + ignoredStepOptionId: this.draggedOptionId, + }) + ) { + this.clearConnectorDrag(); + return; + } + + try { + if (this.connectorDragMode === "create") { + await this.createLink(finalTargetStepId); + } else if (this.connectorDragMode === "retarget-source") { + await this.retargetOptionSource(sourceStepId); + } else { + await this.retargetOption(finalTargetStepId); + } + } catch (err) { + popupAjaxError(err); + } finally { + this.clearConnectorDrag(); + } + } + + @action + dragConnectorHandleEnd() { + this.clearConnectorDrag(); + this.scheduleEdgeLayout(); + } + + @action + async dropStepOnLane(lane, event) { + event.preventDefault(); + + const step = this.workflowSteps.find( + (workflowStep) => workflowStep.id === this.draggedStepId + ); + + if (!step || step.category_id === lane.id) { + this.draggedStepId = null; + return; + } + + try { + await this.updateStep(step, { category_id: lane.id }); + await this.loadGraph(); + } catch (err) { + popupAjaxError(err); + } finally { + this.draggedStepId = null; + } + } + + @action + async dropStepOnLanePosition(lane, position, event) { + event.preventDefault(); + + const sourceStep = this.workflowSteps.find( + (workflowStep) => workflowStep.id === this.draggedStepId + ); + + if ( + !sourceStep || + (sourceStep.category_id === lane.id && sourceStep.position === position) + ) { + this.draggedStepId = null; + return; + } + + const targetStep = this.workflowSteps.find((workflowStep) => { + return ( + workflowStep.id !== sourceStep.id && workflowStep.position === position + ); + }); + + try { + if (targetStep) { + await this.updateStep(targetStep, { position: sourceStep.position }); + } + + await this.updateStep(sourceStep, { + category_id: lane.id, + position, + }); + await this.loadGraph(); + } catch (err) { + popupAjaxError(err); + } finally { + this.draggedStepId = null; + } + } + + async reorderStep(targetStep) { + const sourceStep = this.workflowSteps.find( + (step) => step.id === this.draggedStepId + ); + + if (!sourceStep || sourceStep.id === targetStep.id) { + return; + } + + const sourcePosition = sourceStep.position; + await this.updateStep(targetStep, { position: sourcePosition }); + await this.updateStep(sourceStep, { position: targetStep.position }); + await this.loadGraph(); + } + + @action + async dropOnStep(targetStep, event) { + event.preventDefault(); + event.stopPropagation(); + + try { + if (this.draggedStepId) { + await this.reorderStep(targetStep); + } + } catch (err) { + popupAjaxError(err); + } finally { + this.draggedStepId = null; + } + } + + @action + dragLinkStart(step, event) { + event.stopPropagation(); + this.linkSourceStepId = step.id; + } + + @action + clearLinkSource() { + this.linkSourceStepId = null; + } + + async createLink(targetStepId) { + const sourceStep = this.workflowSteps.find( + (step) => step.id === this.linkSourceStepId + ); + const workflowOptionId = this.defaultWorkflowOptionId; + + if (!sourceStep || !workflowOptionId || sourceStep.id === targetStepId) { + return; + } + + await ajax("/admin/plugins/discourse-workflow/workflow_step_options.json", { + type: "POST", + data: { + workflow_step_option: { + workflow_step_id: sourceStep.id, + workflow_option_id: workflowOptionId, + target_step_id: targetStepId, + position: this.stepOptions(sourceStep).length + 1, + }, + }, + }); + await this.loadGraph(); + } + + @action + dragOptionStart(stepOption, event) { + event.stopPropagation(); + this.draggedOptionId = stepOption.id; + } + + @action + dragOptionEnd() { + this.draggedOptionId = null; + } + + async retargetOption(targetStepId) { + const stepOption = this.workflowSteps + .flatMap((step) => step.workflow_step_options || []) + .find((option) => option.id === this.draggedOptionId); + + if (!stepOption || stepOption.target_step_id === targetStepId) { + return; + } + + await this.updateStepOption(stepOption, { target_step_id: targetStepId }); + await this.loadGraph(); + } + + async retargetOptionSource(sourceStepId) { + const stepOption = this.workflowSteps + .flatMap((step) => step.workflow_step_options || []) + .find((option) => option.id === this.draggedOptionId); + + if (!stepOption || stepOption.workflow_step_id === sourceStepId) { + return; + } + + const sourceStep = this.workflowSteps.find( + (step) => step.id === sourceStepId + ); + + await this.updateStepOption(stepOption, { + workflow_step_id: sourceStepId, + position: sourceStep ? this.stepOptions(sourceStep).length + 1 : 1, + }); + await this.loadGraph(); + } + + @action + async updateStepOptionName(stepOption, event) { + try { + await this.updateStepOption(stepOption, { + workflow_option_id: parseInt(event.target.value, 10), + }); + await this.loadGraph(); + } catch (err) { + popupAjaxError(err); + } + } + + @action + updateNewStepCategory(categoryId) { + this.newStepCategoryId = categoryId; + } + + @action + async addStep() { + if (!this.newStepCategoryId) { + return; + } + + try { + await ajax("/admin/plugins/discourse-workflow/workflow_steps.json", { + type: "POST", + data: { + workflow_step: { + workflow_id: this.args.workflow.id, + name: + this.newStepName || + i18n( + "admin.discourse_workflow.workflows.overview.default_step_name", + { + position: this.nextStepPosition, + } + ), + category_id: this.newStepCategoryId, + position: this.nextStepPosition, + }, + }, + }); + this.newStepName = ""; + await this.loadGraph(); + } catch (err) { + popupAjaxError(err); + } + } + + +} diff --git a/assets/stylesheets/common/workflow_common.scss b/assets/stylesheets/common/workflow_common.scss index fecac7a..2739613 100644 --- a/assets/stylesheets/common/workflow_common.scss +++ b/assets/stylesheets/common/workflow_common.scss @@ -129,6 +129,201 @@ body.workflow-topic { color: var(--danger); font-weight: 600; } + + .workflow-editor__steps-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + } + + .workflow-overview-editor { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .workflow-overview-editor__add-step { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; + } + + .workflow-overview-editor__new-step-name { + margin-bottom: 0; + } + + .workflow-overview-editor__lanes { + display: grid; + gap: 1rem; + } + + .workflow-overview-editor__board { + position: relative; + } + + .workflow-overview-editor__edge-layer, + .workflow-overview-editor__edge-controls { + position: absolute; + inset: 0; + pointer-events: none; + } + + .workflow-overview-editor__edge-layer { + z-index: 4; + overflow: visible; + + marker path { + fill: var(--primary-medium); + } + } + + .workflow-overview-editor__edge-path { + fill: none; + stroke: var(--primary-medium); + stroke-width: 2; + marker-end: url("#workflow-overview-editor-arrowhead"); + } + + .workflow-overview-editor__edge-path--preview { + stroke: var(--tertiary); + stroke-dasharray: 5 4; + } + + .workflow-overview-editor__edge-controls { + z-index: 5; + } + + .workflow-overview-editor__lane { + position: relative; + z-index: 1; + border: 2px solid var(--primary-low); + border-radius: var(--d-border-radius); + background: var(--secondary); + min-height: 180px; + overflow: hidden; + } + + .workflow-overview-editor__lane-header { + background: var(--primary-very-low); + border-bottom: 1px solid var(--primary-low); + font-weight: 700; + padding: 0.6rem 0.75rem; + } + + .workflow-overview-editor__lane-steps { + display: grid; + grid-auto-columns: minmax(220px, 1fr); + grid-auto-flow: column; + gap: 0.75rem; + padding: 0.75rem; + } + + .workflow-overview-editor__position-slot { + border-left: 1px dashed var(--primary-low); + min-height: 7rem; + padding-left: 0.75rem; + + &:first-child { + border-left: 0; + padding-left: 0; + } + } + + .workflow-overview-editor__step { + position: relative; + background: var(--secondary); + border: 1px solid var(--primary-low); + border-radius: var(--d-border-radius); + box-shadow: 0 1px 3px color-mix(in srgb, var(--primary) 12%, transparent); + cursor: grab; + min-width: 220px; + padding: 0.75rem; + } + + .workflow-overview-editor__step-title { + display: flex; + gap: 0.35rem; + margin-bottom: 0.5rem; + } + + .workflow-overview-editor__connector-handle { + position: absolute; + z-index: 6; + width: 0.75rem; + height: 0.75rem; + border: 1px dotted var(--primary-high); + border-radius: 50%; + background: var(--secondary); + cursor: crosshair; + padding: 0; + } + + .workflow-overview-editor__connector-handle--connected { + border-style: solid; + background: var(--tertiary-low); + } + + .workflow-overview-editor__connector-handle--top { + top: 0; + left: 50%; + transform: translate(-50%, -50%); + } + + .workflow-overview-editor__connector-handle--right { + top: 50%; + right: 0; + transform: translate(50%, -50%); + } + + .workflow-overview-editor__connector-handle--bottom { + bottom: 0; + left: 50%; + transform: translate(-50%, 50%); + } + + .workflow-overview-editor__connector-handle--left { + top: 50%; + left: 0; + transform: translate(-50%, -50%); + } + + .workflow-overview-editor__options { + display: flex; + flex-direction: column; + gap: 0.4rem; + list-style: none; + margin: 0; + } + + .workflow-overview-editor__option { + position: absolute; + display: flex; + align-items: center; + border: 1px solid var(--primary-low); + border-radius: var(--d-border-radius); + background: var(--secondary); + box-shadow: 0 1px 3px color-mix(in srgb, var(--primary) 12%, transparent); + cursor: default; + padding: 0.25rem; + pointer-events: auto; + transform: translate(-50%, -50%); + } + + .workflow-overview-editor__delete-option { + align-self: stretch; + border-radius: calc(var(--d-border-radius) - 1px) 0 0 + calc(var(--d-border-radius) - 1px); + margin: -0.25rem 0.25rem -0.25rem -0.25rem; + min-height: 0; + min-width: 0; + padding: 0.25rem 0.4rem; + } + + .workflow-overview-editor__option select { + margin-bottom: 0; + width: 12rem; + } } .workflow-quick-filters { diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 9f94729..4b6da46 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -24,7 +24,7 @@ en: none: workflow: Currently, there are no viewable Workflow topics. workflow-name: "Workflow" - workflow-step-name: "Step Name" + workflow-step-name: "Step Name" workflow-step-position: "Step" workflow-overdue: "Overdue" filters: @@ -164,6 +164,9 @@ en: category: "Category" ai_enabled: "AI Enabled" ai_prompt: "AI Prompt" + tabs: + list: "List" + overview: "Overview" overdue_days: "Step Overdue Days Override" overdue_days_help: "Blank uses workflow/global defaults. 0 disables overdue behavior for this step." title: "Steps" @@ -186,7 +189,7 @@ en: target_position: "Target Position" actions: submit: "Submit" - start : "Start" + start: "Start" accept: "Accept" reject: "Reject" great: "Great" @@ -201,6 +204,15 @@ en: finish: "Finish" close: "Close" reopen: "Reopen" + overview: + add_step: "Add step" + confirm_delete_connector: "Are you sure you want to permanently delete this connector?" + connector_handle: "%{step} %{side} connector" + default_step_name: "Step %{position}" + delete_connector: "Delete connector" + loading: "Loading workflow overview..." + new_step_name: "New step name" + unknown_category: "Unknown category" back: "Back" save: "Save" saved: "Workflow saved!" diff --git a/spec/requests/admin/workflow_step_options_controller_spec.rb b/spec/requests/admin/workflow_step_options_controller_spec.rb index 1f519a5..7c1f2d1 100644 --- a/spec/requests/admin/workflow_step_options_controller_spec.rb +++ b/spec/requests/admin/workflow_step_options_controller_spec.rb @@ -43,6 +43,21 @@ expect(DiscourseWorkflow::WorkflowStepOption.order(:id).last.position).to eq(2) end + it "updates the workflow option used by a step option" do + put "/admin/plugins/discourse-workflow/workflow_step_options/#{step_option.id}.json", + params: { + workflow_step_option: { + workflow_step_id: step_1.id, + workflow_option_id: new_option.id, + target_step_id: step_2.id, + position: step_option.position, + }, + } + + expect(response.status).to eq(200) + expect(step_option.reload.workflow_option_id).to eq(new_option.id) + end + it "does not add per-option queries when listing step options" do get "/admin/plugins/discourse-workflow/workflows/#{workflow.id}/workflow_steps/#{step_1.id}/workflow_step_options.json" base_query_count = diff --git a/spec/system/page_objects/pages/workflow_admin_overview.rb b/spec/system/page_objects/pages/workflow_admin_overview.rb new file mode 100644 index 0000000..09da0eb --- /dev/null +++ b/spec/system/page_objects/pages/workflow_admin_overview.rb @@ -0,0 +1,1096 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class WorkflowAdminOverview < PageObjects::Pages::Base + def visit_workflow(workflow) + page.visit "/admin/plugins/discourse-workflow/workflows/#{workflow.id}/edit" + self + end + + def switch_to_overview + click_button "Overview" + self + end + + def has_steps_tab?(label) + has_css?(".workflow-editor__steps-tab", text: label) + end + + def has_overview? + has_css?(".workflow-overview-editor") + end + + def has_step?(step, text: nil) + options = text ? { text: text } : {} + has_css?(step_selector(step), **options) + end + + def has_lane?(category, text: nil) + options = text ? { text: text } : {} + has_css?(lane_selector(category), **options) + end + + def has_step_in_lane?(step, category) + has_css?("#{lane_selector(category)} #{step_selector(step)}") + end + + def has_step_in_lane_position?(step, category, position) + has_css?("#{position_selector(category, position)} #{step_selector(step)}") + end + + def has_option?(step_option, text: nil) + options = text ? { text: text } : {} + has_css?(option_selector(step_option), **options) + end + + def has_any_option?(text:) + has_css?(".workflow-overview-editor__option", text: text) + end + + def has_any_option_control? + has_css?(".workflow-overview-editor__option") + end + + def has_no_new_arrow_option_control? + has_no_css?(".workflow-overview-editor__link-option") && has_no_content?("New arrow option") + end + + def has_connector_handles?(step) + %w[top right bottom left].all? { |side| has_css?(connector_handle_selector(step, side)) } + end + + def has_arrow_link_for_option?(step_option) + has_css?("#{option_selector(step_option)}") && + has_css?(".workflow-overview-editor__edge-path") + end + + def has_only_orthogonal_arrow_paths? + has_css?(".workflow-overview-editor__edge-path") && + all(".workflow-overview-editor__edge-path").all? do |path| + path["d"].exclude?("C") && path["d"].exclude?("Q") + end + end + + def has_forward_arrow_path? + has_css?(".workflow-overview-editor__edge-path") && + all(".workflow-overview-editor__edge-path").any? do |path| + path["d"].include?(" H") && path["d"].include?(" V") + end + end + + def has_no_arrow_crossing_step_boxes? + has_css?(".workflow-overview-editor__edge-path") && page.evaluate_script(<<~JS) + (() => { + const parsePathSegments = (path) => { + const tokens = path.getAttribute("d").match(/[MLHV]|-?\\d+(?:\\.\\d+)?/g) || []; + const segments = []; + let index = 0; + let command = null; + let current = { x: 0, y: 0 }; + + while (index < tokens.length) { + const token = tokens[index++]; + if (["M", "L", "H", "V"].includes(token)) { + command = token; + } else { + index--; + } + + if (command === "M") { + current = { x: Number(tokens[index++]), y: Number(tokens[index++]) }; + } else if (command === "L") { + const next = { x: Number(tokens[index++]), y: Number(tokens[index++]) }; + segments.push({ x1: current.x, y1: current.y, x2: next.x, y2: next.y }); + current = next; + } else if (command === "H") { + const next = { x: Number(tokens[index++]), y: current.y }; + segments.push({ x1: current.x, y1: current.y, x2: next.x, y2: next.y }); + current = next; + } else if (command === "V") { + const next = { x: current.x, y: Number(tokens[index++]) }; + segments.push({ x1: current.x, y1: current.y, x2: next.x, y2: next.y }); + current = next; + } + } + + return segments; + }; + const intersects = (segment, rect) => { + const padding = 6; + const left = rect.left + padding; + const right = rect.right - padding; + const top = rect.top + padding; + const bottom = rect.bottom - padding; + const minX = Math.min(segment.x1, segment.x2); + const maxX = Math.max(segment.x1, segment.x2); + const minY = Math.min(segment.y1, segment.y2); + const maxY = Math.max(segment.y1, segment.y2); + + if (segment.y1 === segment.y2) { + return segment.y1 >= top && segment.y1 <= bottom && maxX >= left && minX <= right; + } + + if (segment.x1 === segment.x2) { + return segment.x1 >= left && segment.x1 <= right && maxY >= top && minY <= bottom; + } + + return false; + }; + const boardRect = document.querySelector(".workflow-overview-editor__board").getBoundingClientRect(); + const stepRects = Array.from(document.querySelectorAll(".workflow-overview-editor__step")).map((step) => { + const rect = step.getBoundingClientRect(); + return { + id: step.dataset.workflowStepId, + left: rect.left - boardRect.left, + right: rect.right - boardRect.left, + top: rect.top - boardRect.top, + bottom: rect.bottom - boardRect.top, + }; + }); + + return Array.from(document.querySelectorAll(".workflow-overview-editor__edge-path")).every((path) => { + const endpointStepIds = [path.dataset.workflowSourceStepId, path.dataset.workflowTargetStepId]; + + return parsePathSegments(path).every((segment) => { + return stepRects.every((rect) => endpointStepIds.includes(rect.id) || !intersects(segment, rect)); + }); + }); + })(); + JS + end + + def has_no_overlapping_arrow_segments? + has_css?(".workflow-overview-editor__edge-path") && page.evaluate_script(<<~JS) + (() => { + #{path_geometry_helpers} + + const overlaps = (segment, otherSegment) => { + const rangesOverlap = (start, end, otherStart, otherEnd) => { + const min = Math.min(start, end); + const max = Math.max(start, end); + const otherMin = Math.min(otherStart, otherEnd); + const otherMax = Math.max(otherStart, otherEnd); + return Math.max(min, otherMin) < Math.min(max, otherMax); + }; + + if (segment.y1 === segment.y2 && otherSegment.y1 === otherSegment.y2) { + return segment.y1 === otherSegment.y1 && rangesOverlap(segment.x1, segment.x2, otherSegment.x1, otherSegment.x2); + } + + if (segment.x1 === segment.x2 && otherSegment.x1 === otherSegment.x2) { + return segment.x1 === otherSegment.x1 && rangesOverlap(segment.y1, segment.y2, otherSegment.y1, otherSegment.y2); + } + + return false; + }; + const segments = Array.from(document.querySelectorAll(".workflow-overview-editor__edge-path")).flatMap((path, pathIndex) => { + return parsePathSegments(path).map((segment) => ({ ...segment, pathIndex })); + }); + + return segments.every((segment, index) => { + return segments.slice(index + 1).every((otherSegment) => { + return segment.pathIndex === otherSegment.pathIndex || !overlaps(segment, otherSegment); + }); + }); + })(); + JS + end + + def has_no_double_back_arrow_paths? + has_css?(".workflow-overview-editor__edge-path") && page.evaluate_script(<<~JS) + (() => { + #{path_geometry_helpers} + + const doublesBack = (segments) => { + return segments.slice(1).some((segment, index) => { + const previous = segments[index]; + const bothHorizontal = previous.y1 === previous.y2 && segment.y1 === segment.y2; + const bothVertical = previous.x1 === previous.x2 && segment.x1 === segment.x2; + + if (bothHorizontal && previous.y1 === segment.y1) { + return Math.sign(previous.x2 - previous.x1) !== Math.sign(segment.x2 - segment.x1); + } + + if (bothVertical && previous.x1 === segment.x1) { + return Math.sign(previous.y2 - previous.y1) !== Math.sign(segment.y2 - segment.y1); + } + + return false; + }); + }; + + return Array.from(document.querySelectorAll(".workflow-overview-editor__edge-path")).every((path) => { + return !doublesBack(parsePathSegments(path)); + }); + })(); + JS + end + + def has_arrow_paths_within_lane_stack? + has_css?(".workflow-overview-editor__edge-path") && page.evaluate_script(<<~JS) + (() => { + #{path_geometry_helpers} + + const boardRect = document.querySelector(".workflow-overview-editor__board").getBoundingClientRect(); + const laneBounds = Array.from(document.querySelectorAll(".workflow-overview-editor__lane")).map((lane) => { + const rect = lane.getBoundingClientRect(); + return { + top: rect.top - boardRect.top, + bottom: rect.bottom - boardRect.top, + }; + }); + const top = Math.min(...laneBounds.map((bound) => bound.top)); + const bottom = Math.max(...laneBounds.map((bound) => bound.bottom)); + + return Array.from(document.querySelectorAll(".workflow-overview-editor__edge-path")).every((path) => { + return parsePathSegments(path).every((segment) => { + return segment.y1 >= top && segment.y1 <= bottom && segment.y2 >= top && segment.y2 <= bottom; + }); + }); + })(); + JS + end + + def has_no_arrow_travelling_along_lane_borders? + has_css?(".workflow-overview-editor__edge-path") && page.evaluate_script(<<~JS) + (() => { + #{path_geometry_helpers} + + const boardRect = document.querySelector(".workflow-overview-editor__board").getBoundingClientRect(); + const laneBounds = Array.from(document.querySelectorAll(".workflow-overview-editor__lane")).map((lane) => { + const rect = lane.getBoundingClientRect(); + return { + left: rect.left - boardRect.left, + right: rect.right - boardRect.left, + top: rect.top - boardRect.top, + bottom: rect.bottom - boardRect.top, + }; + }); + const overlaps = (segment, lane) => { + const travelsAlongLaneEdge = Math.abs(segment.y1 - lane.top) <= 2 || + Math.abs(segment.y1 - lane.bottom) <= 2; + + if (segment.y1 !== segment.y2 || !travelsAlongLaneEdge) { + return false; + } + + const segmentLeft = Math.min(segment.x1, segment.x2); + const segmentRight = Math.max(segment.x1, segment.x2); + return Math.min(segmentRight, lane.right) > Math.max(segmentLeft, lane.left); + }; + + return Array.from(document.querySelectorAll(".workflow-overview-editor__edge-path")).every((path) => { + return parsePathSegments(path).every((segment) => { + return laneBounds.every((lane) => !overlaps(segment, lane)); + }); + }); + })(); + JS + end + + def has_crossing_penalty_without_forbidding_routes? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const crossingSegments = [{ x1: 0, y1: 10, x2: 20, y2: 10 }]; + const routedSegments = [{ x1: 10, y1: 0, x2: 10, y2: 20 }]; + const scoreFor = (segments, existingSegments, arrowheadPoints = []) => { + return editor.routeCandidateScore({ + segments, + labelPoint: { x: 20, y: 20 }, + obstacleRects: [], + labelObstacleRects: [], + routedSegments: existingSegments, + routedLabels: [], + arrowheadPoints, + laneStackBounds: null, + sidePenalty: 0, + }); + }; + const scoreWithoutCrossing = scoreFor(crossingSegments, []); + const scoreWithCrossing = scoreFor(crossingSegments, routedSegments); + const scoreWithOverlap = scoreFor( + [{ x1: 0, y1: 10, x2: 20, y2: 10 }], + [{ x1: 10, y1: 10, x2: 30, y2: 10 }] + ); + + return Number.isFinite(scoreWithCrossing) && + scoreWithCrossing > scoreWithoutCrossing && + scoreWithOverlap === Infinity; + })(); + JS + end + + def has_arrowhead_label_penalty? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const segments = [{ x1: 0, y1: 10, x2: 20, y2: 10 }]; + const scoreFor = (arrowheadPoints) => { + return editor.routeCandidateScore({ + segments, + labelPoint: { x: 20, y: 20 }, + obstacleRects: [], + labelObstacleRects: [], + routedSegments: [], + routedLabels: [], + arrowheadPoints, + laneStackBounds: null, + sidePenalty: 0, + }); + }; + const scoreWithoutArrowheadCover = scoreFor([{ x: 300, y: 300 }]); + const scoreWithArrowheadCover = scoreFor([{ x: 20, y: 20 }]); + + return Number.isFinite(scoreWithArrowheadCover) && + scoreWithArrowheadCover > scoreWithoutArrowheadCover; + })(); + JS + end + + def has_connector_line_label_penalty? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const segments = [{ x1: 0, y1: 10, x2: 20, y2: 10 }]; + const scoreFor = (routedSegments) => { + return editor.routeCandidateScore({ + segments, + labelPoint: { x: 20, y: 20 }, + obstacleRects: [], + labelObstacleRects: [], + routedSegments, + routedLabels: [], + arrowheadPoints: [], + laneStackBounds: null, + sidePenalty: 0, + }); + }; + const scoreWithoutLineCover = scoreFor([{ x1: 300, y1: 300, x2: 320, y2: 300 }]); + const scoreWithLineCover = scoreFor([{ x1: 10, y1: 20, x2: 30, y2: 20 }]); + + return Number.isFinite(scoreWithLineCover) && + scoreWithLineCover > scoreWithoutLineCover; + })(); + JS + end + + def has_horizontal_connector_under_dropdown_penalty? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const scoreFor = (routedLabels) => { + return editor.routeCandidateScore({ + segments: [{ x1: 0, y1: 20, x2: 100, y2: 20 }], + labelPoint: { x: 500, y: 500 }, + obstacleRects: [], + labelObstacleRects: [], + routedSegments: [], + routedLabels, + arrowheadPoints: [], + laneStackBounds: null, + sidePenalty: 0, + }); + }; + const scoreWithoutDropdownCover = scoreFor([{ x: 300, y: 300 }]); + const scoreWithDropdownCover = scoreFor([{ x: 50, y: 20 }]); + + return Number.isFinite(scoreWithDropdownCover) && + scoreWithDropdownCover > scoreWithoutDropdownCover; + })(); + JS + end + + def has_label_penalties_restored_for_return_connectors? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const scoreWithLabelPenalties = editor.routeCandidateScore({ + segments: [{ x1: 0, y1: 10, x2: 20, y2: 10 }], + labelPoint: { x: 20, y: 20 }, + obstacleRects: [], + labelObstacleRects: [{ left: 0, right: 40, top: 0, bottom: 40 }], + routedSegments: [{ x1: 10, y1: 20, x2: 30, y2: 20 }], + routedLabels: [], + arrowheadPoints: [{ x: 20, y: 20 }], + laneStackBounds: null, + sidePenalty: 0, + }); + const scoreWithoutLabelPenalties = editor.routeCandidateScore({ + segments: [{ x1: 0, y1: 10, x2: 20, y2: 10 }], + labelPoint: { x: 300, y: 300 }, + obstacleRects: [], + labelObstacleRects: [], + routedSegments: [{ x1: 500, y1: 500, x2: 520, y2: 500 }], + routedLabels: [], + arrowheadPoints: [], + laneStackBounds: null, + sidePenalty: 0, + }); + + return Number.isFinite(scoreWithLabelPenalties) && + scoreWithLabelPenalties > scoreWithoutLabelPenalties; + })(); + JS + end + + def has_own_arrowhead_included_in_label_penalties? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const originalRouteCandidateScore = editor.routeCandidateScore.bind(editor); + let ownArrowheadIncluded = false; + + editor.routeCandidateScore = (options) => { + ownArrowheadIncluded ||= options.arrowheadPoints.some((arrowheadPoint) => { + return arrowheadPoint.x === 20 && arrowheadPoint.y === 10; + }); + + return originalRouteCandidateScore(options); + }; + + editor.routePoints({ + index: 0, + source: { x: 0, y: 10 }, + sourceSide: "right", + target: { x: 20, y: 10 }, + targetSide: "left", + obstacleRects: [], + labelObstacleRects: [], + routedSegments: [], + routedLabels: [], + routedArrowheads: [], + laneStackBounds: null, + sidePenalty: 0, + }); + + return ownArrowheadIncluded; + })(); + JS + end + + def has_other_arrowhead_label_penalty? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const scoreFor = (arrowheadPoints) => { + return editor.routeCandidateScore({ + segments: [{ x1: 0, y1: 10, x2: 20, y2: 10 }], + labelPoint: { x: 20, y: 20 }, + obstacleRects: [], + labelObstacleRects: [], + routedSegments: [], + routedLabels: [], + arrowheadPoints, + laneStackBounds: null, + sidePenalty: 0, + }); + }; + const scoreWithoutArrowheadCover = scoreFor([{ x: 300, y: 300 }]); + const scoreWithArrowheadCover = scoreFor([{ x: 20, y: 20 }]); + + return Number.isFinite(scoreWithArrowheadCover) && + scoreWithArrowheadCover > scoreWithoutArrowheadCover; + })(); + JS + end + + def has_midpoint_label_preference? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const candidates = editor.labelCandidatesForSegments([ + { x1: 20, y1: 0, x2: 20, y2: 90 }, + ]); + + return candidates[0].point.y === 45 && + candidates[0].penalty === 0 && + candidates[1].point.y === 30 && + candidates[1].penalty > 0 && + candidates[2].point.y === 60 && + candidates[2].penalty > 0 && + candidates[3].point.y === 22.5 && + candidates[3].penalty > candidates[1].penalty && + candidates[4].point.y === 67.5 && + candidates[4].penalty > candidates[2].penalty && + candidates[5].point.y === 18 && + candidates[5].penalty > candidates[3].penalty && + candidates[6].point.y === 72 && + candidates[6].penalty > candidates[4].penalty && + candidates[7].point.y === 9 && + candidates[7].penalty > candidates[5].penalty && + candidates[8].point.y === 81 && + candidates[8].penalty > candidates[6].penalty; + })(); + JS + end + + def has_alternate_label_position_can_win? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const route = editor.routePoints({ + index: 0, + source: { x: 0, y: 0 }, + sourceSide: "right", + target: { x: 40, y: 90 }, + targetSide: "left", + obstacleRects: [], + labelObstacleRects: [{ left: 8, right: 32, top: 34, bottom: 56 }], + routedSegments: [], + routedLabels: [], + routedArrowheads: [], + laneStackBounds: null, + sidePenalty: 0, + }); + + return route.label_y !== 45; + })(); + JS + end + + def has_label_lane_boundary_penalty? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const scoreFor = (labelPoint) => { + return editor.routeCandidateScore({ + segments: [{ x1: 0, y1: 50, x2: 100, y2: 50 }], + labelPoint, + obstacleRects: [], + labelObstacleRects: [], + routedSegments: [], + routedLabels: [], + arrowheadPoints: [], + laneStackBounds: { + top: 0, + bottom: 180, + lanes: [ + { + left: 0, + right: 200, + top: 40, + bottom: 180, + labelTop: 40, + labelBottom: 62, + }, + ], + }, + sidePenalty: 0, + }); + }; + const scoreWithoutBoundaryOverlap = scoreFor({ x: 100, y: 100 }); + const scoreWithBoundaryOverlap = scoreFor({ x: 100, y: 180 }); + const scoreWithLaneLabelOverlap = scoreFor({ x: 100, y: 52 }); + + return Number.isFinite(scoreWithBoundaryOverlap) && + scoreWithBoundaryOverlap > scoreWithoutBoundaryOverlap && + scoreWithLaneLabelOverlap > scoreWithBoundaryOverlap; + })(); + JS + end + + def has_lane_gap_travel_penalty? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const laneStackBounds = { + top: 0, + bottom: 220, + lanes: [ + { left: 0, right: 200, top: 0, bottom: 100 }, + { left: 0, right: 200, top: 120, bottom: 220 }, + ], + }; + const penaltyInLane = editor.laneGapTravelPenalty( + [{ x1: 0, y1: 80, x2: 100, y2: 80 }], + laneStackBounds + ); + const penaltyBetweenLanes = editor.laneGapTravelPenalty( + [{ x1: 0, y1: 110, x2: 100, y2: 110 }], + laneStackBounds + ); + const penaltyCrossingGap = editor.laneGapTravelPenalty( + [{ x1: 50, y1: 80, x2: 50, y2: 140 }], + laneStackBounds + ); + + return penaltyInLane === 0 && + penaltyBetweenLanes > 0 && + penaltyCrossingGap === 0; + })(); + JS + end + + def has_turn_count_penalty? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const scoreFor = (segments) => { + return editor.routeCandidateScore({ + segments, + labelPoint: { x: 300, y: 300 }, + obstacleRects: [], + labelObstacleRects: [], + routedSegments: [], + routedLabels: [], + arrowheadPoints: [], + laneStackBounds: null, + sidePenalty: 0, + }); + }; + const simpleRoute = [{ x1: 0, y1: 0, x2: 100, y2: 0 }]; + const sameLengthRouteWithTurns = [ + { x1: 0, y1: 0, x2: 25, y2: 0 }, + { x1: 25, y1: 0, x2: 25, y2: 25 }, + { x1: 25, y1: 25, x2: 75, y2: 25 }, + { x1: 75, y1: 25, x2: 75, y2: 0 }, + { x1: 75, y1: 0, x2: 100, y2: 0 }, + ]; + + return scoreFor(sameLengthRouteWithTurns) > scoreFor(simpleRoute); + })(); + JS + end + + def has_lower_return_route_length_penalty? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const segments = [{ x1: 0, y1: 0, x2: 100, y2: 0 }]; + const scoreFor = (sourcePosition, targetPosition) => { + return editor.routeCandidateScore({ + segments, + labelPoint: { x: 300, y: 300 }, + obstacleRects: [], + labelObstacleRects: [], + routedSegments: [], + routedLabels: [], + arrowheadPoints: [], + laneStackBounds: null, + routeLengthMultiplier: editor.routeLengthMultiplier( + { position: sourcePosition }, + { position: targetPosition } + ), + sidePenalty: 0, + }); + }; + const forwardScore = scoreFor(1, 2); + const returnScore = scoreFor(2, 1); + + return Number.isFinite(returnScore) && + returnScore > 0 && + returnScore < forwardScore; + })(); + JS + end + + def has_lane_escape_gutter_for_connector_handles? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const laneStackBounds = { top: 0, bottom: 100 }; + const insideConnectorGutter = [ + { x1: 0, y1: 130, x2: 100, y2: 130 }, + ]; + const beyondConnectorGutter = [ + { x1: 0, y1: 170, x2: 100, y2: 170 }, + ]; + + return editor.laneEscapePenalty(insideConnectorGutter, laneStackBounds) === 0 && + editor.laneEscapePenalty(beyondConnectorGutter, laneStackBounds) > 0; + })(); + JS + end + + def has_lower_escape_route_candidate? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + const route = editor.routePoints({ + index: 0, + source: { x: 500, y: 100 }, + sourceSide: "bottom", + target: { x: 100, y: 100 }, + targetSide: "bottom", + obstacleRects: [ + { left: 250, right: 350, top: 0, bottom: 150 }, + ], + labelObstacleRects: [], + routedSegments: [], + routedLabels: [], + routedArrowheads: [], + laneStackBounds: { top: 0, bottom: 160 }, + sidePenalty: 0, + }); + + return route.segments.some((segment) => { + return segment.y1 === segment.y2 && segment.y1 > 160; + }); + })(); + JS + end + + def has_global_side_pair_route_scoring? + has_overview? && page.evaluate_script(<<~JS) + (() => { + const moduleName = "discourse/plugins/discourse-workflow/discourse/admin/components/workflow-overview-editor"; + const WorkflowOverviewEditor = window.requirejs(moduleName).default; + const editor = Object.create(WorkflowOverviewEditor.prototype); + + editor.routeBetweenHandles = ({ sourceSide, targetSide }) => { + return { + source_side: sourceSide, + target_side: targetSide, + path: "", + label_x: 0, + label_y: 0, + arrowhead_x: 0, + arrowhead_y: 0, + collided: false, + segments: [], + score: sourceSide === "bottom" ? 10 : 100, + }; + }; + + const route = editor.edgeRoute({ + allRects: new Map([ + [1, { left: 300, right: 420, top: 100, bottom: 160, centerX: 360, centerY: 130 }], + [2, { left: 60, right: 180, top: 100, bottom: 160, centerX: 120, centerY: 130 }], + ]), + index: 0, + sourceRect: { left: 300, right: 420, top: 100, bottom: 160, centerX: 360, centerY: 130 }, + targetRect: { left: 60, right: 180, top: 100, bottom: 160, centerX: 120, centerY: 130 }, + sourceStep: { id: 1, position: 2 }, + targetStep: { id: 2, position: 1 }, + usedEndpoints: new Set(), + routedSegments: [], + routedLabels: [], + routedArrowheads: [], + laneStackBounds: null, + }); + + return route.source_side === "bottom"; + })(); + JS + end + + def has_option_on_longest_vertical_segment?(step_option) + has_css?(option_selector(step_option)) && page.evaluate_script(<<~JS, step_option.id) + (() => { + #{path_geometry_helpers} + + const stepOptionId = arguments[0]; + const path = document.querySelector(`.workflow-overview-editor__edge-path[data-workflow-step-option-id="${stepOptionId}"]`); + const option = document.querySelector(`.workflow-overview-editor__option[data-workflow-step-option-id="${stepOptionId}"]`); + const boardRect = document.querySelector(".workflow-overview-editor__board").getBoundingClientRect(); + const optionRect = option.getBoundingClientRect(); + const optionCenter = { + x: optionRect.left - boardRect.left + optionRect.width / 2, + y: optionRect.top - boardRect.top + optionRect.height / 2, + }; + const verticalSegments = parsePathSegments(path).filter((segment) => segment.x1 === segment.x2); + + if (!verticalSegments.length) { + return true; + } + + const longest = verticalSegments.reduce((current, segment) => { + const currentLength = Math.abs(current.y2 - current.y1); + const segmentLength = Math.abs(segment.y2 - segment.y1); + return segmentLength > currentLength ? segment : current; + }, verticalSegments[0]); + const expected = { + x: longest.x1, + y: (longest.y1 + longest.y2) / 2, + }; + + return Math.abs(optionCenter.x - expected.x) <= 3 && Math.abs(optionCenter.y - expected.y) <= 3; + })(); + JS + end + + def has_no_overlapping_option_dropdowns? + has_css?(".workflow-overview-editor__option") && page.evaluate_script(<<~JS) + (() => { + const rects = Array.from(document.querySelectorAll(".workflow-overview-editor__option")).map((option) => { + const rect = option.getBoundingClientRect(); + return { + left: rect.left, + right: rect.right, + top: rect.top, + bottom: rect.bottom, + }; + }); + const overlaps = (rect, otherRect) => { + return Math.max(rect.left, otherRect.left) < Math.min(rect.right, otherRect.right) && + Math.max(rect.top, otherRect.top) < Math.min(rect.bottom, otherRect.bottom); + }; + + return rects.every((rect, index) => { + return rects.slice(index + 1).every((otherRect) => !overlaps(rect, otherRect)); + }); + })(); + JS + end + + def has_no_option_dropdown_over_step_boxes? + has_css?(".workflow-overview-editor__option") && page.evaluate_script(<<~JS) + (() => { + const overlaps = (rect, otherRect) => { + return Math.max(rect.left, otherRect.left) < Math.min(rect.right, otherRect.right) && + Math.max(rect.top, otherRect.top) < Math.min(rect.bottom, otherRect.bottom); + }; + const optionRects = Array.from(document.querySelectorAll(".workflow-overview-editor__option")).map((option) => { + return option.getBoundingClientRect(); + }); + const stepRects = Array.from(document.querySelectorAll(".workflow-overview-editor__step")).map((step) => { + return step.getBoundingClientRect(); + }); + + return optionRects.every((optionRect) => { + return stepRects.every((stepRect) => !overlaps(optionRect, stepRect)); + }); + })(); + JS + end + + def has_no_option_dropdown_over_step_boxes_for?(step_option) + has_css?(option_selector(step_option)) && page.evaluate_script(<<~JS, step_option.id) + (() => { + const stepOptionId = arguments[0]; + const overlaps = (rect, otherRect) => { + return Math.max(rect.left, otherRect.left) < Math.min(rect.right, otherRect.right) && + Math.max(rect.top, otherRect.top) < Math.min(rect.bottom, otherRect.bottom); + }; + const option = document.querySelector(`.workflow-overview-editor__option[data-workflow-step-option-id="${stepOptionId}"]`); + const optionRect = option.getBoundingClientRect(); + const stepRects = Array.from(document.querySelectorAll(".workflow-overview-editor__step")).map((step) => { + return step.getBoundingClientRect(); + }); + + return stepRects.every((stepRect) => !overlaps(optionRect, stepRect)); + })(); + JS + end + + def has_forward_arrow_from_right_edge?(step_option) + has_css?( + ".workflow-overview-editor__edge-path[data-workflow-step-option-id='#{step_option.id}'][data-workflow-source-side='right']", + ) + end + + def drag_connector(source_step, target_step) + drag(unconnected_handle_selector(source_step), unconnected_handle_selector(target_step)) + self + end + + def drag_existing_connector_target(current_target_step, new_target_step) + drag( + connected_handle_selector(current_target_step), + unconnected_handle_selector(new_target_step), + ) + self + end + + def drag_step_to_lane(step, category) + drag(step_selector(step), lane_selector(category)) + self + end + + def drag_step_to_lane_position(step, category, position) + drag(step_selector(step), position_selector(category, position)) + self + end + + def fill_new_step_name(name) + find(".workflow-overview-editor__new-step-name").fill_in(with: name) + self + end + + def choose_new_step_category(category) + PageObjects::Components::SelectKit.new( + ".workflow-overview-editor__add-step .category-chooser", + ).select_row_by_value(category.id) + self + end + + def add_step + click_button "Add step" + self + end + + def select_option(step_option, workflow_option) + find( + "#{option_selector(step_option)} select[data-workflow-step-option-id='#{step_option.id}']", + ).find("option[value='#{workflow_option.id}']").select_option + self + end + + def delete_option(step_option) + find("#{option_selector(step_option)} .workflow-overview-editor__delete-option").click + self + end + + def has_delete_connector_confirmation? + has_css?( + ".dialog-body", + text: "Are you sure you want to permanently delete this connector?", + ) + end + + def confirm_delete_connector + find(".dialog-footer .btn-danger").click + self + end + + private + + def step_selector(step) + ".workflow-overview-editor__step[data-workflow-step-id='#{step.id}']" + end + + def lane_selector(category) + ".workflow-overview-editor__lane[data-workflow-category-id='#{category.id}']" + end + + def position_selector(category, position) + ".workflow-overview-editor__position-slot[data-workflow-category-id='#{category.id}'][data-workflow-position='#{position}']" + end + + def option_selector(step_option) + ".workflow-overview-editor__option[data-workflow-step-option-id='#{step_option.id}']" + end + + def connector_handle_selector(step, side) + "#{step_selector(step)} .workflow-overview-editor__connector-handle--#{side}" + end + + def connected_handle_selector(step) + "#{step_selector(step)} .workflow-overview-editor__connector-handle--connected" + end + + def unconnected_handle_selector(step) + "#{step_selector(step)} .workflow-overview-editor__connector-handle:not(.workflow-overview-editor__connector-handle--connected)" + end + + def path_geometry_helpers + <<~JS + const parsePathSegments = (path) => { + const tokens = path.getAttribute("d").match(/[MLHV]|-?\\d+(?:\\.\\d+)?/g) || []; + const segments = []; + let index = 0; + let command = null; + let current = { x: 0, y: 0 }; + + while (index < tokens.length) { + const token = tokens[index++]; + if (["M", "L", "H", "V"].includes(token)) { + command = token; + } else { + index--; + } + + if (command === "M") { + current = { x: Number(tokens[index++]), y: Number(tokens[index++]) }; + } else if (command === "L") { + const next = { x: Number(tokens[index++]), y: Number(tokens[index++]) }; + segments.push({ x1: current.x, y1: current.y, x2: next.x, y2: next.y }); + current = next; + } else if (command === "H") { + const next = { x: Number(tokens[index++]), y: current.y }; + segments.push({ x1: current.x, y1: current.y, x2: next.x, y2: next.y }); + current = next; + } else if (command === "V") { + const next = { x: current.x, y: Number(tokens[index++]) }; + segments.push({ x1: current.x, y1: current.y, x2: next.x, y2: next.y }); + current = next; + } + } + + return segments; + }; + JS + end + + def drag(source_selector, target_selector) + page.execute_script(<<~JS, source_selector, target_selector) + const source = document.querySelector(arguments[0]); + const target = document.querySelector(arguments[1]); + const dataTransfer = new DataTransfer(); + const sourceRect = source.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + + source.dispatchEvent( + new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + dataTransfer, + clientX: sourceRect.left + sourceRect.width / 2, + clientY: sourceRect.top + sourceRect.height / 2, + }) + ); + + target.dispatchEvent( + new DragEvent("dragover", { + bubbles: true, + cancelable: true, + dataTransfer, + clientX: targetRect.left + targetRect.width / 2, + clientY: targetRect.top + targetRect.height / 2, + }) + ); + + target.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer, + clientX: targetRect.left + targetRect.width / 2, + clientY: targetRect.top + targetRect.height / 2, + }) + ); + + source.dispatchEvent( + new DragEvent("dragend", { + bubbles: true, + cancelable: true, + dataTransfer, + }) + ); + JS + end + end + end +end diff --git a/spec/system/workflow_admin_overview_spec.rb b/spec/system/workflow_admin_overview_spec.rb new file mode 100644 index 0000000..a7adff3 --- /dev/null +++ b/spec/system/workflow_admin_overview_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require_relative "../plugin_helper" + +RSpec.describe "Workflow admin overview" do + let(:overview_page) { PageObjects::Pages::WorkflowAdminOverview.new } + + fab!(:admin) + fab!(:workflow) { Fabricate(:workflow, name: "Overview workflow") } + fab!(:workflow_parent_category, :category) + fab!(:review_category) { Fabricate(:category, parent_category_id: workflow_parent_category.id) } + fab!(:done_category) { Fabricate(:category, parent_category_id: workflow_parent_category.id) } + fab!(:unused_category) { Fabricate(:category, parent_category_id: workflow_parent_category.id) } + fab!(:queue_step) do + Fabricate( + :workflow_step, + workflow_id: workflow.id, + category_id: review_category.id, + position: 1, + name: "Queue", + ) + end + fab!(:review_step) do + Fabricate( + :workflow_step, + workflow_id: workflow.id, + category_id: done_category.id, + position: 2, + name: "Review", + ) + end + fab!(:done_step) do + Fabricate( + :workflow_step, + workflow_id: workflow.id, + category_id: done_category.id, + position: 3, + name: "Done", + ) + end + fab!(:next_option) { Fabricate(:workflow_option, slug: "next", name: "Next") } + fab!(:back_option) { Fabricate(:workflow_option, slug: "back", name: "Back") } + fab!(:queue_to_done_option) do + Fabricate( + :workflow_step_option, + workflow_step_id: queue_step.id, + workflow_option_id: next_option.id, + target_step_id: done_step.id, + position: 1, + ) + end + + before { sign_in(admin) } + + it "shows a list and overview tab for workflow step editing" do + overview_page.visit_workflow(workflow) + + expect(overview_page).to have_steps_tab("List") + expect(overview_page).to have_steps_tab("Overview") + + overview_page.switch_to_overview + + expect(overview_page).to have_overview + expect(overview_page).to have_no_new_arrow_option_control + expect(overview_page).to have_step(queue_step, text: "Queue") + expect(overview_page).to have_connector_handles(queue_step) + expect(overview_page).to have_lane(done_category, text: done_category.name) + expect(overview_page).to have_lane(unused_category, text: unused_category.name) + end + + it "routes arrows around intermediate step boxes" do + Fabricate( + :workflow_step_option, + workflow_step_id: review_step.id, + workflow_option_id: back_option.id, + target_step_id: queue_step.id, + position: 1, + ) + Fabricate( + :workflow_step_option, + workflow_step_id: done_step.id, + workflow_option_id: back_option.id, + target_step_id: review_step.id, + position: 1, + ) + + overview_page.visit_workflow(workflow).switch_to_overview + + expect(overview_page).to have_arrow_link_for_option(queue_to_done_option) + expect(overview_page).to have_no_arrow_crossing_step_boxes + expect(overview_page).to have_no_double_back_arrow_paths + expect(overview_page).to have_arrow_paths_within_lane_stack + expect(overview_page).to have_no_arrow_travelling_along_lane_borders + expect(overview_page).to have_lane_gap_travel_penalty + expect(overview_page).to have_no_overlapping_arrow_segments + expect(overview_page).to have_no_overlapping_option_dropdowns + expect(overview_page).to have_no_option_dropdown_over_step_boxes_for(queue_to_done_option) + expect(overview_page).to have_crossing_penalty_without_forbidding_routes + expect(overview_page).to have_arrowhead_label_penalty + expect(overview_page).to have_connector_line_label_penalty + expect(overview_page).to have_horizontal_connector_under_dropdown_penalty + expect(overview_page).to have_label_penalties_restored_for_return_connectors + expect(overview_page).to have_own_arrowhead_included_in_label_penalties + expect(overview_page).to have_other_arrowhead_label_penalty + expect(overview_page).to have_midpoint_label_preference + expect(overview_page).to have_alternate_label_position_can_win + expect(overview_page).to have_label_lane_boundary_penalty + expect(overview_page).to have_turn_count_penalty + expect(overview_page).to have_lower_return_route_length_penalty + expect(overview_page).to have_lane_escape_gutter_for_connector_handles + expect(overview_page).to have_lower_escape_route_candidate + expect(overview_page).to have_global_side_pair_route_scoring + expect(overview_page).to have_option_on_longest_vertical_segment(queue_to_done_option) + expect(overview_page).to have_forward_arrow_from_right_edge(queue_to_done_option) + end + + it "creates and retargets arrows by dragging between step cards" do + overview_page.visit_workflow(workflow).switch_to_overview + + overview_page.drag_connector(queue_step, review_step) + + expect(overview_page).to have_any_option_control + step_option = + DiscourseWorkflow::WorkflowStepOption.find_by!( + workflow_step_id: queue_step.id, + target_step_id: review_step.id, + ) + expect(step_option.target_step_id).to eq(review_step.id) + expect(overview_page).to have_arrow_link_for_option(step_option) + expect(overview_page).to have_only_orthogonal_arrow_paths + expect(overview_page).to have_forward_arrow_path + + overview_page.drag_existing_connector_target(review_step, done_step) + + expect(overview_page).to have_option(step_option) + expect(step_option.reload.target_step_id).to eq(done_step.id) + end + + it "updates arrow option labels from the connector dropdown" do + overview_page.visit_workflow(workflow).switch_to_overview + + overview_page.select_option(queue_to_done_option, back_option) + + expect(queue_to_done_option.reload.workflow_option_id).to eq(back_option.id) + end + + it "deletes arrows from the connector option control after confirmation" do + overview_page.visit_workflow(workflow).switch_to_overview + + overview_page.delete_option(queue_to_done_option) + + expect(overview_page).to have_delete_connector_confirmation + + overview_page.confirm_delete_connector + + expect(DiscourseWorkflow::WorkflowStepOption.exists?(queue_to_done_option.id)).to eq(false) + expect(overview_page).to have_no_css( + ".workflow-overview-editor__edge-path[data-workflow-step-option-id='#{queue_to_done_option.id}']", + ) + expect(overview_page).to have_no_css( + ".workflow-overview-editor__option[data-workflow-step-option-id='#{queue_to_done_option.id}']", + ) + end + + it "moves steps between swimlanes by dragging cards" do + overview_page.visit_workflow(workflow).switch_to_overview + + overview_page.drag_step_to_lane_position(queue_step, done_category, 1) + + expect(overview_page).to have_step_in_lane(queue_step, done_category) + expect(overview_page).to have_lane(review_category, text: review_category.name) + expect(queue_step.reload.category_id).to eq(done_category.id) + end + + it "moves steps into explicit x-axis positions" do + overview_page.visit_workflow(workflow).switch_to_overview + + overview_page.drag_step_to_lane_position(done_step, review_category, 1) + + expect(overview_page).to have_step_in_lane_position(done_step, review_category, 1) + expect(done_step.reload.category_id).to eq(review_category.id) + expect(done_step.position).to eq(1) + expect(queue_step.reload.position).to eq(3) + end + + it "adds a step from the overview builder" do + overview_page.visit_workflow(workflow).switch_to_overview + + overview_page.fill_new_step_name("QA").choose_new_step_category(review_category).add_step + + step = DiscourseWorkflow::WorkflowStep.find_by!(workflow_id: workflow.id, name: "QA") + expect(step.category_id).to eq(review_category.id) + expect(step.position).to eq(4) + expect(overview_page).to have_step(step, text: "QA") + end +end From 493329c63a6470602d59f0deb3cf46c8c082a9bd Mon Sep 17 00:00:00 2001 From: merefield Date: Wed, 13 May 2026 23:48:46 +0100 Subject: [PATCH 02/22] WIP: refine graphical edit --- .../admin/workflow_steps_controller.rb | 16 ++- .../components/workflow-overview-editor.gjs | 125 ++++++++++++------ .../stylesheets/common/workflow_common.scss | 21 ++- config/locales/client.en.yml | 2 + .../admin/workflow_steps_controller_spec.rb | 33 +++++ .../pages/workflow_admin_overview.rb | 55 +++++++- spec/system/workflow_admin_overview_spec.rb | 40 ++++++ 7 files changed, 245 insertions(+), 47 deletions(-) diff --git a/app/controllers/discourse_workflow/admin/workflow_steps_controller.rb b/app/controllers/discourse_workflow/admin/workflow_steps_controller.rb index 1b3dad4..8dd5714 100644 --- a/app/controllers/discourse_workflow/admin/workflow_steps_controller.rb +++ b/app/controllers/discourse_workflow/admin/workflow_steps_controller.rb @@ -88,10 +88,18 @@ def update end def destroy - if @workflow_step.destroy - head :no_content - else - render_json_error @workflow_step + WorkflowStep.transaction do + WorkflowStepOption + .where(workflow_step_id: @workflow_step.id) + .or(WorkflowStepOption.where(target_step_id: @workflow_step.id)) + .destroy_all + + if @workflow_step.destroy + head :no_content + else + render_json_error @workflow_step + raise ActiveRecord::Rollback + end end end diff --git a/assets/javascripts/discourse/admin/components/workflow-overview-editor.gjs b/assets/javascripts/discourse/admin/components/workflow-overview-editor.gjs index 629701a..7c592ff 100644 --- a/assets/javascripts/discourse/admin/components/workflow-overview-editor.gjs +++ b/assets/javascripts/discourse/admin/components/workflow-overview-editor.gjs @@ -284,6 +284,33 @@ export default class WorkflowOverviewEditor extends Component { ); } + @action + confirmDeleteStep(step) { + if (this.args.disabled) { + return; + } + + return this.dialog.confirm({ + message: i18n( + "admin.discourse_workflow.workflows.overview.confirm_delete_step" + ), + confirmButtonClass: "btn-danger", + confirmButtonLabel: + "admin.discourse_workflow.workflows.overview.delete_step", + didConfirm: async () => { + try { + await ajax( + `/admin/plugins/discourse-workflow/workflow_steps/${step.id}.json`, + { type: "DELETE" } + ); + await this.loadGraph(); + } catch (err) { + popupAjaxError(err); + } + }, + }); + } + updateStepOption(stepOption, attributes) { return ajax( `/admin/plugins/discourse-workflow/workflow_step_options/${stepOption.id}.json`, @@ -384,12 +411,17 @@ export default class WorkflowOverviewEditor extends Component { const headerRect = lane .querySelector(".workflow-overview-editor__lane-header") ?.getBoundingClientRect(); + const contentRect = lane + .querySelector(".workflow-overview-editor__lane-steps") + ?.getBoundingClientRect(); return { left: rect.left - boardRect.left, right: rect.right - boardRect.left, top: rect.top - boardRect.top, bottom: rect.bottom - boardRect.top, + contentLeft: (contentRect || rect).left - boardRect.left, + contentRight: (contentRect || rect).right - boardRect.left, labelTop: (headerRect || rect).top - boardRect.top, labelBottom: (headerRect || rect).bottom - boardRect.top, }; @@ -902,7 +934,8 @@ export default class WorkflowOverviewEditor extends Component { return ( this.routeLength(segments) * routeLengthMultiplier + - this.routeTurnCount(segments) * 45 + + this.shortSegmentPenalty(segments) + + this.routeTurnCount(segments) * 90 + this.segmentCrossingCount(segments, routedSegments) * 2000 + this.horizontalSegmentLabelPenalty(segments, routedLabels) + this.labelPenalty({ @@ -961,9 +994,10 @@ export default class WorkflowOverviewEditor extends Component { return lanePenalty; } + const laneLeft = lane.contentLeft ?? lane.left; + const laneRight = lane.contentRight ?? lane.right; const overlap = - Math.min(segmentRight, lane.right) - - Math.max(segmentLeft, lane.left); + Math.min(segmentRight, laneRight) - Math.max(segmentLeft, laneLeft); return overlap > 0 ? lanePenalty + overlap * 80 : lanePenalty; }, 0) @@ -997,9 +1031,15 @@ export default class WorkflowOverviewEditor extends Component { return gapPenalty; } + const previousLaneLeft = + previousLane.contentLeft ?? previousLane.left; + const previousLaneRight = + previousLane.contentRight ?? previousLane.right; + const laneLeft = lane.contentLeft ?? lane.left; + const laneRight = lane.contentRight ?? lane.right; const overlap = - Math.min(segmentRight, previousLane.right, lane.right) - - Math.max(segmentLeft, previousLane.left, lane.left); + Math.min(segmentRight, previousLaneRight, laneRight) - + Math.max(segmentLeft, previousLaneLeft, laneLeft); return overlap > 0 ? gapPenalty + overlap * 140 : gapPenalty; }, 0) @@ -1012,7 +1052,7 @@ export default class WorkflowOverviewEditor extends Component { return 0; } - const connectorGutter = 48; + const connectorGutter = 12; const upperLimit = laneStackBounds.top - connectorGutter; const lowerLimit = laneStackBounds.bottom + connectorGutter; @@ -1024,15 +1064,26 @@ export default class WorkflowOverviewEditor extends Component { Math.max(0, segment.y1 - lowerLimit) + Math.max(0, segment.y2 - lowerLimit); - return penalty + (escapedAbove + escapedBelow) * 240; + return penalty + (escapedAbove + escapedBelow) * 960; }, 0); } + optionLabelRect(labelPoint) { + const halfWidth = 80; + + return { + left: labelPoint.x - halfWidth, + right: labelPoint.x + halfWidth, + top: labelPoint.y - 20, + bottom: labelPoint.y + 20, + }; + } + labelCollisionPenalty(labelPoint, routedLabels) { return routedLabels.reduce((penalty, routedLabel) => { const horizontalDistance = Math.abs(labelPoint.x - routedLabel.x); const verticalDistance = Math.abs(labelPoint.y - routedLabel.y); - const likelyHorizontalOverlap = horizontalDistance < 240; + const likelyHorizontalOverlap = horizontalDistance < 160; if (likelyHorizontalOverlap && verticalDistance < 40) { return Infinity; @@ -1047,12 +1098,7 @@ export default class WorkflowOverviewEditor extends Component { } labelObstaclePenalty(labelPoint, obstacleRects) { - const labelRect = { - left: labelPoint.x - 120, - right: labelPoint.x + 120, - top: labelPoint.y - 20, - bottom: labelPoint.y + 20, - }; + const labelRect = this.optionLabelRect(labelPoint); return obstacleRects.reduce((penalty, rect) => { const overlaps = @@ -1066,12 +1112,7 @@ export default class WorkflowOverviewEditor extends Component { } labelArrowheadPenalty(labelPoint, arrowheadPoints) { - const labelRect = { - left: labelPoint.x - 120, - right: labelPoint.x + 120, - top: labelPoint.y - 20, - bottom: labelPoint.y + 20, - }; + const labelRect = this.optionLabelRect(labelPoint); return arrowheadPoints.reduce((penalty, arrowheadPoint) => { const covered = @@ -1085,12 +1126,7 @@ export default class WorkflowOverviewEditor extends Component { } labelSegmentPenalty(labelPoint, routedSegments) { - const labelRect = { - left: labelPoint.x - 120, - right: labelPoint.x + 120, - top: labelPoint.y - 20, - bottom: labelPoint.y + 20, - }; + const labelRect = this.optionLabelRect(labelPoint); return routedSegments.reduce((penalty, segment) => { const segmentIntersectsLabel = @@ -1106,12 +1142,7 @@ export default class WorkflowOverviewEditor extends Component { return 0; } - const labelRect = { - left: labelPoint.x - 120, - right: labelPoint.x + 120, - top: labelPoint.y - 20, - bottom: labelPoint.y + 20, - }; + const labelRect = this.optionLabelRect(labelPoint); return laneStackBounds.lanes.reduce((penalty, lane) => { const overlapsTop = @@ -1147,12 +1178,7 @@ export default class WorkflowOverviewEditor extends Component { return ( penalty + routedLabels.reduce((labelPenalty, routedLabel) => { - const labelRect = { - left: routedLabel.x - 120, - right: routedLabel.x + 120, - top: routedLabel.y - 20, - bottom: routedLabel.y + 20, - }; + const labelRect = this.optionLabelRect(routedLabel); return this.horizontalSegmentIntersectsRect(segment, labelRect) ? labelPenalty + 10_000 @@ -1228,6 +1254,21 @@ export default class WorkflowOverviewEditor extends Component { }, 0); } + shortSegmentPenalty(segments) { + const minimumSegmentLength = 24; + + return segments.reduce((penalty, segment) => { + const segmentLength = + Math.abs(segment.x2 - segment.x1) + Math.abs(segment.y2 - segment.y1); + + if (segmentLength >= minimumSegmentLength) { + return penalty; + } + + return penalty + (minimumSegmentLength - segmentLength) * 12; + }, 0); + } + routeTurnCount(segments) { return segments.slice(1).filter((segment, index) => { const previous = segments[index]; @@ -2225,6 +2266,14 @@ export default class WorkflowOverviewEditor extends Component { {{on "dragover" this.allowDrop}} {{on "drop" (fn this.dropOnStep step)}} > + + {{#each this.connectorSides as |side|}}