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 */
|
/* For jade, light on dark looks better */
|
||||||
--success-color: #70FCBA; /* Pico jade 100 */
|
--success-color: #70FCBA; /* Pico jade 100 */
|
||||||
--success-bg: #015234; /* Pico jade 700 */
|
--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-color: #014C75; /* Pico azure 700 */
|
||||||
--info-bg: #D1E5FB; /* Pico azure 100 */
|
--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 {
|
table {
|
||||||
/* TODO: maybe extract into a separate file */
|
/* TODO: maybe extract into a separate file */
|
||||||
details.dropdown {
|
details.dropdown {
|
||||||
margin: 0;
|
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 {
|
.task-status {
|
||||||
--color: var(--backlog-color);
|
--color: #424751; /* Pico zinc 700 */
|
||||||
--background-color: var(--backlog-bg);
|
--background-color: #E0E3E7; /* Pico zinc 100 */
|
||||||
|
|
||||||
&.analysis {
|
&.badge {
|
||||||
--color: var(--analysis-color);
|
border: 2px solid var(--border-color, currentColor);
|
||||||
--background-color: var(--analysis-bg);
|
color: var(--color, currentColor);
|
||||||
|
background-color: var(--background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.development {
|
&.blue {
|
||||||
--color: var(--info-color);
|
--color: #1D59D0; /* Pico blue 600 */
|
||||||
--background-color: var(--info-bg);
|
--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);
|
--color: var(--success-color);
|
||||||
--background-color: var(--success-bg);
|
--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 {
|
.task-show-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row nowrap;
|
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
|
class TasksController < ApplicationController
|
||||||
before_action :fetch_task, only: %w[show edit update delete change_status]
|
before_action :fetch_task, only: %w[show edit update delete change_status]
|
||||||
|
|
||||||
|
helper_method :workflows_for_tasks
|
||||||
|
|
||||||
def index
|
def index
|
||||||
self.current_project = fetch_project
|
self.current_project = fetch_project
|
||||||
@tasks = if current_project
|
@tasks = if current_project
|
||||||
@@ -11,18 +13,20 @@ class TasksController < ApplicationController
|
|||||||
Task.all
|
Task.all
|
||||||
end
|
end
|
||||||
|
|
||||||
@tasks = @tasks.includes(:status, project: :task_statuses)
|
@tasks = @tasks.includes(:project, workflow: %i[task_statuses default_status], status: :next_statuses)
|
||||||
end
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@project = fetch_project || Project.order(:name).first
|
@project = self.current_project = fetch_project || Project.order(:name).first
|
||||||
@form = Tasks::Create.new(project_id: @project.id)
|
@workflow = fetch_workflow || @project.workflows.first
|
||||||
|
@form = Tasks::Create.new(project_id: @project.id, workflow_id: @workflow.id,
|
||||||
|
status_id: @workflow.default_status&.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
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
|
if @form.perform
|
||||||
redirect_to tasks_path(project: @form.project)
|
redirect_to tasks_path(project: @form.project)
|
||||||
else
|
else
|
||||||
@@ -70,8 +74,18 @@ class TasksController < ApplicationController
|
|||||||
Project.find_by!(code: params[:project])
|
Project.find_by!(code: params[:project])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def fetch_workflow
|
||||||
|
return nil if params[:workflow_id].blank?
|
||||||
|
|
||||||
|
@project.workflows.find(params[:workflow_id])
|
||||||
|
end
|
||||||
|
|
||||||
def fetch_task
|
def fetch_task
|
||||||
@task = Task.includes(:project).find_by_full_number_or_id!(params[:id])
|
@task = Task.includes(:project).find_by_full_number_or_id!(params[:id])
|
||||||
self.current_project = @task.project
|
self.current_project = @task.project
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def workflows_for_tasks
|
||||||
|
@project.workflows.joins(:task_statuses).distinct
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,4 +6,13 @@ module ApplicationHelper
|
|||||||
|
|
||||||
url_for(controller: controller_name, action: :index, project:)
|
url_for(controller: controller_name, action: :index, project:)
|
||||||
end
|
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
|
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)
|
def task_status_selector(task, with_form: false)
|
||||||
render Tasks::Statuses::SelectorViewModel.new(task, with_form:)
|
render Tasks::Statuses::SelectorViewModel.new(task, with_form:)
|
||||||
end
|
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
|
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
|
class PostInitJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as :default
|
||||||
|
|
||||||
include Projects::CreateDefaultTaskStatuses
|
include Projects::CreateDefaults
|
||||||
|
|
||||||
def perform(project_id)
|
def perform(project_id)
|
||||||
project = Project.preparing.find(project_id)
|
project = Project.preparing.find(project_id)
|
||||||
|
|
||||||
project.transaction do
|
project.transaction do
|
||||||
|
workflow = create_default_workflow(project)
|
||||||
create_tasks_number_sequence(project)
|
create_tasks_number_sequence(project)
|
||||||
create_default_task_statuses(project)
|
create_default_task_statuses(project, workflow)
|
||||||
project.update!(status: :ready)
|
project.update!(status: :ready)
|
||||||
end
|
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/ }
|
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 :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
|
has_rich_text :description
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
class Task < ApplicationRecord
|
class Task < ApplicationRecord
|
||||||
belongs_to :project
|
belongs_to :project
|
||||||
belongs_to :status, class_name: 'TaskStatus'
|
belongs_to :status, class_name: 'TaskStatus'
|
||||||
|
belongs_to :workflow
|
||||||
|
|
||||||
validates :number, :title, presence: true
|
validates :number, :title, presence: true
|
||||||
validates :number, numericality: { greater_than: 0 }
|
validates :number, numericality: { greater_than: 0 }
|
||||||
@@ -33,8 +34,8 @@ class Task < ApplicationRecord
|
|||||||
private
|
private
|
||||||
|
|
||||||
def associations_should_have_same_project
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class TaskStatus < ApplicationRecord
|
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 }
|
enum :icon, %w[new achived done circle_dash hammer play tool].index_by(&:itself), default: 'new', scopes: false
|
||||||
validates :category, presence: true
|
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
|
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 :title, :string
|
||||||
attribute :description, :string
|
attribute :description, :string
|
||||||
attribute :status_id, :integer
|
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
|
delegate :model_name, to: Task
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ module Tasks
|
|||||||
end
|
end
|
||||||
|
|
||||||
def perform
|
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
|
save @task
|
||||||
end
|
end
|
||||||
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 Tasks
|
||||||
module Statuses
|
module Statuses
|
||||||
class SelectorViewModel
|
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)
|
def initialize(task, with_form: false)
|
||||||
@task = task
|
@task = task
|
||||||
@with_form = with_form
|
@with_form = with_form
|
||||||
@@ -17,23 +33,18 @@ module Tasks
|
|||||||
view_context.render(
|
view_context.render(
|
||||||
partial: 'tasks/status_selector',
|
partial: 'tasks/status_selector',
|
||||||
locals: { task: @task, id: dom_id, with_form: @with_form,
|
locals: { task: @task, id: dom_id, with_form: @with_form,
|
||||||
project_task_statuses:,
|
workflow_task_statuses: }
|
||||||
task_status_badge: ->(status) { task_status_badge(status, view_context) }}
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def project_task_statuses
|
def workflow_task_statuses
|
||||||
# TODO: refactor because it causes N+1 (task statuses loaded separately)
|
return @task.workflow.task_statuses.sort_by { |e| [e.position, e.name] } if @task.status.next_statuses.empty?
|
||||||
@task.project.task_statuses.default_order
|
|
||||||
end
|
|
||||||
|
|
||||||
def task_status_badge(status, view_context)
|
@task.status.next_statuses.sort_by { |e| [e.position, e.name] }.tap do |statuses|
|
||||||
view_context.content_tag(
|
statuses.prepend(@task.status)
|
||||||
:span, status.name,
|
end
|
||||||
class: ['badge', 'task-status', status.category.dasherize]
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
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
|
.field
|
||||||
= form.label :code
|
= form.label :code
|
||||||
= form.text_field :code
|
= form.text_field :code
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
h1
|
= project_admin_frame(@project) do
|
||||||
|
h2
|
||||||
span Editing project
|
span Editing project
|
||||||
span= @project.name
|
|
||||||
|
|
||||||
= render 'form'
|
= render 'form'
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
details.dropdown.task-status-selector id=id data-controller="tasks--status-selector"
|
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
|
ul
|
||||||
- project_task_statuses.each do |status|
|
- workflow_task_statuses.each do |status|
|
||||||
li
|
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
|
- 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|
|
= 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'}
|
= f.hidden_field :status_id, data: {'tasks--status-selector-target': 'statusField'}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
- cache task do
|
- cache task do
|
||||||
tr id="task_#{task.id}"
|
tr id="task_#{task.id}"
|
||||||
|
td= workflow_display task.workflow, badge: true
|
||||||
td= link_to task.full_number, task_path(task)
|
td= link_to task.full_number, task_path(task)
|
||||||
td
|
td
|
||||||
= task_status_selector task, with_form: true
|
= task_status_selector task, with_form: true
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
h1 New task
|
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
|
.field
|
||||||
= form.label :project_id
|
= 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'}
|
= 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 :status_select do
|
= 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
|
.field
|
||||||
= form.label :status_id
|
= 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
|
.field
|
||||||
= form.label :title
|
= form.label :title
|
||||||
= form.text_field :title
|
= form.text_field :title
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ div
|
|||||||
h1= @task.title
|
h1= @task.title
|
||||||
|
|
||||||
section.task-show-info
|
section.task-show-info
|
||||||
|
= workflow_display @task.workflow, full: true, badge: true
|
||||||
= task_status_selector @task, with_form: true
|
= task_status_selector @task, with_form: true
|
||||||
= turbo_stream_from @task, :status, :with_form
|
= 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 ("/")
|
# Defines the root path route ("/")
|
||||||
# root "posts#index"
|
# 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
|
resources :tasks do
|
||||||
patch :change_status, on: :member
|
patch :change_status, on: :member
|
||||||
end
|
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.
|
# 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|
|
create_table "action_text_rich_texts", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||||
t.text "body", size: :long
|
t.text "body", size: :long
|
||||||
t.datetime "created_at", null: false
|
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"
|
t.index ["user_id"], name: "index_sessions_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "task_statuses", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
create_table "task_status_transitions", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||||
t.integer "category", limit: 2, null: false, unsigned: true
|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "name", null: false
|
t.bigint "from_id", null: false
|
||||||
t.bigint "project_id", null: false
|
t.bigint "to_id", null: false
|
||||||
t.datetime "updated_at", 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 ["from_id"], name: "index_task_status_transitions_on_from_id"
|
||||||
t.index ["project_id", "name"], name: "index_task_statuses_on_project_id_and_name", unique: true
|
t.index ["to_id"], name: "index_task_status_transitions_on_to_id"
|
||||||
t.index ["project_id"], name: "index_task_statuses_on_project_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
|
end
|
||||||
|
|
||||||
create_table "tasks", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
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.bigint "status_id"
|
||||||
t.string "title", null: false
|
t.string "title", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.bigint "workflow_id"
|
||||||
t.index ["project_id"], name: "index_tasks_on_project_id"
|
t.index ["project_id"], name: "index_tasks_on_project_id"
|
||||||
t.index ["status_id"], name: "index_tasks_on_status_id"
|
t.index ["status_id"], name: "index_tasks_on_status_id"
|
||||||
|
t.index ["workflow_id"], name: "index_tasks_on_workflow_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "users", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
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
|
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
||||||
end
|
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_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "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 "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", "projects"
|
||||||
add_foreign_key "tasks", "task_statuses", column: "status_id"
|
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
|
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
|
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
|
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 |