Merge pull request #2 from art-solopov/workflows-and-statuses

Workflows and statuses
This commit is contained in:
2026-04-25 16:41:10 +03:00
committed by GitHub
75 changed files with 997 additions and 117 deletions
+22 -15
View File
@@ -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;
}
}
+13
View File
@@ -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;
}
+13
View File
@@ -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;
}
}
}
+28
View File
@@ -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;
}
}
}
+31 -9
View File
@@ -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;
}
}
+29
View File
@@ -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
+18 -4
View File
@@ -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
+9
View File
@@ -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
+35
View File
@@ -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
+8
View File
@@ -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
+8
View File
@@ -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
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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 -2
View File
@@ -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
+10 -5
View File
@@ -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
+10
View File
@@ -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
+23
View File
@@ -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
+3 -2
View File
@@ -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
+13
View File
@@ -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)
+3
View File
@@ -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 -1
View File
@@ -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
+2 -2
View File
@@ -1,5 +1,5 @@
h1
= project_admin_frame(@project) do
h2
span Editing project
span= @project.name
= render 'form'
+3 -3
View File
@@ -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
View File
@@ -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
+10 -4
View File
@@ -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
+1
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
div.workflow class=[color, ('badge' if badge)] title=name
= mask_icon(icon)
- if full
span<= name
+17 -1
View File
@@ -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
Generated
+37 -9
View File
@@ -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
+24
View File
@@ -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
+30
View File
@@ -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
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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