From dbd849589be303e31395239bae5bea20fd87b4ef Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Sat, 21 Mar 2026 14:02:55 +0200 Subject: [PATCH 01/19] Workflow model --- app/models/task.rb | 1 + app/models/task_status.rb | 1 + app/models/workflow.rb | 11 +++++++++++ db/migrate/20260321114951_create_workflows.rb | 14 ++++++++++++++ ...21_add_workflow_id_to_statuses_and_tasks.rb | 6 ++++++ db/schema.rb | 18 +++++++++++++++++- 6 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 app/models/workflow.rb create mode 100644 db/migrate/20260321114951_create_workflows.rb create mode 100644 db/migrate/20260321115521_add_workflow_id_to_statuses_and_tasks.rb diff --git a/app/models/task.rb b/app/models/task.rb index 467273b..1b736d3 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 } diff --git a/app/models/task_status.rb b/app/models/task_status.rb index a13768d..8ba0a17 100644 --- a/app/models/task_status.rb +++ b/app/models/task_status.rb @@ -2,6 +2,7 @@ class TaskStatus < ApplicationRecord belongs_to :project + belongs_to :workflow enum :category, { backlog: 100, analysis: 1000, development: 20_000, fulfillment: 60_000 } diff --git a/app/models/workflow.rb b/app/models/workflow.rb new file mode 100644 index 0000000..e69b8b2 --- /dev/null +++ b/app/models/workflow.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Workflow < ApplicationRecord + belongs_to :project + + has_many :tasks, dependent: :restrict_with_exception + has_mnay :statuses, dependent: :restrict_with_error + + enum :icon, { task: 'task', warning: 'warning' }, default: 'task', scopes: false + enum :color, { blue: 'blue', gray: 'gray', yellow: 'yellow', red: 'red' }, default: 'gray', scopes: false +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/schema.rb b/db/schema.rb index f05bd18..a124fed 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_03_21_115521) 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 @@ -75,9 +75,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_01_141927) do t.string "name", null: false t.bigint "project_id", null: false t.datetime "updated_at", null: false + t.bigint "workflow_id" 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 ["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 +89,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 +103,22 @@ 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.string "icon", null: false + t.string "name", null: false + t.bigint "project_id", null: false + t.datetime "updated_at", null: false + 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 "tasks", "projects" add_foreign_key "tasks", "task_statuses", column: "status_id" + add_foreign_key "workflows", "projects" end From fb9d884313be4baa6f5a52dab51cc695e4f53717 Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Sat, 21 Mar 2026 14:48:27 +0200 Subject: [PATCH 02/19] Tasks creation (mostly) --- app/controllers/tasks_controller.rb | 4 ++-- .../form_projects_selector_controller.js | 10 ++------ app/jobs/projects/post_init_job.rb | 5 ++-- app/models/project.rb | 1 + app/models/task.rb | 4 ++-- app/models/task_status.rb | 9 +++++++ app/models/workflow.rb | 2 +- app/services/tasks/create.rb | 5 ++-- app/views/tasks/_table_row.html.slim | 3 +++ app/views/tasks/new.html.slim | 8 +++++-- lib/projects/create_default_task_statuses.rb | 18 -------------- lib/projects/create_defaults.rb | 24 +++++++++++++++++++ lib/tasks/data_migrations.rake | 23 ++++++++++++++++++ vendor/assets/images/mingcute/task_line.svg | 1 + .../assets/images/mingcute/warning_line.svg | 1 + 15 files changed, 81 insertions(+), 37 deletions(-) delete mode 100644 lib/projects/create_default_task_statuses.rb create mode 100644 lib/projects/create_defaults.rb create mode 100644 vendor/assets/images/mingcute/task_line.svg create mode 100644 vendor/assets/images/mingcute/warning_line.svg diff --git a/app/controllers/tasks_controller.rb b/app/controllers/tasks_controller.rb index 05e61d6..9776ea1 100644 --- a/app/controllers/tasks_controller.rb +++ b/app/controllers/tasks_controller.rb @@ -11,7 +11,7 @@ class TasksController < ApplicationController Task.all end - @tasks = @tasks.includes(:status, project: :task_statuses) + @tasks = @tasks.includes(:status, :workflow, project: :task_statuses) end def show; end @@ -22,7 +22,7 @@ class TasksController < ApplicationController 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 diff --git a/app/javascript/controllers/form_projects_selector_controller.js b/app/javascript/controllers/form_projects_selector_controller.js index d2ca090..f89d0c1 100644 --- a/app/javascript/controllers/form_projects_selector_controller.js +++ b/app/javascript/controllers/form_projects_selector_controller.js @@ -2,20 +2,14 @@ import { Controller } from '@hotwired/stimulus' // TODO: unite with ProjectsSelectorController? class FormProjectsSelectorController extends Controller { - static values = { - frame: String - } - - connect() { - console.log("Connected", this.element) - } + static targets = ['frame'] 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}) + Turbo.visit(loc.toString(), {frame: this.frameTarget}) } } 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..50799f3 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -8,6 +8,7 @@ class Project < ApplicationRecord has_many :tasks, dependent: :restrict_with_exception has_many :task_statuses, dependent: :destroy + has_many :workflows, dependent: :destroy has_rich_text :description diff --git a/app/models/task.rb b/app/models/task.rb index 1b736d3..a5d7730 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -34,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 8ba0a17..e2f43ea 100644 --- a/app/models/task_status.rb +++ b/app/models/task_status.rb @@ -8,6 +8,15 @@ class TaskStatus < ApplicationRecord validates :name, presence: true, uniqueness: { scope: :project } validates :category, presence: true + validate :associations_should_have_same_project scope :default_order, -> { order(:category, :name) } + + private + + def associations_should_have_same_project + return if workflow.project == project + + errors.add(:workflow, "Doesn't belong in the same project") + end end diff --git a/app/models/workflow.rb b/app/models/workflow.rb index e69b8b2..a39f941 100644 --- a/app/models/workflow.rb +++ b/app/models/workflow.rb @@ -4,7 +4,7 @@ class Workflow < ApplicationRecord belongs_to :project has_many :tasks, dependent: :restrict_with_exception - has_mnay :statuses, dependent: :restrict_with_error + has_many :statuses, dependent: :restrict_with_error enum :icon, { task: 'task', warning: 'warning' }, default: 'task', scopes: false enum :color, { blue: 'blue', gray: 'gray', yellow: 'yellow', red: 'red' }, default: 'gray', scopes: false 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/views/tasks/_table_row.html.slim b/app/views/tasks/_table_row.html.slim index 45fa2c3..e359c9b 100644 --- a/app/views/tasks/_table_row.html.slim +++ b/app/views/tasks/_table_row.html.slim @@ -1,5 +1,8 @@ - cache task do tr id="task_#{task.id}" + td + / TODO: extract into a presenter (and especially add color) + = image_tag('mingcute/task_line.svg', title: task.workflow.name) 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..df2dbcf 100644 --- a/app/views/tasks/new.html.slim +++ b/app/views/tasks/new.html.slim @@ -1,10 +1,14 @@ 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: 'form-projects-selector'} 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 + = turbo_frame_tag :project_field_selects, data: {'form-projects-selector-target': 'frame'} do + .field + = form.label :workflow + = form.select :workflow_id, Workflow.where(project: @form.project).map { |w| [w.name, w.id] } + .field = form.label :status_id = form.select :status_id, TaskStatus.where(project: @form.project).default_order.map { |ts| [ts.name, ts.id] } 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..5b42249 100644 --- a/lib/tasks/data_migrations.rake +++ b/lib/tasks/data_migrations.rake @@ -12,4 +12,27 @@ 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 end diff --git a/vendor/assets/images/mingcute/task_line.svg b/vendor/assets/images/mingcute/task_line.svg new file mode 100644 index 0000000..e303c87 --- /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/warning_line.svg b/vendor/assets/images/mingcute/warning_line.svg new file mode 100644 index 0000000..5ecd642 --- /dev/null +++ b/vendor/assets/images/mingcute/warning_line.svg @@ -0,0 +1 @@ + \ No newline at end of file From 218e8a750eab1866699a11b6616aa9e962f54aaf Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Sat, 21 Mar 2026 15:20:04 +0200 Subject: [PATCH 03/19] Task creation (workflow dependent frames) --- app/controllers/tasks_controller.rb | 9 ++++++++- ...> dependent_frames_selector_controller.js} | 12 ++++++----- app/models/workflow.rb | 2 +- app/views/tasks/_table_row.html.slim | 5 ++++- app/views/tasks/new.html.slim | 20 ++++++++++--------- 5 files changed, 31 insertions(+), 17 deletions(-) rename app/javascript/controllers/{form_projects_selector_controller.js => dependent_frames_selector_controller.js} (50%) diff --git a/app/controllers/tasks_controller.rb b/app/controllers/tasks_controller.rb index 9776ea1..39597ff 100644 --- a/app/controllers/tasks_controller.rb +++ b/app/controllers/tasks_controller.rb @@ -18,7 +18,8 @@ class TasksController < ApplicationController def new @project = fetch_project || Project.order(:name).first - @form = Tasks::Create.new(project_id: @project.id) + @workflow = fetch_workflow || @project.workflows.first + @form = Tasks::Create.new(project_id: @project.id, workflow_id: @workflow.id) end def create @@ -70,6 +71,12 @@ 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 diff --git a/app/javascript/controllers/form_projects_selector_controller.js b/app/javascript/controllers/dependent_frames_selector_controller.js similarity index 50% rename from app/javascript/controllers/form_projects_selector_controller.js rename to app/javascript/controllers/dependent_frames_selector_controller.js index f89d0c1..108ba90 100644 --- a/app/javascript/controllers/form_projects_selector_controller.js +++ b/app/javascript/controllers/dependent_frames_selector_controller.js @@ -1,16 +1,18 @@ import { Controller } from '@hotwired/stimulus' // TODO: unite with ProjectsSelectorController? -class FormProjectsSelectorController extends Controller { +class DependentFramesSelectorController extends Controller { + static values = {'param': String} + static targets = ['frame'] - changeProject(event) { + refresh(event) { const loc = new URL(location) const selected = event.target.selectedOptions[0] - const code = selected.dataset.code - loc.searchParams.set('project', code) + const value = selected.dataset.code || selected.value + loc.searchParams.set(this.paramValue, value) Turbo.visit(loc.toString(), {frame: this.frameTarget}) } } -export default FormProjectsSelectorController +export default DependentFramesSelectorController diff --git a/app/models/workflow.rb b/app/models/workflow.rb index a39f941..28d75ff 100644 --- a/app/models/workflow.rb +++ b/app/models/workflow.rb @@ -4,7 +4,7 @@ class Workflow < ApplicationRecord belongs_to :project has_many :tasks, dependent: :restrict_with_exception - has_many :statuses, dependent: :restrict_with_error + has_many :statuses, class_name: 'TaskStatus', dependent: :restrict_with_error enum :icon, { task: 'task', warning: 'warning' }, default: 'task', scopes: false enum :color, { blue: 'blue', gray: 'gray', yellow: 'yellow', red: 'red' }, default: 'gray', scopes: false diff --git a/app/views/tasks/_table_row.html.slim b/app/views/tasks/_table_row.html.slim index e359c9b..562e0af 100644 --- a/app/views/tasks/_table_row.html.slim +++ b/app/views/tasks/_table_row.html.slim @@ -2,7 +2,10 @@ tr id="task_#{task.id}" td / TODO: extract into a presenter (and especially add color) - = image_tag('mingcute/task_line.svg', title: task.workflow.name) + - wf_icon = 'task_line' + - if task.workflow.icon == 'warning' + - wf_icon = 'warning_line' + = image_tag("mingcute/#{wf_icon}.svg", title: task.workflow.name) 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 df2dbcf..e40c8c3 100644 --- a/app/views/tasks/new.html.slim +++ b/app/views/tasks/new.html.slim @@ -1,17 +1,19 @@ h1 New task -= form_with model: @form, data: {controller: 'form-projects-selector'} 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 :project_field_selects, data: {'form-projects-selector-target': 'frame'} do - .field - = form.label :workflow - = form.select :workflow_id, Workflow.where(project: @form.project).map { |w| [w.name, w.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, Workflow.where(project: @form.project).map { |w| [w.name, w.id] }, {}, data: {action: 'dependent-frames-selector#refresh'} - .field - = form.label :status_id - = form.select :status_id, TaskStatus.where(project: @form.project).default_order.map { |ts| [ts.name, ts.id] } + = 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 From eb9f3e982c9606245d6963b16fb72ff4de0e0a1d Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Sat, 21 Mar 2026 15:35:09 +0200 Subject: [PATCH 04/19] Untie statuses from the project directly (tie to workflows) --- app/controllers/tasks_controller.rb | 2 +- app/models/project.rb | 2 +- app/models/task_status.rb | 4 ++-- app/models/workflow.rb | 2 +- app/view_models/tasks/statuses/selector_view_model.rb | 7 +++---- app/views/tasks/_status_selector.html.slim | 2 +- .../20260321132125_change_task_statuses_uniqueness.rb | 9 +++++++++ ...1132550_add_null_false_to_task_statuses_workflow.rb | 5 +++++ ...0260321132740_drop_project_id_from_task_statuses.rb | 5 +++++ db/schema.rb | 10 +++------- 10 files changed, 31 insertions(+), 17 deletions(-) create mode 100644 db/migrate/20260321132125_change_task_statuses_uniqueness.rb create mode 100644 db/migrate/20260321132550_add_null_false_to_task_statuses_workflow.rb create mode 100644 db/migrate/20260321132740_drop_project_id_from_task_statuses.rb diff --git a/app/controllers/tasks_controller.rb b/app/controllers/tasks_controller.rb index 39597ff..d766283 100644 --- a/app/controllers/tasks_controller.rb +++ b/app/controllers/tasks_controller.rb @@ -11,7 +11,7 @@ class TasksController < ApplicationController Task.all end - @tasks = @tasks.includes(:status, :workflow, project: :task_statuses) + @tasks = @tasks.includes(:status, :project, workflow: :task_statuses) end def show; end diff --git a/app/models/project.rb b/app/models/project.rb index 50799f3..386bb4b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -7,8 +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_status.rb b/app/models/task_status.rb index e2f43ea..6aeae6f 100644 --- a/app/models/task_status.rb +++ b/app/models/task_status.rb @@ -1,12 +1,12 @@ # 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 } - validates :name, presence: true, uniqueness: { scope: :project } + validates :name, presence: true, uniqueness: { scope: :workflow } validates :category, presence: true validate :associations_should_have_same_project diff --git a/app/models/workflow.rb b/app/models/workflow.rb index 28d75ff..102856d 100644 --- a/app/models/workflow.rb +++ b/app/models/workflow.rb @@ -4,7 +4,7 @@ class Workflow < ApplicationRecord belongs_to :project has_many :tasks, dependent: :restrict_with_exception - has_many :statuses, class_name: 'TaskStatus', dependent: :restrict_with_error + has_many :task_statuses, dependent: :restrict_with_error enum :icon, { task: 'task', warning: 'warning' }, default: 'task', scopes: false enum :color, { blue: 'blue', gray: 'gray', yellow: 'yellow', red: 'red' }, default: 'gray', scopes: false diff --git a/app/view_models/tasks/statuses/selector_view_model.rb b/app/view_models/tasks/statuses/selector_view_model.rb index dc5b401..b9ad4e4 100644 --- a/app/view_models/tasks/statuses/selector_view_model.rb +++ b/app/view_models/tasks/statuses/selector_view_model.rb @@ -17,16 +17,15 @@ module Tasks view_context.render( partial: 'tasks/status_selector', locals: { task: @task, id: dom_id, with_form: @with_form, - project_task_statuses:, + workflow_task_statuses:, task_status_badge: ->(status) { task_status_badge(status, view_context) }} ) end private - def project_task_statuses - # TODO: refactor because it causes N+1 (task statuses loaded separately) - @task.project.task_statuses.default_order + def workflow_task_statuses + @task.workflow.task_statuses.sort_by { |e| [e.category, e.name] } end def task_status_badge(status, view_context) diff --git a/app/views/tasks/_status_selector.html.slim b/app/views/tasks/_status_selector.html.slim index c91c34b..957c2d8 100644 --- a/app/views/tasks/_status_selector.html.slim +++ b/app/views/tasks/_status_selector.html.slim @@ -1,7 +1,7 @@ details.dropdown.task-status-selector id=id data-controller="tasks--status-selector" 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] - if with_form 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/schema.rb b/db/schema.rb index a124fed..6cfd8d2 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_21_115521) do +ActiveRecord::Schema[8.1].define(version: 2026_03_21_132740) 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 @@ -73,12 +73,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_21_115521) do t.integer "category", limit: 2, null: false, unsigned: true t.datetime "created_at", null: false t.string "name", null: false - t.bigint "project_id", null: false t.datetime "updated_at", null: false - t.bigint "workflow_id" - 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.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 @@ -117,7 +114,6 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_21_115521) do 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 "tasks", "projects" add_foreign_key "tasks", "task_statuses", column: "status_id" add_foreign_key "workflows", "projects" From 72af5a06102fd33050cda7d62a172f9467de05d4 Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Sat, 21 Mar 2026 19:57:01 +0200 Subject: [PATCH 05/19] Workflow display styling --- app/assets/stylesheets/tasks.css | 6 ++++ app/assets/stylesheets/workflows.css | 26 +++++++++++++++++ app/helpers/workflows_helper.rb | 8 ++++++ .../tasks/statuses/selector_view_model.rb | 2 +- .../workflows/display_view_model.rb | 28 +++++++++++++++++++ app/views/tasks/_table_row.html.slim | 7 +---- app/views/tasks/show.html.slim | 1 + app/views/workflows/_display.html.slim | 4 +++ 8 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 app/assets/stylesheets/workflows.css create mode 100644 app/helpers/workflows_helper.rb create mode 100644 app/view_models/workflows/display_view_model.rb create mode 100644 app/views/workflows/_display.html.slim diff --git a/app/assets/stylesheets/tasks.css b/app/assets/stylesheets/tasks.css index 3c5a50f..392529b 100644 --- a/app/assets/stylesheets/tasks.css +++ b/app/assets/stylesheets/tasks.css @@ -28,4 +28,10 @@ .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..7bc05a3 --- /dev/null +++ b/app/assets/stylesheets/workflows.css @@ -0,0 +1,26 @@ +.workflow { + --bg-color: #D1D5DB; /* Pico zinc 150 */ + --text-color: oklch(from var(--bg-color) calc(0.55 * l) calc(0.55 * c) h); + + border-radius: 1em; + background-color: var(--bg-color); + color: var(--text-color); + + display: flex; + flex-flow: row nowrap; + justify-content: center; + min-width: 2em; + padding: 0.1em 0.5em; + gap: 0.5em; + text-align: center; + + > img { + max-width: unset; + width: 1lh; + opacity: 0.6; + } + + &.red { + --bg-color: #F06048; /* Pico red 400 */ + } +} 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/view_models/tasks/statuses/selector_view_model.rb b/app/view_models/tasks/statuses/selector_view_model.rb index b9ad4e4..64fd319 100644 --- a/app/view_models/tasks/statuses/selector_view_model.rb +++ b/app/view_models/tasks/statuses/selector_view_model.rb @@ -18,7 +18,7 @@ module Tasks partial: 'tasks/status_selector', locals: { task: @task, id: dom_id, with_form: @with_form, workflow_task_statuses:, - task_status_badge: ->(status) { task_status_badge(status, view_context) }} + task_status_badge: ->(status) { task_status_badge(status, view_context) } } ) 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..9501dcb --- /dev/null +++ b/app/view_models/workflows/display_view_model.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Workflows + class DisplayViewModel + DEFAULT_ICON = 'task_line' + ICONS = { + warning: 'warning_line' + }.freeze + + def initialize(workflow, full: false) + @workflow = workflow + @full = full + end + + def icon + icon = ICONS.fetch(@workflow.icon.to_sym, DEFAULT_ICON) + + "mingcute/#{icon}.svg" + end + + def render_in(view_context) + view_context.render( + partial: 'workflows/display', + locals: @workflow.attributes.symbolize_keys.merge(icon:, full: @full) + ) + end + end +end diff --git a/app/views/tasks/_table_row.html.slim b/app/views/tasks/_table_row.html.slim index 562e0af..76cfae5 100644 --- a/app/views/tasks/_table_row.html.slim +++ b/app/views/tasks/_table_row.html.slim @@ -1,11 +1,6 @@ - cache task do tr id="task_#{task.id}" - td - / TODO: extract into a presenter (and especially add color) - - wf_icon = 'task_line' - - if task.workflow.icon == 'warning' - - wf_icon = 'warning_line' - = image_tag("mingcute/#{wf_icon}.svg", title: task.workflow.name) + td= workflow_display task.workflow td= link_to task.full_number, task_path(task) td = task_status_selector task, with_form: true diff --git a/app/views/tasks/show.html.slim b/app/views/tasks/show.html.slim index aaade18..07292a6 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 = 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..5be1e6f --- /dev/null +++ b/app/views/workflows/_display.html.slim @@ -0,0 +1,4 @@ +div.workflow class=color title=name + = image_tag(icon) + - if full + span= name From 1c1d11cbe6b0f0b44b21bccf964a58259ef5b584 Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Sun, 22 Mar 2026 22:50:37 +0200 Subject: [PATCH 06/19] Workflows index --- app/assets/stylesheets/application.css | 31 +++++++++++++++++++ app/assets/stylesheets/tabs.css | 28 +++++++++++++++++ app/assets/stylesheets/workflows.css | 21 ++++++------- .../project_admin/application_controller.rb | 13 ++++++++ .../project_admin/workflows_controller.rb | 9 ++++++ app/helpers/application_helper.rb | 10 ++++++ app/helpers/project_admin/workflows_helper.rb | 2 ++ app/helpers/project_admin_helper.rb | 11 +++++++ .../project_admin/tabs_view_model.rb | 29 +++++++++++++++++ app/views/project_admin/_frame.html.slim | 11 +++++++ app/views/project_admin/_tabs.html.slim | 3 ++ .../project_admin/workflows/index.html.slim | 9 ++++++ app/views/projects/edit.html.slim | 8 ++--- app/views/workflows/_display.html.slim | 4 +-- config/routes.rb | 7 ++++- .../images/mingcute/external_link_line.svg | 1 + 16 files changed, 179 insertions(+), 18 deletions(-) create mode 100644 app/assets/stylesheets/tabs.css create mode 100644 app/controllers/project_admin/application_controller.rb create mode 100644 app/controllers/project_admin/workflows_controller.rb create mode 100644 app/helpers/project_admin/workflows_helper.rb create mode 100644 app/helpers/project_admin_helper.rb create mode 100644 app/view_models/project_admin/tabs_view_model.rb create mode 100644 app/views/project_admin/_frame.html.slim create mode 100644 app/views/project_admin/_tabs.html.slim create mode 100644 app/views/project_admin/workflows/index.html.slim create mode 100644 vendor/assets/images/mingcute/external_link_line.svg diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index f296340..305c4b0 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -82,3 +82,34 @@ table { 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; + } +} + +.mask-icon { + display: inline-block; + height: 1lh; + width: 1lh; + background-color: var(--icon-color, black); + mask-image: var(--icon, url("mingcute/task_line.svg")); + mask-size: 100%; +} 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/workflows.css b/app/assets/stylesheets/workflows.css index 7bc05a3..7018909 100644 --- a/app/assets/stylesheets/workflows.css +++ b/app/assets/stylesheets/workflows.css @@ -1,26 +1,25 @@ .workflow { - --bg-color: #D1D5DB; /* Pico zinc 150 */ - --text-color: oklch(from var(--bg-color) calc(0.55 * l) calc(0.55 * c) h); + --color: #424751; /* Pico zinc 700 */ border-radius: 1em; - background-color: var(--bg-color); - color: var(--text-color); + outline: 2px solid var(--color); - display: flex; - flex-flow: row nowrap; - justify-content: center; + display: inline-flex; + gap: 0.25em; min-width: 2em; padding: 0.1em 0.5em; - gap: 0.5em; - text-align: center; + + > .mask-icon { + --icon-color: var(--color); + } > img { max-width: unset; width: 1lh; - opacity: 0.6; + opacity: 0.75; } &.red { - --bg-color: #F06048; /* Pico red 400 */ + --color: #BD3C13; /* Pico orange 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..bc023f8 --- /dev/null +++ b/app/controllers/project_admin/application_controller.rb @@ -0,0 +1,13 @@ +# 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]) + 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..a69b762 --- /dev/null +++ b/app/controllers/project_admin/workflows_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ProjectAdmin + class WorkflowsController < ApplicationController + def index + @workflows = @project.workflows + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 84f8109..eef67dc 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -6,4 +6,14 @@ module ApplicationHelper url_for(controller: controller_name, action: :index, project:) end + + def mask_icon(icon, **options) + # Renders a span as a masked icon + case options[:class] + when String then options[:class] += ' mask-icon' + when nil then options[:class] = 'mask-icon' + else options[:class] = Array(options[:class]) + ['mask-icon'] + end + content_tag(:span, '', style: "--icon: url(#{image_path(icon)})", **options) + end 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..9e46b92 --- /dev/null +++ b/app/helpers/project_admin_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ProjectAdminHelper + def project_admin_frame(project, &) + tabs_id = 'project_admin_tabs' + tabs = ProjectAdmin::TabsViewModel.new(project, frame: :project_admin, id: tabs_id) + content = capture(&) if block_given? + + render partial: 'project_admin/frame', locals: { id: :project_admin, tabs:, tabs_id:, content: } + 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..7756f42 --- /dev/null +++ b/app/view_models/project_admin/tabs_view_model.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ProjectAdmin + class TabsViewModel + include Rails.application.routes.url_helpers + + def initialize(project, id:, frame:) + @project = project + @id = id + @frame = frame + end + + def render_in(view_context) + view_context.render( + partial: 'project_admin/tabs', + locals: { project: @project, links:, id: @id, frame: @frame } + ) + end + + private + + def links + { + 'Data' => edit_project_path(@project), + 'Workflows' => project_workflows_path(@project) + } + 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..1087df0 --- /dev/null +++ b/app/views/project_admin/_frame.html.slim @@ -0,0 +1,11 @@ +h1 + span> Project + span= @project.name + += link_to '← Back', project_path(@project) + += render tabs + += turbo_frame_tag(id) do + = content + = 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/index.html.slim b/app/views/project_admin/workflows/index.html.slim new file mode 100644 index 0000000..80c82b9 --- /dev/null +++ b/app/views/project_admin/workflows/index.html.slim @@ -0,0 +1,9 @@ += project_admin_frame(@project) do + h2 + span Workflows + + - @workflows.each do |workflow| + article.row + = workflow_display(workflow, full: true) + ul.links.right + li= link_to 'Edit', edit_project_workflow_path(@project, workflow), target: '_blank' 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/workflows/_display.html.slim b/app/views/workflows/_display.html.slim index 5be1e6f..6e72610 100644 --- a/app/views/workflows/_display.html.slim +++ b/app/views/workflows/_display.html.slim @@ -1,4 +1,4 @@ div.workflow class=color title=name - = image_tag(icon) + = mask_icon(icon) - if full - span= name + span<= name diff --git a/config/routes.rb b/config/routes.rb index 86a6e85..da6724a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,7 +16,12 @@ Rails.application.routes.draw do # Defines the root path route ("/") # root "posts#index" - resources :projects + resources :projects do + scope module: :project_admin do + resources :workflows, only: %i[index new create edit update destroy] + end + end + resources :tasks do patch :change_status, on: :member end 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..63a2fc0 --- /dev/null +++ b/vendor/assets/images/mingcute/external_link_line.svg @@ -0,0 +1 @@ + \ No newline at end of file From 83b296dbe7c070db5f3b4e85712ebf068ef880a7 Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Tue, 24 Mar 2026 00:28:14 +0200 Subject: [PATCH 07/19] Changed workflows rendering again --- app/assets/stylesheets/application.css | 20 ------------- app/assets/stylesheets/badge.css | 13 ++++++++ app/assets/stylesheets/mask-icon.css | 13 ++++++++ app/assets/stylesheets/tasks.css | 6 ++++ app/assets/stylesheets/workflows.css | 30 +++++++++++-------- app/helpers/application_helper.rb | 13 ++++---- app/models/task_status.rb | 9 ------ app/models/workflow.rb | 2 +- .../workflows/display_view_model.rb | 11 +++---- app/views/project_admin/_frame.html.slim | 3 +- app/views/tasks/_table_row.html.slim | 2 +- app/views/tasks/show.html.slim | 2 +- app/views/workflows/_display.html.slim | 2 +- 13 files changed, 67 insertions(+), 59 deletions(-) create mode 100644 app/assets/stylesheets/badge.css create mode 100644 app/assets/stylesheets/mask-icon.css diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 305c4b0..a4cbd89 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -65,17 +65,6 @@ 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 { @@ -104,12 +93,3 @@ a[target=_blank]::after { margin-left: auto; } } - -.mask-icon { - display: inline-block; - height: 1lh; - width: 1lh; - background-color: var(--icon-color, black); - mask-image: var(--icon, url("mingcute/task_line.svg")); - mask-size: 100%; -} 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/tasks.css b/app/assets/stylesheets/tasks.css index 392529b..c754dff 100644 --- a/app/assets/stylesheets/tasks.css +++ b/app/assets/stylesheets/tasks.css @@ -9,6 +9,12 @@ --color: var(--backlog-color); --background-color: var(--backlog-bg); + &.badge { + border: 2px solid var(--border-color, currentColor); + color: var(--color, currentColor); + background-color: var(--background-color); + } + &.analysis { --color: var(--analysis-color); --background-color: var(--analysis-bg); diff --git a/app/assets/stylesheets/workflows.css b/app/assets/stylesheets/workflows.css index 7018909..d5c9f4d 100644 --- a/app/assets/stylesheets/workflows.css +++ b/app/assets/stylesheets/workflows.css @@ -1,25 +1,29 @@ .workflow { --color: #424751; /* Pico zinc 700 */ + --icon: url("mingcute/task_line.svg"); - border-radius: 1em; - outline: 2px solid var(--color); - - display: inline-flex; - gap: 0.25em; - min-width: 2em; - padding: 0.1em 0.5em; + &.badge { + outline: 2px solid var(--color); + color: var(--color); + } > .mask-icon { --icon-color: var(--color); } - > img { - max-width: unset; - width: 1lh; - opacity: 0.75; - } - &.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/helpers/application_helper.rb b/app/helpers/application_helper.rb index eef67dc..29e28f5 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -8,12 +8,11 @@ module ApplicationHelper end def mask_icon(icon, **options) - # Renders a span as a masked icon - case options[:class] - when String then options[:class] += ' mask-icon' - when nil then options[:class] = 'mask-icon' - else options[:class] = Array(options[:class]) + ['mask-icon'] - end - content_tag(:span, '', style: "--icon: url(#{image_path(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/models/task_status.rb b/app/models/task_status.rb index 6aeae6f..7bc4582 100644 --- a/app/models/task_status.rb +++ b/app/models/task_status.rb @@ -8,15 +8,6 @@ class TaskStatus < ApplicationRecord validates :name, presence: true, uniqueness: { scope: :workflow } validates :category, presence: true - validate :associations_should_have_same_project scope :default_order, -> { order(:category, :name) } - - private - - def associations_should_have_same_project - return if workflow.project == project - - errors.add(:workflow, "Doesn't belong in the same project") - end end diff --git a/app/models/workflow.rb b/app/models/workflow.rb index 102856d..ec930ef 100644 --- a/app/models/workflow.rb +++ b/app/models/workflow.rb @@ -7,5 +7,5 @@ class Workflow < ApplicationRecord has_many :task_statuses, dependent: :restrict_with_error enum :icon, { task: 'task', warning: 'warning' }, default: 'task', scopes: false - enum :color, { blue: 'blue', gray: 'gray', yellow: 'yellow', red: 'red' }, default: 'gray', scopes: false + enum :color, { blue: 'blue', gray: 'gray', lime: 'lime', red: 'red', teal: 'teal' }, default: 'gray', scopes: false end diff --git a/app/view_models/workflows/display_view_model.rb b/app/view_models/workflows/display_view_model.rb index 9501dcb..d5d709b 100644 --- a/app/view_models/workflows/display_view_model.rb +++ b/app/view_models/workflows/display_view_model.rb @@ -7,21 +7,22 @@ module Workflows warning: 'warning_line' }.freeze - def initialize(workflow, full: false) + def initialize(workflow, badge: false, full: false) @workflow = workflow + @badge = badge @full = full end - def icon - icon = ICONS.fetch(@workflow.icon.to_sym, DEFAULT_ICON) + attr_reader :full, :badge - "mingcute/#{icon}.svg" + 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(icon:, full: @full) + locals: @workflow.attributes.symbolize_keys.merge(full:, badge:, icon:) ) end end diff --git a/app/views/project_admin/_frame.html.slim b/app/views/project_admin/_frame.html.slim index 1087df0..81337f8 100644 --- a/app/views/project_admin/_frame.html.slim +++ b/app/views/project_admin/_frame.html.slim @@ -8,4 +8,5 @@ h1 = turbo_frame_tag(id) do = content - = turbo_stream.replace(tabs_id, tabs) + - if turbo_frame_request? + = turbo_stream.replace(tabs_id, tabs) diff --git a/app/views/tasks/_table_row.html.slim b/app/views/tasks/_table_row.html.slim index 76cfae5..13c81c6 100644 --- a/app/views/tasks/_table_row.html.slim +++ b/app/views/tasks/_table_row.html.slim @@ -1,6 +1,6 @@ - cache task do tr id="task_#{task.id}" - td= workflow_display task.workflow + 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/show.html.slim b/app/views/tasks/show.html.slim index 07292a6..fac8421 100644 --- a/app/views/tasks/show.html.slim +++ b/app/views/tasks/show.html.slim @@ -3,7 +3,7 @@ div h1= @task.title section.task-show-info - = workflow_display @task.workflow, full: true + = 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 index 6e72610..915df42 100644 --- a/app/views/workflows/_display.html.slim +++ b/app/views/workflows/_display.html.slim @@ -1,4 +1,4 @@ -div.workflow class=color title=name +div.workflow class=[color, ('badge' if badge)] title=name = mask_icon(icon) - if full span<= name From 32a7b07f2d1b5dc5f7614925813d918d10110e12 Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Sun, 29 Mar 2026 14:23:26 +0300 Subject: [PATCH 08/19] Workflow info and update --- .../project_admin/workflows_controller.rb | 31 +++++++++++++++++++ .../project_admin/workflows/update.rb | 27 ++++++++++++++++ .../project_admin/tabs_view_model.rb | 2 +- .../project_admin/workflows/_form.html.slim | 14 +++++++++ .../project_admin/workflows/edit.html.slim | 5 +++ .../project_admin/workflows/index.html.slim | 3 +- .../project_admin/workflows/show.html.slim | 8 +++++ config/routes.rb | 11 +++++-- 8 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 app/services/project_admin/workflows/update.rb create mode 100644 app/views/project_admin/workflows/_form.html.slim create mode 100644 app/views/project_admin/workflows/edit.html.slim create mode 100644 app/views/project_admin/workflows/show.html.slim diff --git a/app/controllers/project_admin/workflows_controller.rb b/app/controllers/project_admin/workflows_controller.rb index a69b762..fb62575 100644 --- a/app/controllers/project_admin/workflows_controller.rb +++ b/app/controllers/project_admin/workflows_controller.rb @@ -2,8 +2,39 @@ 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 edit + @form = ProjectAdmin::Workflows::Update.new( + @workflow.attributes.slice('name', 'icon', 'color') + ) + end + + def update + @form = ProjectAdmin::Workflows::Update.new(params.expect(workflow: %i[name color icon])) + + if @form.perform(@workflow) + 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 end end diff --git a/app/services/project_admin/workflows/update.rb b/app/services/project_admin/workflows/update.rb new file mode 100644 index 0000000..e48c297 --- /dev/null +++ b/app/services/project_admin/workflows/update.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ProjectAdmin + module Workflows + class Update < ApplicationService + attribute :id, :integer + attribute :name, :string + attribute :icon, :string + attribute :color, :string + + validates :name, presence: true + validates :icon, inclusion: { in: Workflow.icons.values } + validates :color, inclusion: { in: Workflow.colors.values } + + delegate :model_name, to: Workflow + + def persisted? = true + + def perform(workflow) + @workflow = workflow + @id = workflow.id + @workflow.assign_attributes(icon:, color:, name:) + save @workflow + end + end + end +end diff --git a/app/view_models/project_admin/tabs_view_model.rb b/app/view_models/project_admin/tabs_view_model.rb index 7756f42..a1d16ff 100644 --- a/app/view_models/project_admin/tabs_view_model.rb +++ b/app/view_models/project_admin/tabs_view_model.rb @@ -22,7 +22,7 @@ module ProjectAdmin def links { 'Data' => edit_project_path(@project), - 'Workflows' => project_workflows_path(@project) + 'Workflows' => project_admin_workflows_path(@project) } end end 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..fa0185f --- /dev/null +++ b/app/views/project_admin/workflows/_form.html.slim @@ -0,0 +1,14 @@ += form_with model: @form, url: url 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..80e3c3c --- /dev/null +++ b/app/views/project_admin/workflows/edit.html.slim @@ -0,0 +1,5 @@ +h1 + | Editing workflow + =< @workflow.name + += 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 index 80c82b9..a220a01 100644 --- a/app/views/project_admin/workflows/index.html.slim +++ b/app/views/project_admin/workflows/index.html.slim @@ -6,4 +6,5 @@ article.row = workflow_display(workflow, full: true) ul.links.right - li= link_to 'Edit', edit_project_workflow_path(@project, workflow), target: '_blank' + 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/show.html.slim b/app/views/project_admin/workflows/show.html.slim new file mode 100644 index 0000000..43e2976 --- /dev/null +++ b/app/views/project_admin/workflows/show.html.slim @@ -0,0 +1,8 @@ +h1 + | Workflow + += workflow_display @workflow, full: true, badge: true + +ul + - @workflow.task_statuses.each do |status| + li= status.name diff --git a/config/routes.rb b/config/routes.rb index da6724a..b4550d2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,9 +16,16 @@ Rails.application.routes.draw do # Defines the root path route ("/") # root "posts#index" + root to: redirect('/projects') + resources :projects do - scope module: :project_admin do - resources :workflows, only: %i[index new create edit update destroy] + namespace :project_admin, as: 'admin' do + resources :workflows do + namespace :statuses do + get '/', action: :index + patch '/', action: :batch_update + end + end end end From 138f1c42a0e37e215372311f19f2fa719e63de50 Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Tue, 31 Mar 2026 01:33:58 +0300 Subject: [PATCH 09/19] More generic rework of the project admin frame Plus a minor fix on project edit --- app/helpers/project_admin_helper.rb | 18 +++++++++++++++--- .../project_admin/tabs_view_model.rb | 15 +++------------ app/views/projects/_form.html.slim | 2 +- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/app/helpers/project_admin_helper.rb b/app/helpers/project_admin_helper.rb index 9e46b92..d3f385c 100644 --- a/app/helpers/project_admin_helper.rb +++ b/app/helpers/project_admin_helper.rb @@ -1,11 +1,23 @@ # frozen_string_literal: true module ProjectAdminHelper + FRAME_ID = 'admin_frame' + TABS_ID = 'admin_tabs' + def project_admin_frame(project, &) - tabs_id = 'project_admin_tabs' - tabs = ProjectAdmin::TabsViewModel.new(project, frame: :project_admin, id: tabs_id) + links = { + 'Data' => edit_project_path(project), + 'Workflows' => project_admin_workflows_path(project) + } + + title = "Project #{project.name}" + admin_frame(links, title:, &) + end + + def admin_frame(links, title:, &) + tabs = ProjectAdmin::TabsViewModel.new(links, id: TABS_ID, frame: FRAME_ID) content = capture(&) if block_given? - render partial: 'project_admin/frame', locals: { id: :project_admin, tabs:, tabs_id:, content: } + render partial: 'project_admin/frame', locals: { id: FRAME_ID, tabs:, tabs_id: TABS_ID, title:, content: } end end diff --git a/app/view_models/project_admin/tabs_view_model.rb b/app/view_models/project_admin/tabs_view_model.rb index a1d16ff..fa528b5 100644 --- a/app/view_models/project_admin/tabs_view_model.rb +++ b/app/view_models/project_admin/tabs_view_model.rb @@ -4,8 +4,8 @@ module ProjectAdmin class TabsViewModel include Rails.application.routes.url_helpers - def initialize(project, id:, frame:) - @project = project + def initialize(links, id:, frame:) + @links = links @id = id @frame = frame end @@ -13,17 +13,8 @@ module ProjectAdmin def render_in(view_context) view_context.render( partial: 'project_admin/tabs', - locals: { project: @project, links:, id: @id, frame: @frame } + locals: { project: @project, id: @id, frame: @frame, links: @links } ) end - - private - - def links - { - 'Data' => edit_project_path(@project), - 'Workflows' => project_admin_workflows_path(@project) - } - end end end 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 From 9766430e69eeca2e885aea5faf7191122478b3c9 Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Mon, 6 Apr 2026 02:30:33 +0300 Subject: [PATCH 10/19] Task status model rework Replaced categories with colors, icons and positions --- app/assets/stylesheets/application.css | 4 --- app/assets/stylesheets/tasks.css | 28 +++++++++++++------ app/models/task_status.rb | 5 ++-- .../tasks/statuses/selector_view_model.rb | 20 +++++++++++-- ...5813_replace_old_status_fields_with_new.rb | 11 ++++++++ db/schema.rb | 6 ++-- lib/tasks/data_migrations.rake | 7 +++++ .../images/mingcute/add_circle_fill.svg | 1 + .../assets/images/mingcute/archive_line.svg | 1 + vendor/assets/images/mingcute/checks_line.svg | 1 + .../images/mingcute/circle_dash_line.svg | 1 + .../images/mingcute/external_link_line.svg | 2 +- .../assets/images/mingcute/hamburger_line.svg | 2 +- vendor/assets/images/mingcute/hammer_fill.svg | 1 + .../images/mingcute/play_circle_fill.svg | 1 + vendor/assets/images/mingcute/right_fill.svg | 2 +- .../assets/images/mingcute/signature_line.svg | 1 + vendor/assets/images/mingcute/task_line.svg | 2 +- vendor/assets/images/mingcute/tool_line.svg | 1 + .../assets/images/mingcute/warning_line.svg | 2 +- 20 files changed, 74 insertions(+), 25 deletions(-) create mode 100644 db/migrate/20260404215813_replace_old_status_fields_with_new.rb create mode 100644 vendor/assets/images/mingcute/add_circle_fill.svg create mode 100644 vendor/assets/images/mingcute/archive_line.svg create mode 100644 vendor/assets/images/mingcute/checks_line.svg create mode 100644 vendor/assets/images/mingcute/circle_dash_line.svg create mode 100644 vendor/assets/images/mingcute/hammer_fill.svg create mode 100644 vendor/assets/images/mingcute/play_circle_fill.svg create mode 100644 vendor/assets/images/mingcute/signature_line.svg create mode 100644 vendor/assets/images/mingcute/tool_line.svg diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index a4cbd89..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 */ } diff --git a/app/assets/stylesheets/tasks.css b/app/assets/stylesheets/tasks.css index c754dff..6396fc6 100644 --- a/app/assets/stylesheets/tasks.css +++ b/app/assets/stylesheets/tasks.css @@ -6,8 +6,8 @@ } .task-status { - --color: var(--backlog-color); - --background-color: var(--backlog-bg); + --color: #424751; /* Pico zinc 700 */ + --background-color: #E0E3E7; /* Pico zinc 100 */ &.badge { border: 2px solid var(--border-color, currentColor); @@ -15,20 +15,30 @@ background-color: var(--background-color); } - &.analysis { - --color: var(--analysis-color); - --background-color: var(--analysis-bg); + &.blue { + --color: #1D59D0; /* Pico blue 600 */ + --background-color: #E0E1FA/* Pico blue 100 */ } - &.development { - --color: var(--info-color); - --background-color: var(--info-bg); + &.yellow { + --background-color: #FDDEA6; /* Pico amber 100 */ + --border-color: #A77C00; /* Pico amber 450 */ } - &.fulfillment { + &.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 { diff --git a/app/models/task_status.rb b/app/models/task_status.rb index 7bc4582..847d9d3 100644 --- a/app/models/task_status.rb +++ b/app/models/task_status.rb @@ -4,10 +4,11 @@ class TaskStatus < ApplicationRecord belongs_to :workflow has_one :project, through: :workflow - enum :category, { backlog: 100, analysis: 1000, development: 20_000, fulfillment: 60_000 } + 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 validates :name, presence: true, uniqueness: { scope: :workflow } validates :category, presence: true - scope :default_order, -> { order(:category, :name) } + scope :default_order, -> { order(:position, :name) } end diff --git a/app/view_models/tasks/statuses/selector_view_model.rb b/app/view_models/tasks/statuses/selector_view_model.rb index 64fd319..91824e1 100644 --- a/app/view_models/tasks/statuses/selector_view_model.rb +++ b/app/view_models/tasks/statuses/selector_view_model.rb @@ -3,6 +3,16 @@ 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 initialize(task, with_form: false) @task = task @with_form = with_form @@ -25,15 +35,19 @@ module Tasks private def workflow_task_statuses - @task.workflow.task_statuses.sort_by { |e| [e.category, e.name] } + @task.workflow.task_statuses.sort_by { |e| [e.position, e.name] } end def task_status_badge(status, view_context) view_context.content_tag( - :span, status.name, - class: ['badge', 'task-status', status.category.dasherize] + :span, view_context.mask_icon(icon(status)) + status.name, + class: ['badge', 'task-status', status.color] ) end + + def icon(status) + ICONS.fetch(status.icon.to_sym) + end end 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/schema.rb b/db/schema.rb index 6cfd8d2..2fdfb89 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_21_132740) do +ActiveRecord::Schema[8.1].define(version: 2026_04_04_215813) 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 @@ -70,9 +70,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_21_132740) do 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 + 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 diff --git a/lib/tasks/data_migrations.rake b/lib/tasks/data_migrations.rake index 5b42249..ba203ea 100644 --- a/lib/tasks/data_migrations.rake +++ b/lib/tasks/data_migrations.rake @@ -35,4 +35,11 @@ namespace :data_migrations do 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 @@ +circle_dash_line \ 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 index 63a2fc0..a34fb20 100644 --- a/vendor/assets/images/mingcute/external_link_line.svg +++ b/vendor/assets/images/mingcute/external_link_line.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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 index e303c87..58b6fb8 100644 --- a/vendor/assets/images/mingcute/task_line.svg +++ b/vendor/assets/images/mingcute/task_line.svg @@ -1 +1 @@ - \ No newline at end of file + \ 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 index 5ecd642..06c8f48 100644 --- a/vendor/assets/images/mingcute/warning_line.svg +++ b/vendor/assets/images/mingcute/warning_line.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From 450b7fb1d017a37b73470ea24c3d3616b3dac165 Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Mon, 13 Apr 2026 02:22:52 +0300 Subject: [PATCH 11/19] Workflow statuses editing --- .../workflows/statuses_controller.rb | 34 ++++++++++++ .../workflows/statuses_helper.rb | 2 + app/helpers/tasks_helper.rb | 8 +++ app/models/task_status.rb | 1 - .../workflows/statuses/batch_update.rb | 55 +++++++++++++++++++ .../tasks/statuses/selector_view_model.rb | 20 +++---- .../project_admin/workflows/show.html.slim | 2 +- .../workflows/statuses/edit.html.slim | 16 ++++++ app/views/tasks/_status_selector.html.slim | 4 +- config/routes.rb | 8 ++- 10 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 app/controllers/project_admin/workflows/statuses_controller.rb create mode 100644 app/helpers/project_admin/workflows/statuses_helper.rb create mode 100644 app/services/project_admin/workflows/statuses/batch_update.rb create mode 100644 app/views/project_admin/workflows/statuses/edit.html.slim 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..c329d92 --- /dev/null +++ b/app/controllers/project_admin/workflows/statuses_controller.rb @@ -0,0 +1,34 @@ +# 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 project_admin_workflow_path(@project, @workflow) + else + render :edit + end + end + + private + + def fetch_workflow + @workflow = @project.workflows.find(params[:workflow_id]) + end + end + 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/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/models/task_status.rb b/app/models/task_status.rb index 847d9d3..6f516c9 100644 --- a/app/models/task_status.rb +++ b/app/models/task_status.rb @@ -8,7 +8,6 @@ class TaskStatus < ApplicationRecord enum :color, %w[blue gray yellow green purple pink].index_by(&:itself), default: 'gray', scopes: false validates :name, presence: true, uniqueness: { scope: :workflow } - validates :category, presence: true scope :default_order, -> { order(:position, :name) } 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..c183228 --- /dev/null +++ b/app/services/project_admin/workflows/statuses/batch_update.rb @@ -0,0 +1,55 @@ +# 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 + 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 + task_status_models = @workflow.task_statuses.index_by(&:id) + + @workflow.transaction(requires_new: true) do + task_statuses.each do |ts| + model = task_status_models.fetch(ts.id.to_i) + if ts._destroy + model.destroy! + else + model.update!( + name: ts.name, + icon: ts.icon, + color: ts.color + ) + end + end + end + + true + end + end + 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 91824e1..5a3c11c 100644 --- a/app/view_models/tasks/statuses/selector_view_model.rb +++ b/app/view_models/tasks/statuses/selector_view_model.rb @@ -13,6 +13,12 @@ module Tasks 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 @@ -27,8 +33,7 @@ module Tasks view_context.render( partial: 'tasks/status_selector', locals: { task: @task, id: dom_id, with_form: @with_form, - workflow_task_statuses:, - task_status_badge: ->(status) { task_status_badge(status, view_context) } } + workflow_task_statuses: } ) end @@ -37,17 +42,6 @@ module Tasks def workflow_task_statuses @task.workflow.task_statuses.sort_by { |e| [e.position, e.name] } end - - def task_status_badge(status, view_context) - view_context.content_tag( - :span, view_context.mask_icon(icon(status)) + status.name, - class: ['badge', 'task-status', status.color] - ) - end - - def icon(status) - ICONS.fetch(status.icon.to_sym) - end end end end diff --git a/app/views/project_admin/workflows/show.html.slim b/app/views/project_admin/workflows/show.html.slim index 43e2976..9c165ac 100644 --- a/app/views/project_admin/workflows/show.html.slim +++ b/app/views/project_admin/workflows/show.html.slim @@ -5,4 +5,4 @@ h1 ul - @workflow.task_statuses.each do |status| - li= status.name + li= task_status_badge(status) 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..1bcfdfe --- /dev/null +++ b/app/views/project_admin/workflows/statuses/edit.html.slim @@ -0,0 +1,16 @@ += form_with model: @form, scope: 'workflow', url: project_admin_workflow_statuses_path(@project, @workflow), method: :put do |f| + = f.fields_for :task_statuses, include_id: false do |tsf| + fieldset + = tsf.hidden_field :id + = tsf.hidden_field :_destroy + .field + = tsf.label :name + = tsf.text_field :name + .field + = tsf.label :color + = tsf.select :color, TaskStatus.colors + .field + = tsf.label :icon + = tsf.select :icon, TaskStatus.icons + .submit + = f.submit diff --git a/app/views/tasks/_status_selector.html.slim b/app/views/tasks/_status_selector.html.slim index 957c2d8..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 - 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/config/routes.rb b/config/routes.rb index b4550d2..6f179b1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,9 +21,11 @@ Rails.application.routes.draw do resources :projects do namespace :project_admin, as: 'admin' do resources :workflows do - namespace :statuses do - get '/', action: :index - patch '/', action: :batch_update + scope module: :workflows do + resources :statuses, only: %i[index] do + get :edit, on: :collection + put '/', action: :batch_update, on: :collection + end end end end From 4040fff780ed4924052ddd50f9d37a41dedc347e Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Tue, 14 Apr 2026 01:26:28 +0300 Subject: [PATCH 12/19] Workflow status creation (extra forms) --- .../stylesheets/project_admin/workflows.css | 15 ++++ .../workflows/statuses_controller.rb | 2 +- .../controllers/dynamic_forms_controller.js | 74 +++++++++++++++++++ .../workflows/statuses/batch_update.rb | 36 ++++++--- .../statuses/_batch_update_fieldset.html.slim | 17 +++++ .../workflows/statuses/edit.html.slim | 26 +++---- 6 files changed, 146 insertions(+), 24 deletions(-) create mode 100644 app/assets/stylesheets/project_admin/workflows.css create mode 100644 app/javascript/controllers/dynamic_forms_controller.js create mode 100644 app/views/project_admin/workflows/statuses/_batch_update_fieldset.html.slim 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/controllers/project_admin/workflows/statuses_controller.rb b/app/controllers/project_admin/workflows/statuses_controller.rb index c329d92..e8e3543 100644 --- a/app/controllers/project_admin/workflows/statuses_controller.rb +++ b/app/controllers/project_admin/workflows/statuses_controller.rb @@ -17,7 +17,7 @@ module ProjectAdmin end @form = ProjectAdmin::Workflows::Statuses::BatchUpdate.new(form_params) - if @form.perform(@workflow) + if @form.call(@workflow) redirect_to project_admin_workflow_path(@project, @workflow) else render :edit 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/services/project_admin/workflows/statuses/batch_update.rb b/app/services/project_admin/workflows/statuses/batch_update.rb index c183228..783745b 100644 --- a/app/services/project_admin/workflows/statuses/batch_update.rb +++ b/app/services/project_admin/workflows/statuses/batch_update.rb @@ -28,27 +28,45 @@ module ProjectAdmin @task_statuses = Array(attributes).map { |e| TaskStatus.new(e) } end - def perform(workflow) + def call(workflow) @workflow = workflow task_status_models = @workflow.task_statuses.index_by(&:id) @workflow.transaction(requires_new: true) do task_statuses.each do |ts| - model = task_status_models.fetch(ts.id.to_i) - if ts._destroy - model.destroy! + if ts.id.start_with?('_') + create_model!(ts) else - model.update!( - name: ts.name, - icon: ts.icon, - color: ts.color - ) + model = task_status_models.fetch(Integer(ts.id)) + if ts._destroy + model.destroy! + else + update_model!(model, ts) + end end end end true end + + private + + def update_model!(model, form) + model.update!( + name: form.name, + icon: form.icon, + color: form.color + ) + end + + def create_model!(form) + @workflow.task_statuses.create!( + name: form.name, + icon: form.icon, + color: form.color + ) + end end end end 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 index 1bcfdfe..c61ddee 100644 --- a/app/views/project_admin/workflows/statuses/edit.html.slim +++ b/app/views/project_admin/workflows/statuses/edit.html.slim @@ -1,16 +1,14 @@ -= form_with model: @form, scope: 'workflow', url: project_admin_workflow_statuses_path(@project, @workflow), method: :put do |f| - = f.fields_for :task_statuses, include_id: false do |tsf| - fieldset - = tsf.hidden_field :id - = tsf.hidden_field :_destroy - .field - = tsf.label :name - = tsf.text_field :name - .field - = tsf.label :color - = tsf.select :color, TaskStatus.colors - .field - = tsf.label :icon - = tsf.select :icon, TaskStatus.icons += 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} 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 From 4fedafd65455e469c516eabd8595e722ef3c217b Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Mon, 20 Apr 2026 00:35:51 +0300 Subject: [PATCH 13/19] Remade with nested attributes --- app/models/workflow.rb | 1 + .../workflows/statuses/batch_update.rb | 49 ++++++------------- 2 files changed, 15 insertions(+), 35 deletions(-) diff --git a/app/models/workflow.rb b/app/models/workflow.rb index ec930ef..dc39ade 100644 --- a/app/models/workflow.rb +++ b/app/models/workflow.rb @@ -5,6 +5,7 @@ class Workflow < ApplicationRecord has_many :tasks, dependent: :restrict_with_exception has_many :task_statuses, dependent: :restrict_with_error + accepts_nested_attributes_for :task_statuses, allow_destroy: 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 diff --git a/app/services/project_admin/workflows/statuses/batch_update.rb b/app/services/project_admin/workflows/statuses/batch_update.rb index 783745b..40bb1b4 100644 --- a/app/services/project_admin/workflows/statuses/batch_update.rb +++ b/app/services/project_admin/workflows/statuses/batch_update.rb @@ -13,6 +13,18 @@ module ProjectAdmin 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 @@ -30,42 +42,9 @@ module ProjectAdmin def call(workflow) @workflow = workflow - task_status_models = @workflow.task_statuses.index_by(&:id) - @workflow.transaction(requires_new: true) do - task_statuses.each do |ts| - if ts.id.start_with?('_') - create_model!(ts) - else - model = task_status_models.fetch(Integer(ts.id)) - if ts._destroy - model.destroy! - else - update_model!(model, ts) - end - end - end - end - - true - end - - private - - def update_model!(model, form) - model.update!( - name: form.name, - icon: form.icon, - color: form.color - ) - end - - def create_model!(form) - @workflow.task_statuses.create!( - name: form.name, - icon: form.icon, - color: form.color - ) + @workflow.assign_attributes(task_statuses_attributes: task_statuses.map(&:to_model_attributes)) + save @workflow end end end From 52f2191a2568165a419b0e13310fedb92f565024 Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Wed, 22 Apr 2026 00:06:47 +0300 Subject: [PATCH 14/19] Added turbo frame --- app/helpers/project_admin_helper.rb | 17 +++++++++++--- app/views/project_admin/_frame.html.slim | 3 ++- .../project_admin/workflows/edit.html.slim | 7 ++---- .../project_admin/workflows/show.html.slim | 7 +++++- .../workflows/statuses/edit.html.slim | 23 ++++++++++--------- 5 files changed, 36 insertions(+), 21 deletions(-) diff --git a/app/helpers/project_admin_helper.rb b/app/helpers/project_admin_helper.rb index d3f385c..58ac73b 100644 --- a/app/helpers/project_admin_helper.rb +++ b/app/helpers/project_admin_helper.rb @@ -11,13 +11,24 @@ module ProjectAdminHelper } title = "Project #{project.name}" - admin_frame(links, title:, &) + admin_frame(links, title:, back_path: project_path(project), &) end - def admin_frame(links, title:, &) + 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) + } + 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:, content: } + render partial: 'project_admin/frame', locals: { + id: FRAME_ID, tabs:, tabs_id: TABS_ID, title:, back_path:, content: + } end end diff --git a/app/views/project_admin/_frame.html.slim b/app/views/project_admin/_frame.html.slim index 81337f8..0f88b3f 100644 --- a/app/views/project_admin/_frame.html.slim +++ b/app/views/project_admin/_frame.html.slim @@ -2,7 +2,8 @@ h1 span> Project span= @project.name -= link_to '← Back', project_path(@project) +- if back_path + = link_to '← Back', back_path = render tabs diff --git a/app/views/project_admin/workflows/edit.html.slim b/app/views/project_admin/workflows/edit.html.slim index 80e3c3c..d40a32c 100644 --- a/app/views/project_admin/workflows/edit.html.slim +++ b/app/views/project_admin/workflows/edit.html.slim @@ -1,5 +1,2 @@ -h1 - | Editing workflow - =< @workflow.name - -= render 'form', url: project_admin_workflow_path(@project, @workflow) += project_admin_workflow_frame(@workflow) do + = render 'form', url: project_admin_workflow_path(@project, @workflow) diff --git a/app/views/project_admin/workflows/show.html.slim b/app/views/project_admin/workflows/show.html.slim index 9c165ac..2a36a88 100644 --- a/app/views/project_admin/workflows/show.html.slim +++ b/app/views/project_admin/workflows/show.html.slim @@ -1,7 +1,12 @@ h1 | Workflow -= workflow_display @workflow, full: true, badge: true +.row + = workflow_display @workflow, full: true, badge: true + .links + = link_to 'Edit', edit_project_admin_workflow_path(@project, @workflow) + +h2 Statuses ul - @workflow.task_statuses.each do |status| diff --git a/app/views/project_admin/workflows/statuses/edit.html.slim b/app/views/project_admin/workflows/statuses/edit.html.slim index c61ddee..a2df883 100644 --- a/app/views/project_admin/workflows/statuses/edit.html.slim +++ b/app/views/project_admin/workflows/statuses/edit.html.slim @@ -1,14 +1,15 @@ -= 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} 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 += 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} 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 + .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 + = f.fields_for :'_extra_form', index: '__index__' do |exf| + template data-dynamic-forms-target="template" + = render 'batch_update_fieldset', ff: exf - .submit - = f.submit + .submit + = f.submit From fb03bb1a2dddbd40e91a20600b2dc2e065f95f25 Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Wed, 22 Apr 2026 02:15:16 +0300 Subject: [PATCH 15/19] Workflow creation --- .../project_admin/workflows_controller.rb | 25 ++++++++++++----- .../project_admin/workflows/update.rb | 27 ------------------- .../project_admin/workflows/_form.html.slim | 2 +- .../project_admin/workflows/index.html.slim | 3 +++ .../project_admin/workflows/new.html.slim | 2 ++ .../project_admin/workflows/show.html.slim | 2 +- .../workflows/statuses/edit.html.slim | 2 +- 7 files changed, 26 insertions(+), 37 deletions(-) delete mode 100644 app/services/project_admin/workflows/update.rb create mode 100644 app/views/project_admin/workflows/new.html.slim diff --git a/app/controllers/project_admin/workflows_controller.rb b/app/controllers/project_admin/workflows_controller.rb index fb62575..6081385 100644 --- a/app/controllers/project_admin/workflows_controller.rb +++ b/app/controllers/project_admin/workflows_controller.rb @@ -12,16 +12,23 @@ module ProjectAdmin # TODO: add loading statuses and other things end - def edit - @form = ProjectAdmin::Workflows::Update.new( - @workflow.attributes.slice('name', 'icon', 'color') - ) + def new + @workflow = @project.workflows.build end - def update - @form = ProjectAdmin::Workflows::Update.new(params.expect(workflow: %i[name color icon])) + 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 - if @form.perform(@workflow) + def edit; end + + def update + if @workflow.update(workflow_params) redirect_to project_admin_workflow_path(@project, @workflow) else render :edit, status: :unprocessable_entity @@ -36,5 +43,9 @@ module ProjectAdmin 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/services/project_admin/workflows/update.rb b/app/services/project_admin/workflows/update.rb deleted file mode 100644 index e48c297..0000000 --- a/app/services/project_admin/workflows/update.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module ProjectAdmin - module Workflows - class Update < ApplicationService - attribute :id, :integer - attribute :name, :string - attribute :icon, :string - attribute :color, :string - - validates :name, presence: true - validates :icon, inclusion: { in: Workflow.icons.values } - validates :color, inclusion: { in: Workflow.colors.values } - - delegate :model_name, to: Workflow - - def persisted? = true - - def perform(workflow) - @workflow = workflow - @id = workflow.id - @workflow.assign_attributes(icon:, color:, name:) - save @workflow - end - end - end -end diff --git a/app/views/project_admin/workflows/_form.html.slim b/app/views/project_admin/workflows/_form.html.slim index fa0185f..2164d8d 100644 --- a/app/views/project_admin/workflows/_form.html.slim +++ b/app/views/project_admin/workflows/_form.html.slim @@ -1,4 +1,4 @@ -= form_with model: @form, url: url do |form| += form_with model: @workflow, url: url, data: {'turbo-frame': '_top'} do |form| .field = form.label :name = form.text_field :name diff --git a/app/views/project_admin/workflows/index.html.slim b/app/views/project_admin/workflows/index.html.slim index a220a01..55268ff 100644 --- a/app/views/project_admin/workflows/index.html.slim +++ b/app/views/project_admin/workflows/index.html.slim @@ -2,6 +2,9 @@ h2 span Workflows + .links + = link_to 'New', new_project_admin_workflow_path(@project) + - @workflows.each do |workflow| article.row = workflow_display(workflow, full: true) 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 index 2a36a88..39831e5 100644 --- a/app/views/project_admin/workflows/show.html.slim +++ b/app/views/project_admin/workflows/show.html.slim @@ -3,7 +3,7 @@ h1 .row = workflow_display @workflow, full: true, badge: true - .links + .links.right = link_to 'Edit', edit_project_admin_workflow_path(@project, @workflow) h2 Statuses diff --git a/app/views/project_admin/workflows/statuses/edit.html.slim b/app/views/project_admin/workflows/statuses/edit.html.slim index a2df883..129d907 100644 --- a/app/views/project_admin/workflows/statuses/edit.html.slim +++ b/app/views/project_admin/workflows/statuses/edit.html.slim @@ -1,5 +1,5 @@ = 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} do |f| + = 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 From b1038c0d66fc2c19fa4459af7439ab07b77c671f Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Thu, 23 Apr 2026 14:04:47 +0300 Subject: [PATCH 16/19] Small fix for workflow tasks --- app/controllers/project_admin/application_controller.rb | 1 + app/controllers/tasks_controller.rb | 8 +++++++- app/views/tasks/new.html.slim | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/controllers/project_admin/application_controller.rb b/app/controllers/project_admin/application_controller.rb index bc023f8..9e12f3a 100644 --- a/app/controllers/project_admin/application_controller.rb +++ b/app/controllers/project_admin/application_controller.rb @@ -8,6 +8,7 @@ module ProjectAdmin def fetch_project @project = Project.find_by!(code: params[:project_id]) + self.current_project = @project end end end diff --git a/app/controllers/tasks_controller.rb b/app/controllers/tasks_controller.rb index d766283..44313d2 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 @@ -17,7 +19,7 @@ class TasksController < ApplicationController def show; end def new - @project = fetch_project || Project.order(:name).first + @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) end @@ -81,4 +83,8 @@ class TasksController < ApplicationController @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/views/tasks/new.html.slim b/app/views/tasks/new.html.slim index e40c8c3..b87d828 100644 --- a/app/views/tasks/new.html.slim +++ b/app/views/tasks/new.html.slim @@ -8,7 +8,7 @@ h1 New task fieldset data={controller: 'dependent-frames-selector', 'dependent-frames-selector-param-value': 'workflow_id'} .field = form.label :workflow - = form.select :workflow_id, Workflow.where(project: @form.project).map { |w| [w.name, w.id] }, {}, data: {action: 'dependent-frames-selector#refresh'} + = 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 From bf78009df8a2548b6d6f320a77cd1e295d469749 Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Sat, 25 Apr 2026 14:30:12 +0300 Subject: [PATCH 17/19] TaskStatus::Transition model --- app/models/task_status.rb | 4 ++++ app/models/task_status/transition.rb | 10 ++++++++++ ...20260425112121_create_task_status_transitions.rb | 10 ++++++++++ db/schema.rb | 13 ++++++++++++- 4 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 app/models/task_status/transition.rb create mode 100644 db/migrate/20260425112121_create_task_status_transitions.rb diff --git a/app/models/task_status.rb b/app/models/task_status.rb index 6f516c9..ce9bd83 100644 --- a/app/models/task_status.rb +++ b/app/models/task_status.rb @@ -4,6 +4,10 @@ class TaskStatus < ApplicationRecord belongs_to :workflow has_one :project, through: :workflow + 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 + 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 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/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/schema.rb b/db/schema.rb index 2fdfb89..56ae9ac 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_04_04_215813) do +ActiveRecord::Schema[8.1].define(version: 2026_04_25_112121) 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,6 +69,15 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_04_215813) do t.index ["user_id"], name: "index_sessions_on_user_id" end + create_table "task_status_transitions", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "from_id", null: false + t.bigint "to_id", null: false + t.datetime "updated_at", null: false + 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 @@ -116,6 +125,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_04_215813) do 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_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" From 2a0a70c29043b77c7d696dcc7c537525071576f7 Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Sat, 25 Apr 2026 15:37:07 +0300 Subject: [PATCH 18/19] Status transitions editing --- .../workflows/statuses_controller.rb | 20 ++++++++++- app/helpers/project_admin_helper.rb | 3 +- .../workflows/statuses/update_transitions.rb | 33 +++++++++++++++++++ .../statuses/edit_transitions.html.slim | 9 +++++ config/routes.rb | 2 ++ 5 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 app/services/project_admin/workflows/statuses/update_transitions.rb create mode 100644 app/views/project_admin/workflows/statuses/edit_transitions.html.slim diff --git a/app/controllers/project_admin/workflows/statuses_controller.rb b/app/controllers/project_admin/workflows/statuses_controller.rb index e8e3543..3157233 100644 --- a/app/controllers/project_admin/workflows/statuses_controller.rb +++ b/app/controllers/project_admin/workflows/statuses_controller.rb @@ -18,12 +18,30 @@ module ProjectAdmin @form = ProjectAdmin::Workflows::Statuses::BatchUpdate.new(form_params) if @form.call(@workflow) - redirect_to project_admin_workflow_path(@project, @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: { 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.call(@workflow) + redirect_to project_admin_workflow_path(@project, @workflow) + else + render :edit_transitions + end + end + private def fetch_workflow diff --git a/app/helpers/project_admin_helper.rb b/app/helpers/project_admin_helper.rb index 58ac73b..78cc668 100644 --- a/app/helpers/project_admin_helper.rb +++ b/app/helpers/project_admin_helper.rb @@ -17,7 +17,8 @@ module ProjectAdminHelper 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) + '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), &) 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..05eb6ad --- /dev/null +++ b/app/services/project_admin/workflows/statuses/update_transitions.rb @@ -0,0 +1,33 @@ +# 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 :task_statuses + + def self.from_model(workflow) + new(task_statuses_attributes: workflow.task_statuses + .includes(:next_statuses) + .map { |ts| { id: ts.id, next_status_ids: ts.next_status_ids } }) + end + + def task_statuses_attributes=(attributes) + @task_statuses = Array(attributes).map { |e| TaskStatus.new(e) } + end + + def call(workflow) + workflow.update(task_statuses_attributes: task_statuses.map(&:attributes)) + end + end + end + end +end 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..4ede69f --- /dev/null +++ b/app/views/project_admin/workflows/statuses/edit_transitions.html.slim @@ -0,0 +1,9 @@ += 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= tsf.collection_checkboxes :next_status_ids, @workflow.task_statuses - [tsf.object], :id, :name + + .submit + = f.submit diff --git a/config/routes.rb b/config/routes.rb index 6f179b1..3d3b3a4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -25,6 +25,8 @@ Rails.application.routes.draw 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 From debff6dc2277e9e5e125021fe562cf3f98df420f Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Sat, 25 Apr 2026 16:17:14 +0300 Subject: [PATCH 19/19] Workflow default status and computing positions --- .../workflows/statuses_controller.rb | 7 ++-- app/controllers/tasks_controller.rb | 5 ++- app/models/workflow.rb | 11 +++++ .../workflows/statuses/batch_update.rb | 2 +- .../workflows/statuses/update_transitions.rb | 42 ++++++++++++++++--- .../tasks/statuses/selector_view_model.rb | 6 ++- .../project_admin/workflows/show.html.slim | 2 +- .../statuses/edit_transitions.html.slim | 1 + ...5123859_add_default_status_to_workflows.rb | 5 +++ db/schema.rb | 5 ++- 10 files changed, 71 insertions(+), 15 deletions(-) create mode 100644 db/migrate/20260425123859_add_default_status_to_workflows.rb diff --git a/app/controllers/project_admin/workflows/statuses_controller.rb b/app/controllers/project_admin/workflows/statuses_controller.rb index 3157233..6ba9922 100644 --- a/app/controllers/project_admin/workflows/statuses_controller.rb +++ b/app/controllers/project_admin/workflows/statuses_controller.rb @@ -17,7 +17,7 @@ module ProjectAdmin end @form = ProjectAdmin::Workflows::Statuses::BatchUpdate.new(form_params) - if @form.call(@workflow) + if @form.perform(@workflow) redirect_to(action: :edit_transitions) else render :edit @@ -29,13 +29,14 @@ module ProjectAdmin end def batch_update_transitions - form_params = params.expect(workflow: { task_statuses_attributes: [[:id, { next_status_ids: [] }]] }) + 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.call(@workflow) + if @form.perform(@workflow) redirect_to project_admin_workflow_path(@project, @workflow) else render :edit_transitions diff --git a/app/controllers/tasks_controller.rb b/app/controllers/tasks_controller.rb index 44313d2..964947e 100644 --- a/app/controllers/tasks_controller.rb +++ b/app/controllers/tasks_controller.rb @@ -13,7 +13,7 @@ class TasksController < ApplicationController Task.all end - @tasks = @tasks.includes(:status, :project, workflow: :task_statuses) + @tasks = @tasks.includes(:project, workflow: %i[task_statuses default_status], status: :next_statuses) end def show; end @@ -21,7 +21,8 @@ class TasksController < ApplicationController def new @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) + @form = Tasks::Create.new(project_id: @project.id, workflow_id: @workflow.id, + status_id: @workflow.default_status&.id) end def create diff --git a/app/models/workflow.rb b/app/models/workflow.rb index dc39ade..3bc135a 100644 --- a/app/models/workflow.rb +++ b/app/models/workflow.rb @@ -6,7 +6,18 @@ class Workflow < ApplicationRecord 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 index 40bb1b4..d45fb25 100644 --- a/app/services/project_admin/workflows/statuses/batch_update.rb +++ b/app/services/project_admin/workflows/statuses/batch_update.rb @@ -40,7 +40,7 @@ module ProjectAdmin @task_statuses = Array(attributes).map { |e| TaskStatus.new(e) } end - def call(workflow) + def perform(workflow) @workflow = workflow @workflow.assign_attributes(task_statuses_attributes: task_statuses.map(&:to_model_attributes)) diff --git a/app/services/project_admin/workflows/statuses/update_transitions.rb b/app/services/project_admin/workflows/statuses/update_transitions.rb index 05eb6ad..3fad643 100644 --- a/app/services/project_admin/workflows/statuses/update_transitions.rb +++ b/app/services/project_admin/workflows/statuses/update_transitions.rb @@ -12,20 +12,50 @@ module ProjectAdmin attribute :next_status_ids end - attr_accessor :task_statuses + attr_accessor :workflow, :task_statuses + + attribute :default_status_id, :integer def self.from_model(workflow) - new(task_statuses_attributes: workflow.task_statuses - .includes(:next_statuses) - .map { |ts| { id: ts.id, next_status_ids: ts.next_status_ids } }) + 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 call(workflow) - workflow.update(task_statuses_attributes: task_statuses.map(&:attributes)) + 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 diff --git a/app/view_models/tasks/statuses/selector_view_model.rb b/app/view_models/tasks/statuses/selector_view_model.rb index 5a3c11c..37093e2 100644 --- a/app/view_models/tasks/statuses/selector_view_model.rb +++ b/app/view_models/tasks/statuses/selector_view_model.rb @@ -40,7 +40,11 @@ module Tasks private def workflow_task_statuses - @task.workflow.task_statuses.sort_by { |e| [e.position, e.name] } + return @task.workflow.task_statuses.sort_by { |e| [e.position, e.name] } if @task.status.next_statuses.empty? + + @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/views/project_admin/workflows/show.html.slim b/app/views/project_admin/workflows/show.html.slim index 39831e5..4a5d9aa 100644 --- a/app/views/project_admin/workflows/show.html.slim +++ b/app/views/project_admin/workflows/show.html.slim @@ -9,5 +9,5 @@ h1 h2 Statuses ul - - @workflow.task_statuses.each do |status| + - @workflow.task_statuses.default_order.each do |status| li= task_status_badge(status) diff --git a/app/views/project_admin/workflows/statuses/edit_transitions.html.slim b/app/views/project_admin/workflows/statuses/edit_transitions.html.slim index 4ede69f..d0d6605 100644 --- a/app/views/project_admin/workflows/statuses/edit_transitions.html.slim +++ b/app/views/project_admin/workflows/statuses/edit_transitions.html.slim @@ -3,6 +3,7 @@ = 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 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 56ae9ac..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_04_25_112121) 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 @@ -114,10 +114,12 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_25_112121) do 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 @@ -130,4 +132,5 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_25_112121) do 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