Merge pull request #2 from art-solopov/workflows-and-statuses
Workflows and statuses
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
module ProjectAdmin::Workflows::StatusesHelper
|
||||
end
|
||||
@@ -0,0 +1,2 @@
|
||||
module ProjectAdmin::WorkflowsHelper
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
|
||||
module WorkflowsHelper
|
||||
def workflow_display(workflow, **)
|
||||
render Workflows::DisplayViewModel.new(workflow, **)
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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'}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,2 @@
|
||||
= project_admin_workflow_frame(@workflow) do
|
||||
= render 'form', url: project_admin_workflow_path(@project, @workflow)
|
||||
@@ -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'
|
||||
@@ -0,0 +1,2 @@
|
||||
= project_admin_frame(@project) do
|
||||
= render 'form', url: project_admin_workflows_path(@project, @workflow)
|
||||
@@ -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)
|
||||
@@ -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') }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
h1
|
||||
= project_admin_frame(@project) do
|
||||
h2
|
||||
span Editing project
|
||||
span= @project.name
|
||||
|
||||
= render 'form'
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
= 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(project: @form.project).default_order.map { |ts| [ts.name, ts.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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
div.workflow class=[color, ('badge' if badge)] title=name
|
||||
= mask_icon(icon)
|
||||
- if full
|
||||
span<= name
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddNullFalseToTaskStatusesWorkflow < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
change_column_null :task_statuses, :workflow_id, false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class DropProjectIdFromTaskStatuses < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
remove_reference :task_statuses, :project, foreign_key: true
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><g fill='none'><path d='M24 0v24H0V0zM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01z'/><path fill='#09244BFF' d='M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2m0 5a1 1 0 0 0-.993.883L11 8v3H8a1 1 0 0 0-.117 1.993L8 13h3v3a1 1 0 0 0 1.993.117L13 16v-3h3a1 1 0 0 0 .117-1.993L16 11h-3V8a1 1 0 0 0-1-1'/></g></svg>
|
||||
|
After Width: | Height: | Size: 852 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><g fill='none' fill-rule='evenodd'><path d='M24 0v24H0V0zM12.594 23.258l-.012.002-.071.035-.02.004-.014-.004-.071-.036c-.01-.003-.019 0-.024.006l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.016-.018m.264-.113-.014.002-.184.093-.01.01-.003.011.018.43.005.012.008.008.201.092c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.003-.011.018-.43-.003-.012-.01-.01z'/><path fill='#09244BFF' d='M16.586 3A2 2 0 0 1 18 3.586L20.414 6A2 2 0 0 1 21 7.414V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.414A2 2 0 0 1 3.586 6L6 3.586A2 2 0 0 1 7.414 3zM19 9H5v10h14zm-7 1a1 1 0 0 1 1 1v3.186l.414-.414a1 1 0 1 1 1.414 1.414l-2.12 2.121a1 1 0 0 1-1.415 0l-2.121-2.121a1 1 0 1 1 1.414-1.414l.414.414V11a1 1 0 0 1 1-1m4.586-5H7.414l-2 2h13.172z'/></g></svg>
|
||||
|
After Width: | Height: | Size: 988 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><g fill='none'><path d='M24 0v24H0V0zM12.594 23.258l-.012.002-.071.035-.02.004-.014-.004-.071-.036c-.01-.003-.019 0-.024.006l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.016-.018m.264-.113-.014.002-.184.093-.01.01-.003.011.018.43.005.012.008.008.201.092c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.003-.011.018-.43-.003-.012-.01-.01z'/><path fill='#09244BFF' d='M2.222 11.121a1 1 0 0 1 1.32-.083l.094.083 4.242 4.243a1 1 0 0 1-1.32 1.497l-.094-.083-4.242-4.242a1 1 0 0 1 0-1.415m20.556-5.656a1 1 0 0 1 .083 1.32l-.083.094-9.829 9.828a1.1 1.1 0 0 1-1.46.086l-.096-.085-4.171-4.172a1 1 0 0 1 1.32-1.498l.094.083 3.535 3.536 9.193-9.192a1 1 0 0 1 1.414 0m-5 0a1 1 0 0 1 .083 1.32l-.083.094-4.95 4.95a1 1 0 0 1-1.497-1.32l.083-.095 4.95-4.95a1 1 0 0 1 1.414 0Z'/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><title>circle_dash_line</title><g fill='none' fill-rule='nonzero'><path d='M24 0v24H0V0zM12.594 23.258l-.012.002-.071.035-.02.004-.014-.004-.071-.036q-.016-.004-.024.006l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427q-.004-.016-.016-.018m.264-.113-.014.002-.184.093-.01.01-.003.011.018.43.005.012.008.008.201.092q.019.005.029-.008l.004-.014-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014-.034.614q.001.018.017.024l.015-.002.201-.093.01-.008.003-.011.018-.43-.003-.012-.01-.01z'/><path fill='#09244BFF' d='M6.483 19.184a1 1 0 0 1 1.373-.34 8 8 0 0 0 2.235.927 1 1 0 1 1-.48 1.941 10 10 0 0 1-2.789-1.155 1 1 0 0 1-.34-1.373m9.662-.34a1 1 0 0 1 1.033 1.713 10 10 0 0 1-2.79 1.155 1 1 0 0 1-.479-1.942 8 8 0 0 0 2.236-.926M3.019 13.179a1 1 0 0 1 1.21.73 8 8 0 0 0 .926 2.236 1 1 0 0 1-1.711 1.033 10 10 0 0 1-1.156-2.79 1 1 0 0 1 .73-1.21m16.75.73a1 1 0 0 1 1.943.48q-.18.727-.474 1.437-.295.711-.681 1.352a1 1 0 0 1-1.713-1.034 8 8 0 0 0 .926-2.235M3.445 6.822a1 1 0 1 1 1.711 1.034 8 8 0 0 0-.925 2.235 1 1 0 0 1-1.942-.48 10 10 0 0 1 1.156-2.789m15.74-.34a1 1 0 0 1 1.373.34 10 10 0 0 1 1.155 2.789 1 1 0 0 1-1.942.48 8 8 0 0 0-.926-2.235 1 1 0 0 1 .34-1.373M13.179 3.02a1 1 0 0 1 1.21-.73 10 10 0 0 1 2.789 1.155 1 1 0 0 1-1.034 1.711 8 8 0 0 0-2.235-.925 1 1 0 0 1-.73-1.211m-3.568-.73a1 1 0 0 1 .48 1.94 8 8 0 0 0-2.235.926 1 1 0 0 1-1.034-1.711 10 10 0 0 1 2.789-1.156'/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><g fill='none'><path d='M24 0v24H0V0zM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01z'/><path fill='#09244BFF' d='M11 6a1 1 0 1 1 0 2H5v11h11v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2zm9-3a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V6.414l-8.293 8.293a1 1 0 0 1-1.414-1.414L17.586 5H15a1 1 0 1 1 0-2Z'/></g></svg>
|
||||
|
After Width: | Height: | Size: 837 B |
@@ -1 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><g fill='none' fill-rule='evenodd'><path d='M24 0v24H0V0zM12.594 23.258l-.012.002-.071.035-.02.004-.014-.004-.071-.036c-.01-.003-.019 0-.024.006l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.016-.018m.264-.113-.014.002-.184.093-.01.01-.003.011.018.43.005.012.008.008.201.092c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.003-.011.018-.43-.003-.012-.01-.01z'/><path fill='#09244BFF' d='M5.844 4.566C7.446 3.643 9.619 3 12 3s4.554.643 6.156 1.566c.8.462 1.488 1.01 1.987 1.615.49.595.857 1.318.857 2.105 0 .792-.38 1.412-.99 1.821-.544.365-1.25.553-1.981.667-.58.09-1.259.145-2.019.177l-.786.026-.837.015-.885.006h-3.004l-.885-.006-.837-.015-.786-.026a18.59 18.59 0 0 1-2.019-.177c-.73-.114-1.437-.302-1.981-.667-.61-.41-.99-1.03-.99-1.821 0-.787.366-1.51.857-2.105.499-.604 1.186-1.153 1.987-1.615M5.4 7.454c-.19.229-.4.527-.4.839 0 .235.44.363.81.433l.233.038.236.034c.376.058.807.1 1.291.13l.608.03.66.02.712.012 1.58.01 2.154-.001.79-.005.739-.009.686-.016.323-.011.608-.03c.387-.024.74-.056 1.059-.097l.298-.042.393-.049c.379-.057.82-.17.82-.454 0-.16-.081-.446-.4-.832-.311-.377-.795-.782-1.443-1.155C15.864 5.553 14.037 5 12 5s-3.864.553-5.157 1.3c-.648.372-1.132.777-1.443 1.154m-1.064 4.454a3 3 0 0 1 3.328 0l.781.52a1 1 0 0 0 1.11 0l.78-.52a3 3 0 0 1 3.33 0l.78.52a1 1 0 0 0 1.11 0l.78-.52a3 3 0 0 1 3.33 0l.924.616a1 1 0 1 1-1.11 1.664l-.924-.616a1 1 0 0 0-1.11 0l-.78.52a3 3 0 0 1-3.33 0l-.78-.52a1 1 0 0 0-1.11 0l-.78.52a3 3 0 0 1-3.33 0l-.78-.52a1 1 0 0 0-1.11 0l-.504.346c-.152.1-.316.203-.475.287a1 1 0 1 1-.932-1.769c.072-.038.138-.077.204-.12zM4 15a1 1 0 0 0-1 1v1a4 4 0 0 0 4 4h10a4 4 0 0 0 4-4v-1a1 1 0 0 0-1-1zm3 4a2 2 0 0 1-2-2h14a2 2 0 0 1-2 2z'/></g></svg>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><g fill='none' fill-rule='evenodd'><path d='M24 0v24H0V0zM12.594 23.258l-.012.002-.071.035-.02.004-.014-.004-.071-.036c-.01-.003-.019 0-.024.006l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.016-.018m.264-.113-.014.002-.184.093-.01.01-.003.011.018.43.005.012.008.008.201.092c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.003-.011.018-.43-.003-.012-.01-.01z'/><path fill='#09244BFF' d='M5.844 4.566C7.446 3.643 9.619 3 12 3s4.554.643 6.156 1.566c.8.462 1.488 1.01 1.987 1.615.49.595.857 1.318.857 2.105 0 .792-.38 1.412-.99 1.821-.544.365-1.25.553-1.981.667-.58.09-1.259.145-2.019.177l-.786.026-.837.015-.885.006h-3.004l-.885-.006-.837-.015-.786-.026a18.59 18.59 0 0 1-2.019-.177c-.73-.114-1.437-.302-1.981-.667-.61-.41-.99-1.03-.99-1.821 0-.787.366-1.51.857-2.105.499-.604 1.186-1.153 1.987-1.615M5.4 7.454c-.19.229-.4.527-.4.839 0 .235.44.363.81.433l.233.038.236.034c.376.058.807.1 1.291.13l.608.03.66.02.712.012 1.58.01 2.154-.001.79-.005.739-.009.686-.016.323-.011.608-.03c.387-.024.74-.056 1.059-.097l.298-.042.393-.049c.379-.057.82-.17.82-.454 0-.16-.081-.446-.4-.832-.311-.377-.795-.782-1.443-1.155C15.864 5.553 14.037 5 12 5s-3.864.553-5.157 1.3c-.648.372-1.132.777-1.443 1.154m-1.064 4.454a3 3 0 0 1 3.328 0l.781.52a1 1 0 0 0 1.11 0l.78-.52a3 3 0 0 1 3.33 0l.78.52a1 1 0 0 0 1.11 0l.78-.52a3 3 0 0 1 3.33 0l.924.616a1 1 0 1 1-1.11 1.664l-.924-.616a1 1 0 0 0-1.11 0l-.78.52a3 3 0 0 1-3.33 0l-.78-.52a1 1 0 0 0-1.11 0l-.78.52a3 3 0 0 1-3.33 0l-.78-.52a1 1 0 0 0-1.11 0l-.504.346c-.152.1-.316.203-.475.287a1 1 0 1 1-.932-1.769c.072-.038.138-.077.204-.12zM4 15a1 1 0 0 0-1 1v1a4 4 0 0 0 4 4h10a4 4 0 0 0 4-4v-1a1 1 0 0 0-1-1zm3 4a2 2 0 0 1-2-2h14a2 2 0 0 1-2 2z'/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><g fill='none' fill-rule='evenodd'><path d='M24 0v24H0V0zM12.594 23.258l-.012.002-.071.035-.02.004-.014-.004-.071-.036c-.01-.003-.019 0-.024.006l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.016-.018m.264-.113-.014.002-.184.093-.01.01-.003.011.018.43.005.012.008.008.201.092c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.003-.011.018-.43-.003-.012-.01-.01z'/><path fill='#09244BFF' d='M6 3a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h4v12a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V9h4a1 1 0 0 0 1-1V7a1 1 0 0 0-.293-.707l-.657-.657A9 9 0 0 0 12.686 3z'/></g></svg>
|
||||
|
After Width: | Height: | Size: 799 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><g fill='none'><path d='M24 0v24H0V0zM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01z'/><path fill='#09244BFF' d='M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2M8.964 8.65l-.053.5-.03.333-.042.6-.024.46-.018.505-.01.549v.578l.01.549.018.506.024.46.042.6.071.73.013.102a1.192 1.192 0 0 0 1.651.954l.456-.202.651-.309.39-.196.43-.224.466-.253.498-.282.493-.29.231-.14.43-.27.388-.25.342-.229.653-.46.177-.131a1.192 1.192 0 0 0-.001-1.908l-.406-.297-.275-.193-.32-.218-.565-.368-.428-.268-.47-.282-.499-.288-.478-.265-.447-.238-.41-.21-.54-.263-.439-.202-.23-.102a16.814 16.814 0 0 0-.095-.04 1.192 1.192 0 0 0-1.654.952'/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><g fill='none' fill-rule='evenodd'><path d='M24 0v24H0V0h24ZM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01-.184-.092Z'/><path fill='#09244BFF' d='M16.06 10.94a1.5 1.5 0 0 1 0 2.12l-5.656 5.658a1.5 1.5 0 1 1-2.121-2.122L12.879 12 8.283 7.404a1.5 1.5 0 0 1 2.12-2.122l5.658 5.657Z'/></g></svg>
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><g fill='none' fill-rule='evenodd'><path d='M24 0v24H0V0h24ZM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018Zm.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022Zm-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01-.184-.092Z'/><path fill='#09244BFF' d='M16.06 10.94a1.5 1.5 0 0 1 0 2.12l-5.656 5.658a1.5 1.5 0 1 1-2.121-2.122L12.879 12 8.283 7.404a1.5 1.5 0 0 1 2.12-2.122l5.658 5.657Z'/></g></svg>
|
||||
|
Before Width: | Height: | Size: 829 B After Width: | Height: | Size: 809 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><g fill='none' fill-rule='evenodd'><path d='M24 0v24H0V0zM12.594 23.258l-.012.002-.071.035-.02.004-.014-.004-.071-.036c-.01-.003-.019 0-.024.006l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.016-.018m.264-.113-.014.002-.184.093-.01.01-.003.011.018.43.005.012.008.008.201.092c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.003-.011.018-.43-.003-.012-.01-.01z'/><path fill='#09244BFF' d='M16.239 4.79c-.595.127-1.456.478-2.55 1.085-2.034 1.127-4.763 3.294-7.949 6.798a1 1 0 1 1-1.48-1.346c3.276-3.603 6.172-5.934 8.458-7.202 1.16-.643 2.225-1.104 3.107-1.29.778-.166 1.942-.211 2.541.768.271.443.313.948.265 1.404-.048.461-.197.965-.417 1.499-.44 1.069-1.232 2.42-2.397 4.07-1.362 1.93-2.103 3.241-2.437 4.09a3.86 3.86 0 0 0-.163.5c.105-.022.23-.056.378-.105.555-.185 1.235-.525 1.89-.918.64-.384 1.22-.662 1.722-.74.263-.04.609-.043.946.126.369.185.576.497.673.793.143.439.078.913-.024 1.352-.165.713-.443 1.395-.621 2.104.109-.046.232-.103.372-.172a1 1 0 0 1 .894 1.788c-.65.325-1.265.552-1.81.55a1.567 1.567 0 0 1-.934-.29 1.468 1.468 0 0 1-.54-.798c-.136-.481-.049-1.007.033-1.372.13-.592.337-1.162.514-1.74-.804.457-1.6.92-2.483 1.215a4.314 4.314 0 0 1-1.16.237c-.374.015-.873-.04-1.29-.38-.452-.366-.603-.892-.602-1.376 0-.463.134-.971.344-1.506.423-1.076 1.276-2.544 2.664-4.51 1.13-1.6 1.824-2.811 2.181-3.68.13-.316.264-.66.283-1.006-.083 0-.213.011-.408.053Z'/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><g fill='none' fill-rule='evenodd'><path d='M24 0v24H0V0zM12.594 23.258l-.012.002-.071.035-.02.004-.014-.004-.071-.036c-.01-.003-.019 0-.024.006l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.016-.018m.264-.113-.014.002-.184.093-.01.01-.003.011.018.43.005.012.008.008.201.092c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.003-.011.018-.43-.003-.012-.01-.01z'/><path fill='#09244BFF' d='M15 2a2 2 0 0 1 1.732 1H18a2 2 0 0 1 2 2v12a5 5 0 0 1-5 5H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h1.268A2 2 0 0 1 9 2zM7 5H6v15h9a3 3 0 0 0 3-3V5h-1a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2m9.238 4.379a1 1 0 0 1 0 1.414l-4.95 4.95a1 1 0 0 1-1.414 0l-2.12-2.122a1 1 0 0 1 1.413-1.414l1.415 1.414 4.242-4.242a1 1 0 0 1 1.414 0M15 4H9v1h6z'/></g></svg>
|
||||
|
After Width: | Height: | Size: 976 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><g fill='none' fill-rule='evenodd'><path d='M24 0v24H0V0zM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01z'/><path fill='#09244BFF' d='M6.798 2.884a7.002 7.002 0 0 1 9.294 8.565l4.394 3.718a3.76 3.76 0 1 1-5.3 5.3l-3.717-4.394a7.002 7.002 0 0 1-8.565-9.295c.358-.894 1.48-1.007 2.063-.373L8.17 9.883l1.446-.288.29-1.449-3.48-3.198c-.634-.583-.522-1.706.373-2.064ZM8.805 4.42l2.763 2.54c.322.296.466.738.38 1.165l-.47 2.354a1.25 1.25 0 0 1-.982.981l-2.35.467a1.25 1.25 0 0 1-1.164-.38L4.438 8.785a5.002 5.002 0 0 0 6.804 5.25 1.257 1.257 0 0 1 1.422.355l4.05 4.786a1.76 1.76 0 1 0 2.48-2.48l-4.785-4.05a1.257 1.257 0 0 1-.355-1.422 5.001 5.001 0 0 0-5.25-6.804Z'/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><g fill='none'><path d='M24 0v24H0V0zM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035c-.01-.004-.019-.001-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427c-.002-.01-.009-.017-.017-.018m.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093c.012.004.023 0 .029-.008l.004-.014-.034-.614c-.003-.012-.01-.02-.02-.022m-.715.002a.023.023 0 0 0-.027.006l-.006.014-.034.614c0 .012.007.02.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01z'/><path fill='#09244BFF' d='M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2m0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16m0 11a1 1 0 1 1 0 2 1 1 0 0 1 0-2m0-9a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V7a1 1 0 0 1 1-1'/></g></svg>
|
||||
|
After Width: | Height: | Size: 830 B |