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