diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index f296340..34b4fe2 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -40,10 +40,6 @@ site-sidebar {
/* For jade, light on dark looks better */
--success-color: #70FCBA; /* Pico jade 100 */
--success-bg: #015234; /* Pico jade 700 */
- --backlog-color: #424751; /* Pico zinc 700 */
- --backlog-bg: #E0E3E7; /* Pico zinc 100 */
- --analysis-color: #5B4200; /* Pico amber 700 */
- --analysis-bg: #FDDEA6; /* Pico amber 100 */
--info-color: #014C75; /* Pico azure 700 */
--info-bg: #D1E5FB; /* Pico azure 100 */
}
@@ -65,20 +61,31 @@ form {
}
}
-.badge {
- font-size: 0.875em;
- padding: 0.25em 0.5em;
- border: 1px solid var(--border-color, currentColor);
- color: var(--color, currentColor);
- background-color: var(--background-color, transparent);
- border-radius: 1em / 0.5em;
- font-weight: 500;
- text-transform: uppercase;
-}
-
table {
/* TODO: maybe extract into a separate file */
details.dropdown {
margin: 0;
}
}
+
+a[target=_blank]::after {
+ content: url("mingcute/external_link_line.svg");
+ display: inline-block;
+ scale: 0.7;
+ transform-origin: 0 50%;
+}
+
+.row {
+ display: flex;
+ flex-flow: row nowrap;
+ align-items: baseline;
+ gap: 1em;
+
+ > * {
+ margin: 0;
+ }
+
+ > .right {
+ margin-left: auto;
+ }
+}
diff --git a/app/assets/stylesheets/badge.css b/app/assets/stylesheets/badge.css
new file mode 100644
index 0000000..75c80dd
--- /dev/null
+++ b/app/assets/stylesheets/badge.css
@@ -0,0 +1,13 @@
+.badge {
+ font-size: 0.875em;
+ border-radius: 1em;
+
+ display: inline-flex;
+ gap: 0.25em;
+ min-width: 2em;
+ padding: 0.1em 0.5em;
+
+ font-weight: 500;
+ text-transform: uppercase;
+
+}
diff --git a/app/assets/stylesheets/mask-icon.css b/app/assets/stylesheets/mask-icon.css
new file mode 100644
index 0000000..f82b5b6
--- /dev/null
+++ b/app/assets/stylesheets/mask-icon.css
@@ -0,0 +1,13 @@
+.mask-icon {
+ display: inline-block;
+ background-color: var(--icon-color, currentColor);
+ mask-image: var(--icon);
+ mask-size: 100%;
+
+ height: 1lh;
+ width: 1lh;
+ transform: translateY(0.3em); /* Looks better when inline */
+ .badge & {
+ transform: unset;
+ }
+}
diff --git a/app/assets/stylesheets/project_admin/workflows.css b/app/assets/stylesheets/project_admin/workflows.css
new file mode 100644
index 0000000..9d94468
--- /dev/null
+++ b/app/assets/stylesheets/project_admin/workflows.css
@@ -0,0 +1,15 @@
+.workflow-statuses-batch-update-fieldsets {
+ display: grid;
+ grid-template-columns: repeat(3, auto) min-content;
+ gap: 0.5em;
+
+ > .fieldset {
+ display: grid;
+ grid-column: 1 / -1;
+ grid-template-columns: subgrid;
+
+ .buttons {
+ align-items: flex-end;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/tabs.css b/app/assets/stylesheets/tabs.css
new file mode 100644
index 0000000..0cb98ad
--- /dev/null
+++ b/app/assets/stylesheets/tabs.css
@@ -0,0 +1,28 @@
+.tabs {
+ padding-inline: 0;
+
+ li {
+ list-style: none;
+ margin: 0;
+ }
+
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+ gap: 0.25em;
+
+ a {
+ text-decoration: none;
+ display: inline-block;
+ padding: 0.25em 0.5em;
+ border: 1px solid gray;
+ border-radius: 0.5em;
+
+ transition: background-color 0.2s, color 0.2s;
+
+ &.active, &:hover, &:focus {
+ background-color: var(--pico-primary);
+ color: white;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/tasks.css b/app/assets/stylesheets/tasks.css
index 3c5a50f..6396fc6 100644
--- a/app/assets/stylesheets/tasks.css
+++ b/app/assets/stylesheets/tasks.css
@@ -6,26 +6,48 @@
}
.task-status {
- --color: var(--backlog-color);
- --background-color: var(--backlog-bg);
+ --color: #424751; /* Pico zinc 700 */
+ --background-color: #E0E3E7; /* Pico zinc 100 */
- &.analysis {
- --color: var(--analysis-color);
- --background-color: var(--analysis-bg);
+ &.badge {
+ border: 2px solid var(--border-color, currentColor);
+ color: var(--color, currentColor);
+ background-color: var(--background-color);
}
- &.development {
- --color: var(--info-color);
- --background-color: var(--info-bg);
+ &.blue {
+ --color: #1D59D0; /* Pico blue 600 */
+ --background-color: #E0E1FA/* Pico blue 100 */
}
- &.fulfillment {
+ &.yellow {
+ --background-color: #FDDEA6; /* Pico amber 100 */
+ --border-color: #A77C00; /* Pico amber 450 */
+ }
+
+ &.green {
--color: var(--success-color);
--background-color: var(--success-bg);
}
+
+ &.purple {
+ --color: #9236A4; /* Pico purple 600 */
+ --background-color: #F2DCF4; /* Pico purple 100 */
+ }
+
+ &.pink {
+ --color: #B21E4F; /* Pico pink 600 */
+ --background-color: #F9DBDF; /* Pico pink 100 */
+ }
}
.task-show-info {
display: flex;
flex-flow: row nowrap;
+ align-items: center;
+ gap: 0.5em;
+
+ > * {
+ margin: 0;
+ }
}
diff --git a/app/assets/stylesheets/workflows.css b/app/assets/stylesheets/workflows.css
new file mode 100644
index 0000000..d5c9f4d
--- /dev/null
+++ b/app/assets/stylesheets/workflows.css
@@ -0,0 +1,29 @@
+.workflow {
+ --color: #424751; /* Pico zinc 700 */
+ --icon: url("mingcute/task_line.svg");
+
+ &.badge {
+ outline: 2px solid var(--color);
+ color: var(--color);
+ }
+
+ > .mask-icon {
+ --icon-color: var(--color);
+ }
+
+ &.red {
+ --color: #BD3C13; /* Pico orange 550 */
+ }
+
+ &.blue {
+ --color: #1D59D0; /* Pico blue 600 */
+ }
+
+ &.lime {
+ --color: #628100; /* Pico lime 500 */
+ }
+
+ &.teal {
+ --color: #047878; /* Pico cyan 550 */
+ }
+}
diff --git a/app/controllers/project_admin/application_controller.rb b/app/controllers/project_admin/application_controller.rb
new file mode 100644
index 0000000..9e12f3a
--- /dev/null
+++ b/app/controllers/project_admin/application_controller.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module ProjectAdmin
+ class ApplicationController < ::ApplicationController
+ before_action :fetch_project
+
+ private
+
+ def fetch_project
+ @project = Project.find_by!(code: params[:project_id])
+ self.current_project = @project
+ end
+ end
+end
diff --git a/app/controllers/project_admin/workflows/statuses_controller.rb b/app/controllers/project_admin/workflows/statuses_controller.rb
new file mode 100644
index 0000000..6ba9922
--- /dev/null
+++ b/app/controllers/project_admin/workflows/statuses_controller.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module ProjectAdmin
+ module Workflows
+ class StatusesController < ApplicationController
+ before_action :fetch_workflow
+
+ def edit
+ @form = ProjectAdmin::Workflows::Statuses::BatchUpdate.from_model(@workflow)
+ end
+
+ def batch_update
+ form_params = params.expect(workflow: { task_statuses_attributes: [%i[id _destroy name color icon]] })
+
+ if form_params[:task_statuses_attributes].respond_to?(:keys)
+ form_params[:task_statuses_attributes] = form_params[:task_statuses_attributes].values
+ end
+
+ @form = ProjectAdmin::Workflows::Statuses::BatchUpdate.new(form_params)
+ if @form.perform(@workflow)
+ redirect_to(action: :edit_transitions)
+ else
+ render :edit
+ end
+ end
+
+ def edit_transitions
+ @form = ProjectAdmin::Workflows::Statuses::UpdateTransitions.from_model(@workflow)
+ end
+
+ def batch_update_transitions
+ form_params = params.expect(workflow: [:default_status_id,
+ { task_statuses_attributes: [[:id, { next_status_ids: [] }]] }])
+ if form_params[:task_statuses_attributes].respond_to?(:keys)
+ form_params[:task_statuses_attributes] = form_params[:task_statuses_attributes].values
+ end
+
+ @form = ProjectAdmin::Workflows::Statuses::UpdateTransitions.new(form_params)
+ if @form.perform(@workflow)
+ redirect_to project_admin_workflow_path(@project, @workflow)
+ else
+ render :edit_transitions
+ end
+ end
+
+ private
+
+ def fetch_workflow
+ @workflow = @project.workflows.find(params[:workflow_id])
+ end
+ end
+ end
+end
diff --git a/app/controllers/project_admin/workflows_controller.rb b/app/controllers/project_admin/workflows_controller.rb
new file mode 100644
index 0000000..6081385
--- /dev/null
+++ b/app/controllers/project_admin/workflows_controller.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module ProjectAdmin
+ class WorkflowsController < ApplicationController
+ before_action :fetch_workflow, only: %w[show edit update destroy]
+
+ def index
+ @workflows = @project.workflows
+ end
+
+ def show
+ # TODO: add loading statuses and other things
+ end
+
+ def new
+ @workflow = @project.workflows.build
+ end
+
+ def create
+ @workflow = @project.workflows.build(workflow_params)
+ if @workflow.save
+ redirect_to project_admin_workflow_path(@project, @workflow)
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit; end
+
+ def update
+ if @workflow.update(workflow_params)
+ redirect_to project_admin_workflow_path(@project, @workflow)
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ end
+
+ private
+
+ def fetch_workflow
+ @workflow = @project.workflows.find(params[:id])
+ end
+
+ def workflow_params
+ params.expect(workflow: %i[name color icon])
+ end
+ end
+end
diff --git a/app/controllers/tasks_controller.rb b/app/controllers/tasks_controller.rb
index 05e61d6..964947e 100644
--- a/app/controllers/tasks_controller.rb
+++ b/app/controllers/tasks_controller.rb
@@ -3,6 +3,8 @@
class TasksController < ApplicationController
before_action :fetch_task, only: %w[show edit update delete change_status]
+ helper_method :workflows_for_tasks
+
def index
self.current_project = fetch_project
@tasks = if current_project
@@ -11,18 +13,20 @@ class TasksController < ApplicationController
Task.all
end
- @tasks = @tasks.includes(:status, project: :task_statuses)
+ @tasks = @tasks.includes(:project, workflow: %i[task_statuses default_status], status: :next_statuses)
end
def show; end
def new
- @project = fetch_project || Project.order(:name).first
- @form = Tasks::Create.new(project_id: @project.id)
+ @project = self.current_project = fetch_project || Project.order(:name).first
+ @workflow = fetch_workflow || @project.workflows.first
+ @form = Tasks::Create.new(project_id: @project.id, workflow_id: @workflow.id,
+ status_id: @workflow.default_status&.id)
end
def create
- @form = Tasks::Create.new(params.expect(task: %i[project_id title description status_id]))
+ @form = Tasks::Create.new(params.expect(task: %i[project_id title description status_id workflow_id]))
if @form.perform
redirect_to tasks_path(project: @form.project)
else
@@ -70,8 +74,18 @@ class TasksController < ApplicationController
Project.find_by!(code: params[:project])
end
+ def fetch_workflow
+ return nil if params[:workflow_id].blank?
+
+ @project.workflows.find(params[:workflow_id])
+ end
+
def fetch_task
@task = Task.includes(:project).find_by_full_number_or_id!(params[:id])
self.current_project = @task.project
end
+
+ def workflows_for_tasks
+ @project.workflows.joins(:task_statuses).distinct
+ end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 84f8109..29e28f5 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -6,4 +6,13 @@ module ApplicationHelper
url_for(controller: controller_name, action: :index, project:)
end
+
+ def mask_icon(icon, **options)
+ klass = options.delete(:class)
+ klass = Array(klass)
+ klass << 'mask-icon'
+
+ icon_path = image_path("mingcute/#{icon}.svg")
+ content_tag(:span, '', class: klass, style: "--icon: url(#{icon_path})", **options)
+ end
end
diff --git a/app/helpers/project_admin/workflows/statuses_helper.rb b/app/helpers/project_admin/workflows/statuses_helper.rb
new file mode 100644
index 0000000..fde2422
--- /dev/null
+++ b/app/helpers/project_admin/workflows/statuses_helper.rb
@@ -0,0 +1,2 @@
+module ProjectAdmin::Workflows::StatusesHelper
+end
diff --git a/app/helpers/project_admin/workflows_helper.rb b/app/helpers/project_admin/workflows_helper.rb
new file mode 100644
index 0000000..a0e9f36
--- /dev/null
+++ b/app/helpers/project_admin/workflows_helper.rb
@@ -0,0 +1,2 @@
+module ProjectAdmin::WorkflowsHelper
+end
diff --git a/app/helpers/project_admin_helper.rb b/app/helpers/project_admin_helper.rb
new file mode 100644
index 0000000..78cc668
--- /dev/null
+++ b/app/helpers/project_admin_helper.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module ProjectAdminHelper
+ FRAME_ID = 'admin_frame'
+ TABS_ID = 'admin_tabs'
+
+ def project_admin_frame(project, &)
+ links = {
+ 'Data' => edit_project_path(project),
+ 'Workflows' => project_admin_workflows_path(project)
+ }
+
+ title = "Project #{project.name}"
+ admin_frame(links, title:, back_path: project_path(project), &)
+ end
+
+ def project_admin_workflow_frame(workflow, &)
+ links = {
+ 'Data' => edit_project_admin_workflow_path(workflow.project, workflow),
+ 'Statuses' => edit_project_admin_workflow_statuses_path(workflow.project, workflow),
+ 'Transitions' => edit_transitions_project_admin_workflow_statuses_path(workflow.project, workflow)
+ }
+ title = "Editing workflow #{workflow.name} for project #{workflow.project.name}"
+ admin_frame(links, title:, back_path: project_admin_workflow_path(workflow.project, workflow), &)
+ end
+
+ def admin_frame(links, title:, back_path: nil, &)
+ tabs = ProjectAdmin::TabsViewModel.new(links, id: TABS_ID, frame: FRAME_ID)
+ content = capture(&) if block_given?
+
+ render partial: 'project_admin/frame', locals: {
+ id: FRAME_ID, tabs:, tabs_id: TABS_ID, title:, back_path:, content:
+ }
+ end
+end
diff --git a/app/helpers/tasks_helper.rb b/app/helpers/tasks_helper.rb
index 204ed67..f9feacf 100644
--- a/app/helpers/tasks_helper.rb
+++ b/app/helpers/tasks_helper.rb
@@ -12,4 +12,12 @@ module TasksHelper
def task_status_selector(task, with_form: false)
render Tasks::Statuses::SelectorViewModel.new(task, with_form:)
end
+
+ # TODO: move into another helper?
+ def task_status_badge(status)
+ content_tag(
+ :span, mask_icon(Tasks::Statuses::SelectorViewModel.icon(status)) + status.name,
+ class: ['badge', 'task-status', status.color]
+ )
+ end
end
diff --git a/app/helpers/workflows_helper.rb b/app/helpers/workflows_helper.rb
new file mode 100644
index 0000000..001eb1c
--- /dev/null
+++ b/app/helpers/workflows_helper.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+
+module WorkflowsHelper
+ def workflow_display(workflow, **)
+ render Workflows::DisplayViewModel.new(workflow, **)
+ end
+end
diff --git a/app/javascript/controllers/dependent_frames_selector_controller.js b/app/javascript/controllers/dependent_frames_selector_controller.js
new file mode 100644
index 0000000..108ba90
--- /dev/null
+++ b/app/javascript/controllers/dependent_frames_selector_controller.js
@@ -0,0 +1,18 @@
+import { Controller } from '@hotwired/stimulus'
+
+// TODO: unite with ProjectsSelectorController?
+class DependentFramesSelectorController extends Controller {
+ static values = {'param': String}
+
+ static targets = ['frame']
+
+ refresh(event) {
+ const loc = new URL(location)
+ const selected = event.target.selectedOptions[0]
+ const value = selected.dataset.code || selected.value
+ loc.searchParams.set(this.paramValue, value)
+ Turbo.visit(loc.toString(), {frame: this.frameTarget})
+ }
+}
+
+export default DependentFramesSelectorController
diff --git a/app/javascript/controllers/dynamic_forms_controller.js b/app/javascript/controllers/dynamic_forms_controller.js
new file mode 100644
index 0000000..10be527
--- /dev/null
+++ b/app/javascript/controllers/dynamic_forms_controller.js
@@ -0,0 +1,74 @@
+import { Controller } from '@hotwired/stimulus'
+
+export default class extends Controller {
+ static targets = ['template', 'container', 'subform']
+ static values = {
+ fieldName: String,
+ fieldTemplate: String,
+ indexTemplate: String,
+ count: Number
+ }
+
+ connect() {
+ this.extraFieldsCount = 0
+ }
+
+ addForm() {
+ this.countValue++;
+ this.extraFieldsCount++;
+ const content = document.importNode(this.templateTarget.content, true)
+ for (let element of content.querySelectorAll('label, input, select, textarea')) {
+ this._replaceAttr(element, 'name')
+ this._replaceAttr(element, 'id')
+ this._replaceAttr(element, 'for')
+ }
+
+ let idInput = content.querySelector('input[name$="[id]"]')
+ if (idInput) {
+ idInput.value = `_${this.extraFieldsCount}`
+ }
+
+ this._subformsContainer.insertAdjacentElement('beforeend', content.children[0])
+ }
+
+ toggleSubform(event) {
+ // TODO: maybe extract into a separate controller
+ let target = event.currentTarget
+ let subform = this._findSubform(target)
+ let disabled = subform.toggleAttribute('data-disabled')
+ for (let input of subform.querySelectorAll('input:not([type="hidden"]), select, textarea')) {
+ input.toggleAttribute('disabled', disabled)
+ }
+ let destroyInput = subform.querySelector('input[name$="[_destroy]"]')
+ if(destroyInput) { destroyInput.value = disabled ? 'true' : '' }
+ if(target.tagName == 'BUTTON') {
+ target.setAttribute('aria-pressed', disabled ? 'true' : 'false')
+ }
+ }
+
+ deleteSubform(event) {
+ let subform = this._findSubform(event.currentTarget)
+ subform.remove()
+ }
+
+ get _subformsContainer() {
+ if(this.hasContainerTarget) { return this.containerTarget }
+
+ return this.element
+ }
+
+ _findSubform(element) {
+ for (let subform of this.subformTargets) {
+ if(subform.contains(element)) { return subform }
+ }
+ }
+
+ _replaceAttr(element, attr) {
+ let value = element.getAttribute(attr)
+ if(value == null) return;
+
+ value = value.replace(this.fieldTemplateValue, this.fieldNameValue).replace(this.indexTemplateValue, this.countValue)
+ element.setAttribute(attr, value)
+ }
+
+}
diff --git a/app/javascript/controllers/form_projects_selector_controller.js b/app/javascript/controllers/form_projects_selector_controller.js
deleted file mode 100644
index d2ca090..0000000
--- a/app/javascript/controllers/form_projects_selector_controller.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Controller } from '@hotwired/stimulus'
-
-// TODO: unite with ProjectsSelectorController?
-class FormProjectsSelectorController extends Controller {
- static values = {
- frame: String
- }
-
- connect() {
- console.log("Connected", this.element)
- }
-
- changeProject(event) {
- const loc = new URL(location)
- const selected = event.target.selectedOptions[0]
- const code = selected.dataset.code
- loc.searchParams.set('project', code)
- Turbo.visit(loc.toString(), {frame: this.frameValue})
- }
-}
-
-export default FormProjectsSelectorController
diff --git a/app/jobs/projects/post_init_job.rb b/app/jobs/projects/post_init_job.rb
index a01fe32..3d6843a 100644
--- a/app/jobs/projects/post_init_job.rb
+++ b/app/jobs/projects/post_init_job.rb
@@ -4,14 +4,15 @@ module Projects
class PostInitJob < ApplicationJob
queue_as :default
- include Projects::CreateDefaultTaskStatuses
+ include Projects::CreateDefaults
def perform(project_id)
project = Project.preparing.find(project_id)
project.transaction do
+ workflow = create_default_workflow(project)
create_tasks_number_sequence(project)
- create_default_task_statuses(project)
+ create_default_task_statuses(project, workflow)
project.update!(status: :ready)
end
diff --git a/app/models/project.rb b/app/models/project.rb
index f29799c..386bb4b 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -7,7 +7,8 @@ class Project < ApplicationRecord
validates :code, exclusion: { in: %w[new] }, uniqueness: true, format: { with: /\A[a-z][a-z0-9]{1,}\z/ }
has_many :tasks, dependent: :restrict_with_exception
- has_many :task_statuses, dependent: :destroy
+ has_many :workflows, dependent: :destroy
+ has_many :task_statuses, through: :workflows
has_rich_text :description
diff --git a/app/models/task.rb b/app/models/task.rb
index 467273b..a5d7730 100644
--- a/app/models/task.rb
+++ b/app/models/task.rb
@@ -3,6 +3,7 @@
class Task < ApplicationRecord
belongs_to :project
belongs_to :status, class_name: 'TaskStatus'
+ belongs_to :workflow
validates :number, :title, presence: true
validates :number, numericality: { greater_than: 0 }
@@ -33,8 +34,8 @@ class Task < ApplicationRecord
private
def associations_should_have_same_project
- return if status&.project == project
+ return if [project, status.project, workflow.project].uniq == [project]
- errors.add(:status, "Doesn't belong in the same project")
+ errors.add(:base, "Project isn't the same across associations")
end
end
diff --git a/app/models/task_status.rb b/app/models/task_status.rb
index a13768d..ce9bd83 100644
--- a/app/models/task_status.rb
+++ b/app/models/task_status.rb
@@ -1,12 +1,17 @@
# frozen_string_literal: true
class TaskStatus < ApplicationRecord
- belongs_to :project
+ belongs_to :workflow
+ has_one :project, through: :workflow
- enum :category, { backlog: 100, analysis: 1000, development: 20_000, fulfillment: 60_000 }
+ has_many :from_transitions, class_name: 'TaskStatus::Transition', inverse_of: :from, dependent: :destroy
+ has_many :to_transitions, class_name: 'TaskStatus::Transition', inverse_of: :to, dependent: :destroy
+ has_many :next_statuses, class_name: 'TaskStatus', through: :from_transitions, source: :to
- validates :name, presence: true, uniqueness: { scope: :project }
- validates :category, presence: true
+ enum :icon, %w[new achived done circle_dash hammer play tool].index_by(&:itself), default: 'new', scopes: false
+ enum :color, %w[blue gray yellow green purple pink].index_by(&:itself), default: 'gray', scopes: false
- scope :default_order, -> { order(:category, :name) }
+ validates :name, presence: true, uniqueness: { scope: :workflow }
+
+ scope :default_order, -> { order(:position, :name) }
end
diff --git a/app/models/task_status/transition.rb b/app/models/task_status/transition.rb
new file mode 100644
index 0000000..b40e179
--- /dev/null
+++ b/app/models/task_status/transition.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class TaskStatus
+ class Transition < ApplicationRecord
+ self.table_name = 'task_status_transitions'
+
+ belongs_to :from, class_name: '::TaskStatus'
+ belongs_to :to, class_name: '::TaskStatus'
+ end
+end
diff --git a/app/models/workflow.rb b/app/models/workflow.rb
new file mode 100644
index 0000000..3bc135a
--- /dev/null
+++ b/app/models/workflow.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class Workflow < ApplicationRecord
+ belongs_to :project
+
+ has_many :tasks, dependent: :restrict_with_exception
+ has_many :task_statuses, dependent: :restrict_with_error
+ accepts_nested_attributes_for :task_statuses, allow_destroy: true
+ belongs_to :default_status, class_name: 'TaskStatus', optional: true
+
+ enum :icon, { task: 'task', warning: 'warning' }, default: 'task', scopes: false
+ enum :color, { blue: 'blue', gray: 'gray', lime: 'lime', red: 'red', teal: 'teal' }, default: 'gray', scopes: false
+
+ validate :should_own_default_status
+
+ private
+
+ def should_own_default_status
+ return if default_status.nil?
+
+ errors.add(:default_status, 'Should own default status') unless default_status.id.in?(task_status_ids)
+ end
+end
diff --git a/app/services/project_admin/workflows/statuses/batch_update.rb b/app/services/project_admin/workflows/statuses/batch_update.rb
new file mode 100644
index 0000000..d45fb25
--- /dev/null
+++ b/app/services/project_admin/workflows/statuses/batch_update.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module ProjectAdmin
+ module Workflows
+ module Statuses
+ class BatchUpdate < ApplicationService
+ class TaskStatus
+ include ActiveModel::Model
+ include ActiveModel::Attributes
+
+ attribute :id, :string
+ attribute :_destroy, :boolean
+ attribute :name
+ attribute :color
+ attribute :icon
+
+ def to_model_attributes
+ return attributes.except('id', '_destroy') unless persisted?
+
+ if persisted? && _destroy
+ { id:, _destroy: }
+ else
+ attributes.except('_destroy')
+ end
+ end
+
+ def persisted? = id.present? && !id.to_s.start_with?('_')
+ end
+
+ attr_accessor :task_statuses
+ attr_reader :workflow
+
+ def self.from_model(workflow)
+ new(task_statuses_attributes: workflow.task_statuses.map do |ts|
+ ts.attributes.slice(*TaskStatus.attribute_names)
+ end)
+ end
+
+ def task_statuses_attributes=(attributes)
+ @task_statuses = Array(attributes).map { |e| TaskStatus.new(e) }
+ end
+
+ def perform(workflow)
+ @workflow = workflow
+
+ @workflow.assign_attributes(task_statuses_attributes: task_statuses.map(&:to_model_attributes))
+ save @workflow
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/project_admin/workflows/statuses/update_transitions.rb b/app/services/project_admin/workflows/statuses/update_transitions.rb
new file mode 100644
index 0000000..3fad643
--- /dev/null
+++ b/app/services/project_admin/workflows/statuses/update_transitions.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module ProjectAdmin
+ module Workflows
+ module Statuses
+ class UpdateTransitions < ApplicationService
+ class TaskStatus
+ include ActiveModel::Model
+ include ActiveModel::Attributes
+
+ attribute :id, :integer
+ attribute :next_status_ids
+ end
+
+ attr_accessor :workflow, :task_statuses
+
+ attribute :default_status_id, :integer
+
+ def self.from_model(workflow)
+ new(
+ workflow:,
+ task_statuses_attributes: workflow.task_statuses
+ .includes(:next_statuses)
+ .map { |ts| { id: ts.id, next_status_ids: ts.next_status_ids } },
+ default_status_id: workflow.default_status_id
+ )
+ end
+
+ def task_statuses_attributes=(attributes)
+ @task_statuses = Array(attributes).map { |e| TaskStatus.new(e) }
+ end
+
+ def perform(workflow)
+ @workflow = workflow
+ workflow.assign_attributes(default_status_id:, task_statuses_attributes: task_statuses.map(&:attributes))
+ save workflow
+ end
+
+ after_success do
+ if @workflow.default_status.nil?
+ @workflow.task_statuses.update!(position: 0)
+ return
+ end
+
+ @workflow.transaction do
+ @workflow.default_status.update!(position: 0)
+ seen_status_ids = Set[@workflow.default_status.id]
+ statuses_to_process = [@workflow.default_status]
+
+ until statuses_to_process.empty?
+ status = statuses_to_process.pop
+
+ next_statuses = status.next_statuses.where.not(id: seen_status_ids)
+ next_statuses.update(position: status.position + 1)
+ statuses_to_process.concat(next_statuses.to_a)
+ seen_status_ids.merge(next_statuses.map(&:id))
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/tasks/create.rb b/app/services/tasks/create.rb
index 42f6bf5..f7cb1a1 100644
--- a/app/services/tasks/create.rb
+++ b/app/services/tasks/create.rb
@@ -6,8 +6,9 @@ module Tasks
attribute :title, :string
attribute :description, :string
attribute :status_id, :integer
+ attribute :workflow_id, :integer
- validates :project_id, :title, :status_id, presence: true
+ validates :project_id, :title, :status_id, :workflow_id, presence: true
delegate :model_name, to: Task
@@ -18,7 +19,7 @@ module Tasks
end
def perform
- @task = project.tasks.build(title:, description:, status_id:, number: @project.next_task_number)
+ @task = project.tasks.build(title:, description:, status_id:, workflow_id:, number: @project.next_task_number)
save @task
end
end
diff --git a/app/view_models/project_admin/tabs_view_model.rb b/app/view_models/project_admin/tabs_view_model.rb
new file mode 100644
index 0000000..fa528b5
--- /dev/null
+++ b/app/view_models/project_admin/tabs_view_model.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module ProjectAdmin
+ class TabsViewModel
+ include Rails.application.routes.url_helpers
+
+ def initialize(links, id:, frame:)
+ @links = links
+ @id = id
+ @frame = frame
+ end
+
+ def render_in(view_context)
+ view_context.render(
+ partial: 'project_admin/tabs',
+ locals: { project: @project, id: @id, frame: @frame, links: @links }
+ )
+ end
+ end
+end
diff --git a/app/view_models/tasks/statuses/selector_view_model.rb b/app/view_models/tasks/statuses/selector_view_model.rb
index dc5b401..37093e2 100644
--- a/app/view_models/tasks/statuses/selector_view_model.rb
+++ b/app/view_models/tasks/statuses/selector_view_model.rb
@@ -3,6 +3,22 @@
module Tasks
module Statuses
class SelectorViewModel
+ ICONS = {
+ new: 'add_circle_fill',
+ archived: 'archive_line',
+ done: 'checks_line',
+ circle_dash: 'circle_dash_line',
+ hammer: 'hammer_fill',
+ play: 'play_circle_fill',
+ tool: 'tool_line'
+ }.freeze
+
+ def self.icon(status)
+ ICONS.fetch(status.icon.to_sym)
+ end
+
+ delegate :icon, to: :'self.class'
+
def initialize(task, with_form: false)
@task = task
@with_form = with_form
@@ -17,23 +33,18 @@ module Tasks
view_context.render(
partial: 'tasks/status_selector',
locals: { task: @task, id: dom_id, with_form: @with_form,
- project_task_statuses:,
- task_status_badge: ->(status) { task_status_badge(status, view_context) }}
+ workflow_task_statuses: }
)
end
private
- def project_task_statuses
- # TODO: refactor because it causes N+1 (task statuses loaded separately)
- @task.project.task_statuses.default_order
- end
+ def workflow_task_statuses
+ return @task.workflow.task_statuses.sort_by { |e| [e.position, e.name] } if @task.status.next_statuses.empty?
- def task_status_badge(status, view_context)
- view_context.content_tag(
- :span, status.name,
- class: ['badge', 'task-status', status.category.dasherize]
- )
+ @task.status.next_statuses.sort_by { |e| [e.position, e.name] }.tap do |statuses|
+ statuses.prepend(@task.status)
+ end
end
end
end
diff --git a/app/view_models/workflows/display_view_model.rb b/app/view_models/workflows/display_view_model.rb
new file mode 100644
index 0000000..d5d709b
--- /dev/null
+++ b/app/view_models/workflows/display_view_model.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Workflows
+ class DisplayViewModel
+ DEFAULT_ICON = 'task_line'
+ ICONS = {
+ warning: 'warning_line'
+ }.freeze
+
+ def initialize(workflow, badge: false, full: false)
+ @workflow = workflow
+ @badge = badge
+ @full = full
+ end
+
+ attr_reader :full, :badge
+
+ def icon
+ ICONS.fetch(@workflow.icon.to_sym, DEFAULT_ICON)
+ end
+
+ def render_in(view_context)
+ view_context.render(
+ partial: 'workflows/display',
+ locals: @workflow.attributes.symbolize_keys.merge(full:, badge:, icon:)
+ )
+ end
+ end
+end
diff --git a/app/views/project_admin/_frame.html.slim b/app/views/project_admin/_frame.html.slim
new file mode 100644
index 0000000..0f88b3f
--- /dev/null
+++ b/app/views/project_admin/_frame.html.slim
@@ -0,0 +1,13 @@
+h1
+ span> Project
+ span= @project.name
+
+- if back_path
+ = link_to '← Back', back_path
+
+= render tabs
+
+= turbo_frame_tag(id) do
+ = content
+ - if turbo_frame_request?
+ = turbo_stream.replace(tabs_id, tabs)
diff --git a/app/views/project_admin/_tabs.html.slim b/app/views/project_admin/_tabs.html.slim
new file mode 100644
index 0000000..5652181
--- /dev/null
+++ b/app/views/project_admin/_tabs.html.slim
@@ -0,0 +1,3 @@
+nav.tabs id=id
+ - links.each do |text, path|
+ li= link_to text, path, class: {'active': request.path == path}, data: {'turbo-frame': frame, 'turbo-action': 'advance'}
diff --git a/app/views/project_admin/workflows/_form.html.slim b/app/views/project_admin/workflows/_form.html.slim
new file mode 100644
index 0000000..2164d8d
--- /dev/null
+++ b/app/views/project_admin/workflows/_form.html.slim
@@ -0,0 +1,14 @@
+= form_with model: @workflow, url: url, data: {'turbo-frame': '_top'} do |form|
+ .field
+ = form.label :name
+ = form.text_field :name
+ .field
+ / TODO: replace with radio buttons maybe
+ = form.label :color
+ = form.select :color, Workflow.colors
+ .field
+ / TODO: replace with radio buttons maybe
+ = form.label :icon
+ = form.select :icon, Workflow.icons
+ .submit
+ = form.submit 'Submit'
diff --git a/app/views/project_admin/workflows/edit.html.slim b/app/views/project_admin/workflows/edit.html.slim
new file mode 100644
index 0000000..d40a32c
--- /dev/null
+++ b/app/views/project_admin/workflows/edit.html.slim
@@ -0,0 +1,2 @@
+= project_admin_workflow_frame(@workflow) do
+ = render 'form', url: project_admin_workflow_path(@project, @workflow)
diff --git a/app/views/project_admin/workflows/index.html.slim b/app/views/project_admin/workflows/index.html.slim
new file mode 100644
index 0000000..55268ff
--- /dev/null
+++ b/app/views/project_admin/workflows/index.html.slim
@@ -0,0 +1,13 @@
+= project_admin_frame(@project) do
+ h2
+ span Workflows
+
+ .links
+ = link_to 'New', new_project_admin_workflow_path(@project)
+
+ - @workflows.each do |workflow|
+ article.row
+ = workflow_display(workflow, full: true)
+ ul.links.right
+ li= link_to 'Info', project_admin_workflow_path(@project, workflow), target: '_blank'
+ li= link_to 'Edit', edit_project_admin_workflow_path(@project, workflow), target: '_blank'
diff --git a/app/views/project_admin/workflows/new.html.slim b/app/views/project_admin/workflows/new.html.slim
new file mode 100644
index 0000000..3c9c82f
--- /dev/null
+++ b/app/views/project_admin/workflows/new.html.slim
@@ -0,0 +1,2 @@
+= project_admin_frame(@project) do
+ = render 'form', url: project_admin_workflows_path(@project, @workflow)
diff --git a/app/views/project_admin/workflows/show.html.slim b/app/views/project_admin/workflows/show.html.slim
new file mode 100644
index 0000000..4a5d9aa
--- /dev/null
+++ b/app/views/project_admin/workflows/show.html.slim
@@ -0,0 +1,13 @@
+h1
+ | Workflow
+
+.row
+ = workflow_display @workflow, full: true, badge: true
+ .links.right
+ = link_to 'Edit', edit_project_admin_workflow_path(@project, @workflow)
+
+h2 Statuses
+
+ul
+ - @workflow.task_statuses.default_order.each do |status|
+ li= task_status_badge(status)
diff --git a/app/views/project_admin/workflows/statuses/_batch_update_fieldset.html.slim b/app/views/project_admin/workflows/statuses/_batch_update_fieldset.html.slim
new file mode 100644
index 0000000..3741449
--- /dev/null
+++ b/app/views/project_admin/workflows/statuses/_batch_update_fieldset.html.slim
@@ -0,0 +1,17 @@
+- with_destroy ||= false
+
+div.fieldset data-dynamic-forms-target="subform"
+ = ff.hidden_field :id
+ - if with_destroy
+ = ff.hidden_field :_destroy
+ .field
+ = ff.label :name
+ = ff.text_field :name
+ .field
+ = ff.label :color
+ = ff.select :color, TaskStatus.colors
+ .field
+ = ff.label :icon
+ = ff.select :icon, TaskStatus.icons
+ .buttons
+ = button_tag 'Destroy', type: :button, class: 'danger', data: { action: (with_destroy ? 'dynamic-forms#toggleSubform' : 'dynamic-forms#deleteSubform') }
diff --git a/app/views/project_admin/workflows/statuses/edit.html.slim b/app/views/project_admin/workflows/statuses/edit.html.slim
new file mode 100644
index 0000000..129d907
--- /dev/null
+++ b/app/views/project_admin/workflows/statuses/edit.html.slim
@@ -0,0 +1,15 @@
+= project_admin_workflow_frame(@workflow) do
+ = form_with model: @form, scope: 'workflow', url: project_admin_workflow_statuses_path(@project, @workflow), method: :put, data: {controller: 'dynamic-forms', 'dynamic-forms-field-name-value': 'task_statuses_attributes', 'dynamic-forms-field-template-value': '_extra_form', 'dynamic-forms-index-template-value': '__index__', 'dynamic-forms-count-value': @form.task_statuses.count, 'turbo-frame': '_top'} do |f|
+ section.workflow-statuses-batch-update-fieldsets data-dynamic-forms-target="container"
+ = f.fields_for :task_statuses, include_id: false do |tsf|
+ = render 'batch_update_fieldset', ff: tsf, with_destroy: true
+
+ .buttons
+ button.success type="button" data-action="dynamic-forms#addForm" Add status
+
+ = f.fields_for :'_extra_form', index: '__index__' do |exf|
+ template data-dynamic-forms-target="template"
+ = render 'batch_update_fieldset', ff: exf
+
+ .submit
+ = f.submit
diff --git a/app/views/project_admin/workflows/statuses/edit_transitions.html.slim b/app/views/project_admin/workflows/statuses/edit_transitions.html.slim
new file mode 100644
index 0000000..d0d6605
--- /dev/null
+++ b/app/views/project_admin/workflows/statuses/edit_transitions.html.slim
@@ -0,0 +1,10 @@
+= project_admin_workflow_frame(@workflow) do
+ = form_with model: @workflow, scope: :workflow, url: transitions_project_admin_workflow_statuses_path(@project, @workflow), method: :put, data: {'turbo-frame': '_top'} do |f|
+ = f.fields_for :task_statuses do |tsf|
+ section
+ div= task_status_badge tsf.object
+ div= f.radio_button :default_status_id, tsf.object.id
+ div= tsf.collection_checkboxes :next_status_ids, @workflow.task_statuses - [tsf.object], :id, :name
+
+ .submit
+ = f.submit
diff --git a/app/views/projects/_form.html.slim b/app/views/projects/_form.html.slim
index a970dc2..4d26cdc 100644
--- a/app/views/projects/_form.html.slim
+++ b/app/views/projects/_form.html.slim
@@ -1,4 +1,4 @@
-= form_with model: @project do |form|
+= form_with model: @project, data: {'turbo-frame': '_top'} do |form|
.field
= form.label :code
= form.text_field :code
diff --git a/app/views/projects/edit.html.slim b/app/views/projects/edit.html.slim
index 386dbee..dc7c7e0 100644
--- a/app/views/projects/edit.html.slim
+++ b/app/views/projects/edit.html.slim
@@ -1,5 +1,5 @@
-h1
- span Editing project
- span= @project.name
+= project_admin_frame(@project) do
+ h2
+ span Editing project
-= render 'form'
+ = render 'form'
diff --git a/app/views/tasks/_status_selector.html.slim b/app/views/tasks/_status_selector.html.slim
index c91c34b..df9f0ae 100644
--- a/app/views/tasks/_status_selector.html.slim
+++ b/app/views/tasks/_status_selector.html.slim
@@ -1,9 +1,9 @@
details.dropdown.task-status-selector id=id data-controller="tasks--status-selector"
- summary= task_status_badge[task.status]
+ summary= task_status_badge(task.status)
ul
- - project_task_statuses.each do |status|
+ - workflow_task_statuses.each do |status|
li
- a href="#" data-status-id="#{status.id}" data-action="tasks--status-selector#changeStatus:prevent" = task_status_badge[status]
+ a href="#" data-status-id="#{status.id}" data-action="tasks--status-selector#changeStatus:prevent" = task_status_badge(status)
- if with_form
= form_with model: Tasks::ChangeStatus.new, url: change_status_task_path(task), method: :patch, data: {'tasks--status-selector-target': 'form', action: 'turbo:submit-end->tasks--status-selector#finalize'} do |f|
= f.hidden_field :status_id, data: {'tasks--status-selector-target': 'statusField'}
diff --git a/app/views/tasks/_table_row.html.slim b/app/views/tasks/_table_row.html.slim
index 45fa2c3..13c81c6 100644
--- a/app/views/tasks/_table_row.html.slim
+++ b/app/views/tasks/_table_row.html.slim
@@ -1,5 +1,6 @@
- cache task do
tr id="task_#{task.id}"
+ td= workflow_display task.workflow, badge: true
td= link_to task.full_number, task_path(task)
td
= task_status_selector task, with_form: true
diff --git a/app/views/tasks/new.html.slim b/app/views/tasks/new.html.slim
index aa3b1f1..b87d828 100644
--- a/app/views/tasks/new.html.slim
+++ b/app/views/tasks/new.html.slim
@@ -1,13 +1,19 @@
h1 New task
-= form_with model: @form, data: {controller: 'form-projects-selector', 'form-projects-selector-frame-value' => 'status_select'} do |form|
+= form_with model: @form, data: {controller: 'dependent-frames-selector', 'dependent-frames-selector-param-value': 'project'} do |form|
.field
= form.label :project_id
- = form.select :project_id, Project.order(:name).map { |p| [p.name, p.id, {'data-code': p.code}] }, {}, data: {action: 'form-projects-selector#changeProject'}
- = turbo_frame_tag :status_select do
- .field
- = form.label :status_id
- = form.select :status_id, TaskStatus.where(project: @form.project).default_order.map { |ts| [ts.name, ts.id] }
+ = form.select :project_id, Project.order(:name).map { |p| [p.name, p.id, {'data-code': p.code}] }, {}, data: {action: 'dependent-frames-selector#refresh'}
+ = turbo_frame_tag :project_field_selects, data: {'dependent-frames-selector-target': 'frame'} do
+ fieldset data={controller: 'dependent-frames-selector', 'dependent-frames-selector-param-value': 'workflow_id'}
+ .field
+ = form.label :workflow
+ = form.select :workflow_id, workflows_for_tasks.map { |w| [w.name, w.id] }, {}, data: {action: 'dependent-frames-selector#refresh'}
+
+ = turbo_frame_tag :status_select, data: {'dependent-frames-selector-target': 'frame'}
+ .field
+ = form.label :status_id
+ = form.select :status_id, TaskStatus.where(workflow: @workflow).default_order.map { |ts| [ts.name, ts.id] }
.field
= form.label :title
= form.text_field :title
diff --git a/app/views/tasks/show.html.slim b/app/views/tasks/show.html.slim
index aaade18..fac8421 100644
--- a/app/views/tasks/show.html.slim
+++ b/app/views/tasks/show.html.slim
@@ -3,6 +3,7 @@ div
h1= @task.title
section.task-show-info
+ = workflow_display @task.workflow, full: true, badge: true
= task_status_selector @task, with_form: true
= turbo_stream_from @task, :status, :with_form
diff --git a/app/views/workflows/_display.html.slim b/app/views/workflows/_display.html.slim
new file mode 100644
index 0000000..915df42
--- /dev/null
+++ b/app/views/workflows/_display.html.slim
@@ -0,0 +1,4 @@
+div.workflow class=[color, ('badge' if badge)] title=name
+ = mask_icon(icon)
+ - if full
+ span<= name
diff --git a/config/routes.rb b/config/routes.rb
index 86a6e85..3d3b3a4 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -16,7 +16,23 @@ Rails.application.routes.draw do
# Defines the root path route ("/")
# root "posts#index"
- resources :projects
+ root to: redirect('/projects')
+
+ resources :projects do
+ namespace :project_admin, as: 'admin' do
+ resources :workflows do
+ scope module: :workflows do
+ resources :statuses, only: %i[index] do
+ get :edit, on: :collection
+ put '/', action: :batch_update, on: :collection
+ get :edit_transitions, on: :collection
+ put '/transitions', action: :batch_update_transitions, on: :collection
+ end
+ end
+ end
+ end
+ end
+
resources :tasks do
patch :change_status, on: :member
end
diff --git a/db/migrate/20260321114951_create_workflows.rb b/db/migrate/20260321114951_create_workflows.rb
new file mode 100644
index 0000000..08a4958
--- /dev/null
+++ b/db/migrate/20260321114951_create_workflows.rb
@@ -0,0 +1,14 @@
+class CreateWorkflows < ActiveRecord::Migration[8.1]
+ def change
+ create_table :workflows do |t|
+ t.belongs_to :project, null: false, foreign_key: true
+ t.string :name, null: false
+ t.string :icon, null: false
+ t.string :color, null: false
+
+ t.index %i[project_id name], unique: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260321115521_add_workflow_id_to_statuses_and_tasks.rb b/db/migrate/20260321115521_add_workflow_id_to_statuses_and_tasks.rb
new file mode 100644
index 0000000..68a03c4
--- /dev/null
+++ b/db/migrate/20260321115521_add_workflow_id_to_statuses_and_tasks.rb
@@ -0,0 +1,6 @@
+class AddWorkflowIdToStatusesAndTasks < ActiveRecord::Migration[8.1]
+ def change
+ add_belongs_to :tasks, :workflow
+ add_belongs_to :task_statuses, :workflow
+ end
+end
diff --git a/db/migrate/20260321132125_change_task_statuses_uniqueness.rb b/db/migrate/20260321132125_change_task_statuses_uniqueness.rb
new file mode 100644
index 0000000..04d8a99
--- /dev/null
+++ b/db/migrate/20260321132125_change_task_statuses_uniqueness.rb
@@ -0,0 +1,9 @@
+class ChangeTaskStatusesUniqueness < ActiveRecord::Migration[8.1]
+ def change
+ change_table :task_statuses, bulk: true do |t|
+ t.remove_index %i[project_id name], unique: true
+ t.remove_index %i[project_id category name]
+ t.index %i[workflow_id name], unique: true
+ end
+ end
+end
diff --git a/db/migrate/20260321132550_add_null_false_to_task_statuses_workflow.rb b/db/migrate/20260321132550_add_null_false_to_task_statuses_workflow.rb
new file mode 100644
index 0000000..bb6b62d
--- /dev/null
+++ b/db/migrate/20260321132550_add_null_false_to_task_statuses_workflow.rb
@@ -0,0 +1,5 @@
+class AddNullFalseToTaskStatusesWorkflow < ActiveRecord::Migration[8.1]
+ def change
+ change_column_null :task_statuses, :workflow_id, false
+ end
+end
diff --git a/db/migrate/20260321132740_drop_project_id_from_task_statuses.rb b/db/migrate/20260321132740_drop_project_id_from_task_statuses.rb
new file mode 100644
index 0000000..407b78a
--- /dev/null
+++ b/db/migrate/20260321132740_drop_project_id_from_task_statuses.rb
@@ -0,0 +1,5 @@
+class DropProjectIdFromTaskStatuses < ActiveRecord::Migration[8.1]
+ def change
+ remove_reference :task_statuses, :project, foreign_key: true
+ end
+end
diff --git a/db/migrate/20260404215813_replace_old_status_fields_with_new.rb b/db/migrate/20260404215813_replace_old_status_fields_with_new.rb
new file mode 100644
index 0000000..5e17ac7
--- /dev/null
+++ b/db/migrate/20260404215813_replace_old_status_fields_with_new.rb
@@ -0,0 +1,11 @@
+class ReplaceOldStatusFieldsWithNew < ActiveRecord::Migration[8.1]
+ def change
+ change_table :task_statuses, bulk: true do |t|
+ t.string :icon
+ t.string :color
+ t.integer :position, default: 0
+
+ t.remove :category, type: :integer
+ end
+ end
+end
diff --git a/db/migrate/20260425112121_create_task_status_transitions.rb b/db/migrate/20260425112121_create_task_status_transitions.rb
new file mode 100644
index 0000000..b01d720
--- /dev/null
+++ b/db/migrate/20260425112121_create_task_status_transitions.rb
@@ -0,0 +1,10 @@
+class CreateTaskStatusTransitions < ActiveRecord::Migration[8.1]
+ def change
+ create_table :task_status_transitions do |t|
+ t.belongs_to :from, null: false, foreign_key: { to_table: :task_statuses }
+ t.belongs_to :to, null: false, foreign_key: { to_table: :task_statuses }
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260425123859_add_default_status_to_workflows.rb b/db/migrate/20260425123859_add_default_status_to_workflows.rb
new file mode 100644
index 0000000..9782d23
--- /dev/null
+++ b/db/migrate/20260425123859_add_default_status_to_workflows.rb
@@ -0,0 +1,5 @@
+class AddDefaultStatusToWorkflows < ActiveRecord::Migration[8.1]
+ def change
+ add_reference :workflows, :default_status, foreign_key: { to_table: :task_statuses }
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index f05bd18..e87c5e9 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_03_01_141927) do
+ActiveRecord::Schema[8.1].define(version: 2026_04_25_123859) do
create_table "action_text_rich_texts", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.text "body", size: :long
t.datetime "created_at", null: false
@@ -69,15 +69,25 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_01_141927) do
t.index ["user_id"], name: "index_sessions_on_user_id"
end
- create_table "task_statuses", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
- t.integer "category", limit: 2, null: false, unsigned: true
+ create_table "task_status_transitions", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
- t.string "name", null: false
- t.bigint "project_id", null: false
+ t.bigint "from_id", null: false
+ t.bigint "to_id", null: false
t.datetime "updated_at", null: false
- t.index ["project_id", "category", "name"], name: "index_task_statuses_on_project_id_and_category_and_name"
- t.index ["project_id", "name"], name: "index_task_statuses_on_project_id_and_name", unique: true
- t.index ["project_id"], name: "index_task_statuses_on_project_id"
+ t.index ["from_id"], name: "index_task_status_transitions_on_from_id"
+ t.index ["to_id"], name: "index_task_status_transitions_on_to_id"
+ end
+
+ create_table "task_statuses", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
+ t.string "color"
+ t.datetime "created_at", null: false
+ t.string "icon"
+ t.string "name", null: false
+ t.integer "position", default: 0
+ t.datetime "updated_at", null: false
+ t.bigint "workflow_id", null: false
+ t.index ["workflow_id", "name"], name: "index_task_statuses_on_workflow_id_and_name", unique: true
+ t.index ["workflow_id"], name: "index_task_statuses_on_workflow_id"
end
create_table "tasks", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
@@ -87,8 +97,10 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_01_141927) do
t.bigint "status_id"
t.string "title", null: false
t.datetime "updated_at", null: false
+ t.bigint "workflow_id"
t.index ["project_id"], name: "index_tasks_on_project_id"
t.index ["status_id"], name: "index_tasks_on_status_id"
+ t.index ["workflow_id"], name: "index_tasks_on_workflow_id"
end
create_table "users", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
@@ -99,10 +111,26 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_01_141927) do
t.index ["email_address"], name: "index_users_on_email_address", unique: true
end
+ create_table "workflows", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
+ t.string "color", null: false
+ t.datetime "created_at", null: false
+ t.bigint "default_status_id"
+ t.string "icon", null: false
+ t.string "name", null: false
+ t.bigint "project_id", null: false
+ t.datetime "updated_at", null: false
+ t.index ["default_status_id"], name: "index_workflows_on_default_status_id"
+ t.index ["project_id", "name"], name: "index_workflows_on_project_id_and_name", unique: true
+ t.index ["project_id"], name: "index_workflows_on_project_id"
+ end
+
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "sessions", "users"
- add_foreign_key "task_statuses", "projects"
+ add_foreign_key "task_status_transitions", "task_statuses", column: "from_id"
+ add_foreign_key "task_status_transitions", "task_statuses", column: "to_id"
add_foreign_key "tasks", "projects"
add_foreign_key "tasks", "task_statuses", column: "status_id"
+ add_foreign_key "workflows", "projects"
+ add_foreign_key "workflows", "task_statuses", column: "default_status_id"
end
diff --git a/lib/projects/create_default_task_statuses.rb b/lib/projects/create_default_task_statuses.rb
deleted file mode 100644
index a92a1fe..0000000
--- a/lib/projects/create_default_task_statuses.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-module Projects
- module CreateDefaultTaskStatuses
- module_function
-
- def create_default_task_statuses(project)
- # TODO: make it configurable/templatable?
-
- project.transaction do
- project.task_statuses.create!(category: :backlog, name: 'Backlog')
- project.task_statuses.create!(category: :analysis, name: 'To do')
- project.task_statuses.create!(category: :development, name: 'In development')
- project.task_statuses.create!(category: :fulfillment, name: 'Done')
- end
- end
- end
-end
diff --git a/lib/projects/create_defaults.rb b/lib/projects/create_defaults.rb
new file mode 100644
index 0000000..d44d609
--- /dev/null
+++ b/lib/projects/create_defaults.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Projects
+ module CreateDefaults
+ module_function
+
+ def create_default_workflow(project)
+ project.transaction do
+ project.workflows.create!(name: 'Default')
+ end
+ end
+
+ def create_default_task_statuses(project, workflow)
+ # TODO: make it configurable/templatable?
+
+ project.transaction do
+ project.task_statuses.create!(workflow:, category: :backlog, name: 'Backlog')
+ project.task_statuses.create!(workflow:, category: :analysis, name: 'To do')
+ project.task_statuses.create!(workflow:, category: :development, name: 'In development')
+ project.task_statuses.create!(workflow:, category: :fulfillment, name: 'Done')
+ end
+ end
+ end
+end
diff --git a/lib/tasks/data_migrations.rake b/lib/tasks/data_migrations.rake
index f1507c7..ba203ea 100644
--- a/lib/tasks/data_migrations.rake
+++ b/lib/tasks/data_migrations.rake
@@ -12,4 +12,34 @@ namespace :data_migrations do
end
end
end
+
+ desc 'Create initial workflows for projects'
+ task create_initial_workflows: :environment do
+ Project.find_each do |project|
+ Projects::CreateDefaults.create_default_workflow(project)
+ end
+ end
+
+ desc 'Set workflows for statuses'
+ task set_default_task_status_workflows: :create_initial_workflows do
+ TaskStatus.includes(project: :workflows).find_each do |ts|
+ ts.workflow = ts.project.workflows.first
+ ts.save!
+ end
+ end
+
+ desc 'Set workflows for tasks'
+ task set_default_task_workflows: %i[create_initial_workflows set_default_task_status_workflows] do
+ Task.includes(project: :workflows).find_each do |task|
+ task.workflow = task.project.workflows.first
+ task.save!
+ end
+ end
+
+ desc 'Backfill task status icons and colors'
+ task backfill_task_status_icons_and_colors: :environment do
+ TaskStatus.in_batches do |batch|
+ batch.update_all(icon: 'circle_dash', color: 'blue')
+ end
+ end
end
diff --git a/vendor/assets/images/mingcute/add_circle_fill.svg b/vendor/assets/images/mingcute/add_circle_fill.svg
new file mode 100644
index 0000000..a37730b
--- /dev/null
+++ b/vendor/assets/images/mingcute/add_circle_fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/vendor/assets/images/mingcute/archive_line.svg b/vendor/assets/images/mingcute/archive_line.svg
new file mode 100644
index 0000000..07e2e0f
--- /dev/null
+++ b/vendor/assets/images/mingcute/archive_line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/vendor/assets/images/mingcute/checks_line.svg b/vendor/assets/images/mingcute/checks_line.svg
new file mode 100644
index 0000000..92c5a81
--- /dev/null
+++ b/vendor/assets/images/mingcute/checks_line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/vendor/assets/images/mingcute/circle_dash_line.svg b/vendor/assets/images/mingcute/circle_dash_line.svg
new file mode 100644
index 0000000..0abc8ba
--- /dev/null
+++ b/vendor/assets/images/mingcute/circle_dash_line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/vendor/assets/images/mingcute/external_link_line.svg b/vendor/assets/images/mingcute/external_link_line.svg
new file mode 100644
index 0000000..a34fb20
--- /dev/null
+++ b/vendor/assets/images/mingcute/external_link_line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/vendor/assets/images/mingcute/hamburger_line.svg b/vendor/assets/images/mingcute/hamburger_line.svg
index bbcef4a..f8626f5 100644
--- a/vendor/assets/images/mingcute/hamburger_line.svg
+++ b/vendor/assets/images/mingcute/hamburger_line.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/vendor/assets/images/mingcute/hammer_fill.svg b/vendor/assets/images/mingcute/hammer_fill.svg
new file mode 100644
index 0000000..300b796
--- /dev/null
+++ b/vendor/assets/images/mingcute/hammer_fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/vendor/assets/images/mingcute/play_circle_fill.svg b/vendor/assets/images/mingcute/play_circle_fill.svg
new file mode 100644
index 0000000..eb3f416
--- /dev/null
+++ b/vendor/assets/images/mingcute/play_circle_fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/vendor/assets/images/mingcute/right_fill.svg b/vendor/assets/images/mingcute/right_fill.svg
index 930e72a..d7a8dcd 100644
--- a/vendor/assets/images/mingcute/right_fill.svg
+++ b/vendor/assets/images/mingcute/right_fill.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/vendor/assets/images/mingcute/signature_line.svg b/vendor/assets/images/mingcute/signature_line.svg
new file mode 100644
index 0000000..65cf1bb
--- /dev/null
+++ b/vendor/assets/images/mingcute/signature_line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/vendor/assets/images/mingcute/task_line.svg b/vendor/assets/images/mingcute/task_line.svg
new file mode 100644
index 0000000..58b6fb8
--- /dev/null
+++ b/vendor/assets/images/mingcute/task_line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/vendor/assets/images/mingcute/tool_line.svg b/vendor/assets/images/mingcute/tool_line.svg
new file mode 100644
index 0000000..f434cf1
--- /dev/null
+++ b/vendor/assets/images/mingcute/tool_line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/vendor/assets/images/mingcute/warning_line.svg b/vendor/assets/images/mingcute/warning_line.svg
new file mode 100644
index 0000000..06c8f48
--- /dev/null
+++ b/vendor/assets/images/mingcute/warning_line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file