Tracker: workflow statuses (#2171)

Signed-off-by: Sergei Ogorelkov <sergei.ogorelkov@xored.com>
This commit is contained in:
Sergei Ogorelkov
2022-07-01 22:24:58 +07:00
committed by GitHub
parent efc3583376
commit 69a8d16db4
13 changed files with 731 additions and 4 deletions
@@ -0,0 +1,112 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { Data } from '@anticrm/core'
import { IssueStatus } from '@anticrm/tracker'
import { Button, eventToHTMLElement, getPlatformColor, showPopup } from '@anticrm/ui'
import presentation from '@anticrm/presentation'
import { ColorsPopup } from '@anticrm/view-resources'
import tracker from '../../plugin'
import Circles from '../icons/Circles.svelte'
import StatusInput from './StatusInput.svelte'
export let value: Partial<Data<IssueStatus>>
export let isSingle = true
export let isSaving = false
const dispatch = createEventDispatcher()
function pickColor (evt: MouseEvent) {
showPopup(ColorsPopup, {}, eventToHTMLElement(evt), (newColor) => (value.color = newColor))
}
$: canSave = !isSaving && (value.name ?? '').length > 0
</script>
<div class="flex-between background-button-bg-color border-radius-1 p-2 root">
<div class="flex flex-grow items-center clear-mins inputs">
<div class="flex-no-shrink draggable-mark">
{#if !isSingle}<Circles />{/if}
</div>
<div class="flex-no-shrink ml-2 color" on:click={pickColor}>
<div class="dot" style="background-color: {getPlatformColor(value.color ?? 0)}" />
</div>
<div class="ml-2 w-full name">
<StatusInput bind:value={value.name} placeholder={tracker.string.Name} focus fill />
</div>
<div class="ml-2 w-full">
<StatusInput bind:value={value.description} placeholder={tracker.string.Description} fill />
</div>
</div>
<div class="buttons-group small-gap flex-no-shrink ml-2 mr-1">
<Button label={presentation.string.Cancel} kind="secondary" on:click={() => dispatch('cancel')} />
<Button label={presentation.string.Save} kind="primary" disabled={!canSave} on:click={() => dispatch('save')} />
</div>
</div>
<style lang="scss">
.root {
line-height: 1.125rem;
&:hover {
.draggable-mark {
opacity: 0.4;
}
}
}
.inputs {
overflow: hidden;
white-space: nowrap;
.name {
max-width: 10rem;
}
}
.draggable-mark {
position: relative;
opacity: 0;
width: 0.375rem;
height: 1rem;
margin-left: 0.25rem;
transition: opacity 0.1s;
&::before {
position: absolute;
content: '';
inset: -0.5rem;
}
}
.color {
position: relative;
width: 1.75rem;
height: 1.75rem;
background-color: var(--accent-bg-color);
border: 1px solid transparent;
border-radius: 0.25rem;
cursor: pointer;
.dot {
position: absolute;
content: '';
background: red;
border-radius: 50%;
inset: 30%;
}
}
</style>
@@ -0,0 +1,80 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { IntlString, translate } from '@anticrm/platform'
import { onMount } from 'svelte'
export let value: string | undefined = undefined
export let placeholder: IntlString | undefined
export let focus = false
export let fill = false
let input: HTMLInputElement
let placeholderTranslation = ''
async function updatePlaceholderTranslation (ph: IntlString | undefined) {
if (ph) {
placeholderTranslation = await translate(ph, {})
}
}
onMount(() => {
if (focus) {
input.focus()
focus = false
}
})
$: updatePlaceholderTranslation(placeholder)
</script>
<div class="root" class:fill>
<input
bind:this={input}
type="text"
bind:value
placeholder={placeholderTranslation}
on:input
on:change
on:keydown
on:keypress
on:blur
/>
</div>
<style lang="scss">
.root {
font-weight: 500;
&.fill {
width: 100%;
input {
width: 100%;
}
}
input {
padding: 0.25rem 0.5rem;
background-color: var(--accent-bg-color);
border: 1px solid transparent;
border-radius: 0.25rem;
&:focus {
border: 1px solid var(--primary-edit-border-color);
}
}
}
</style>
@@ -0,0 +1,125 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { Asset } from '@anticrm/platform'
import { IssueStatus } from '@anticrm/tracker'
import { Icon, Label, IconEdit, IconClose, tooltip } from '@anticrm/ui'
import tracker from '../../plugin'
import Circles from '../icons/Circles.svelte'
export let value: IssueStatus
export let icon: Asset
export let isDefault = false
export let isSingle = true
const dispatch = createEventDispatcher()
function edit () {
dispatch('edit', value)
}
</script>
<div class="flex-between background-button-bg-color border-radius-1 p-2 root" on:dblclick|preventDefault={edit}>
<div class="flex flex-grow items-center">
<div class="flex-no-shrink draggable-mark" class:draggable={!isSingle}>
<Circles />
</div>
<div class="flex-no-shrink ml-2">
<Icon {icon} size="small" />
</div>
<span class="content-accent-color ml-2">{value.name}</span>
{#if value.description}
<span>&nbsp;·&nbsp;{value.description}</span>
{/if}
</div>
<div class="buttons-group flex-no-shrink mr-1">
{#if isDefault}
<Label label={tracker.string.Default} />
{:else if value.category === tracker.issueStatusCategory.Backlog || value.category === tracker.issueStatusCategory.Unstarted}
<div class="btn" on:click|preventDefault={() => dispatch('default-update', value._id)}>
<Label label={tracker.string.MakeDefault} />
</div>
{/if}
<div
class="btn"
use:tooltip={{ label: tracker.string.EditWorkflowStatus, direction: 'bottom' }}
on:click|preventDefault={edit}
>
<Icon icon={IconEdit} size="small" />
</div>
{#if !isSingle}
<div
class="btn"
use:tooltip={{ label: tracker.string.DeleteWorkflowStatus, direction: 'bottom' }}
on:click|preventDefault={() => dispatch('delete', value)}
>
<Icon icon={IconClose} size="small" />
</div>
{/if}
</div>
</div>
<style lang="scss">
.root {
&:hover {
.btn {
opacity: 1;
}
.draggable-mark.draggable {
cursor: grab;
opacity: 0.4;
}
}
}
.btn {
position: relative;
opacity: 0;
cursor: pointer;
color: var(--content-color);
transition: color 0.15s;
transition: opacity 0.15s;
&:hover {
color: var(--caption-color);
}
&::before {
position: absolute;
content: '';
inset: -0.5rem;
}
}
.draggable-mark {
opacity: 0;
position: relative;
width: 0.375rem;
height: 1rem;
margin-left: 0.25rem;
&.draggable {
transition: opacity 0.15s;
&::before {
position: absolute;
content: '';
inset: -0.5rem;
}
}
}
</style>
@@ -0,0 +1,342 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { flip } from 'svelte/animate'
import { AttachedData, Class, Ref, SortingOrder } from '@anticrm/core'
import { Button, Icon, Label, Panel, Scroller, IconAdd, Loading, closeTooltip, showPopup } from '@anticrm/ui'
import { createQuery, getClient, MessageBox } from '@anticrm/presentation'
import { calcRank, IssueStatus, IssueStatusCategory, Team } from '@anticrm/tracker'
import tracker from '../../plugin'
import StatusEditor from './StatusEditor.svelte'
import StatusPresenter from './StatusPresenter.svelte'
import ExpandCollapse from '@anticrm/ui/src/components/ExpandCollapse.svelte'
export let teamId: Ref<Team>
export let teamClass: Ref<Class<Team>>
const client = getClient()
const dispatch = createEventDispatcher()
const teamQuery = createQuery()
const statusesQuery = createQuery()
let team: Team | undefined
let statusCategories: IssueStatusCategory[] | undefined
let workflowStatuses: IssueStatus[] | undefined
let editingStatus: IssueStatus | Partial<AttachedData<IssueStatus>> | null = null
let draggingStatus: IssueStatus | null = null
let hoveringStatus: IssueStatus | null = null
let isSaving = false
async function updateStatusCategories () {
statusCategories = await client.findAll(
tracker.class.IssueStatusCategory,
{},
{ sort: { order: SortingOrder.Ascending } }
)
}
async function updateTeamDefaultStatus (statusId: Ref<IssueStatus>) {
if (team) {
await client.update(team, { defaultIssueStatus: statusId })
}
}
async function addStatus () {
if (editingStatus?.name && editingStatus?.category && workflowStatuses) {
const categoryStatuses = workflowStatuses.filter((s) => s.category === editingStatus!.category)
const prevStatus = categoryStatuses[categoryStatuses.length - 1]
const nextStatus = workflowStatuses[workflowStatuses.findIndex(({ _id }) => _id === prevStatus._id) + 1]
isSaving = true
await client.addCollection(tracker.class.IssueStatus, teamId, teamId, tracker.class.Team, 'issueStatuses', {
name: editingStatus.name,
description: editingStatus.description,
color: editingStatus.color,
category: editingStatus.category,
rank: calcRank(prevStatus, nextStatus)
})
isSaving = false
}
cancelEditing()
}
async function editStatus () {
if (
workflowStatuses &&
statusCategories &&
editingStatus?.name &&
editingStatus?.category &&
'_id' in editingStatus
) {
const statusId = '_id' in editingStatus ? editingStatus._id : undefined
const status = statusId && workflowStatuses.find(({ _id }) => _id === statusId)
if (!status) {
return
}
const updates: Partial<AttachedData<IssueStatus>> = {}
if (status.name !== editingStatus.name) {
updates.name = editingStatus.name
}
if (status.description !== editingStatus.description) {
updates.description = editingStatus.description
}
if (status.color !== editingStatus.color) {
if (status.color === undefined) {
const category = statusCategories.find((c) => c._id === editingStatus?.category)
if (category && editingStatus.color !== category.color) {
updates.color = editingStatus.color
}
} else {
updates.color = editingStatus.color
}
}
if (Object.keys(updates).length > 0) {
isSaving = true
await client.update(status, updates)
isSaving = false
}
cancelEditing()
}
}
async function deleteStatus (event: CustomEvent<IssueStatus>) {
closeTooltip()
const { detail: status } = event
const issuesWithDeletingStatus = await client.findAll(
tracker.class.Issue,
{ status: status._id },
{ projection: { _id: 1 } }
)
if (issuesWithDeletingStatus.length > 0) {
showPopup(MessageBox, {
label: tracker.string.DeleteWorkflowStatusError,
message: tracker.string.DeleteWorkflowStatusErrorDescription,
params: { status: status.name, count: issuesWithDeletingStatus.length },
canSubmit: false
})
} else {
showPopup(
MessageBox,
{
label: tracker.string.DeleteWorkflowStatus,
message: tracker.string.DeleteWorkflowStatusConfirm,
params: { status: status.name }
},
undefined,
async (result) => {
if (result && team && workflowStatuses) {
isSaving = true
await client.removeDoc(status._class, status.space, status._id)
if (team.defaultIssueStatus === status._id) {
const newDefaultStatus = workflowStatuses.find(
(s) => s._id !== status._id && s.category === status.category
)
if (newDefaultStatus?._id) {
await updateTeamDefaultStatus(newDefaultStatus._id)
}
}
isSaving = false
}
}
)
}
}
function cancelEditing () {
editingStatus = null
}
function handleDragStart (ev: DragEvent, status: IssueStatus) {
if (ev.dataTransfer && ev.target) {
ev.dataTransfer.effectAllowed = 'move'
ev.dataTransfer.dropEffect = 'move'
draggingStatus = status
}
}
function handleDragOver (ev: DragEvent, status: IssueStatus) {
if (draggingStatus?.category === status.category) {
hoveringStatus = status
ev.preventDefault()
} else {
hoveringStatus = null
}
}
async function handleDrop (toItem: IssueStatus) {
if (workflowStatuses && draggingStatus?._id !== toItem._id && draggingStatus?.category === toItem.category) {
const fromIndex = getStatusIndex(draggingStatus)
const toIndex = getStatusIndex(toItem)
const [prev, next] = [
workflowStatuses[fromIndex < toIndex ? toIndex : toIndex - 1],
workflowStatuses[fromIndex < toIndex ? toIndex + 1 : toIndex]
]
isSaving = true
await client.update(draggingStatus, { rank: calcRank(prev, next) })
isSaving = false
}
resetDrag()
}
function getStatusIndex (status: IssueStatus) {
return workflowStatuses?.findIndex(({ _id }) => _id === status._id) ?? -1
}
function resetDrag () {
draggingStatus = null
hoveringStatus = null
}
$: teamQuery.query(teamClass, { _id: teamId }, (result) => ([team] = result), { limit: 1 })
$: statusesQuery.query(tracker.class.IssueStatus, { attachedTo: teamId }, (res) => (workflowStatuses = res), {
sort: { rank: SortingOrder.Ascending }
})
$: updateStatusCategories()
</script>
<Panel isHeader={false} isAside={false} isFullSize on:fullsize on:close={() => dispatch('close')}>
<svelte:fragment slot="title">
<div class="antiTitle icon-wrapper">
<div class="wrapped-icon">
<Icon icon={tracker.icon.Issue} size="small" />
</div>
<div class="title-wrapper">
<span class="wrapped-title">
<Label label={tracker.string.ManageWorkflowStatuses} />
</span>
{#if team}
<span class="wrapped-subtitle">{team.name}</span>
{/if}
</div>
</div>
</svelte:fragment>
{#if team === undefined || statusCategories === undefined || workflowStatuses === undefined}
<Loading />
{:else}
<Scroller>
<div class="popupPanel-body__main-content py-10 clear-mins">
{#each statusCategories as category}
{@const statuses = workflowStatuses?.filter((s) => s.category === category._id) ?? []}
{@const isSingle = statuses.length === 1}
<div class="flex-between category-name">
<Label label={category.label} />
<Button
showTooltip={{ label: tracker.string.AddWorkflowStatus }}
width="min-content"
icon={IconAdd}
size="small"
kind="transparent"
on:click={() => {
closeTooltip()
editingStatus = { category: category._id, color: category.color }
}}
/>
</div>
<div class="flex-col">
{#each statuses as status, _ (status._id)}
<div
class="row"
class:is-dragged-over-up={draggingStatus &&
status._id === hoveringStatus?._id &&
status.rank < draggingStatus.rank}
class:is-dragged-over-down={draggingStatus &&
status._id === hoveringStatus?._id &&
status.rank > draggingStatus.rank}
draggable={!isSingle}
animate:flip={{ duration: 200 }}
on:dragstart={(ev) => handleDragStart(ev, status)}
on:dragover={(ev) => handleDragOver(ev, status)}
on:drop={() => handleDrop(status)}
on:dragend={resetDrag}
>
{#if editingStatus && '_id' in editingStatus && editingStatus._id === status._id}
<StatusEditor
value={editingStatus}
on:cancel={cancelEditing}
on:save={editStatus}
{isSingle}
{isSaving}
/>
{:else}
<StatusPresenter
value={status}
icon={category.icon}
isDefault={status._id === team.defaultIssueStatus}
{isSingle}
on:default-update={({ detail }) => updateTeamDefaultStatus(detail)}
on:edit={({ detail }) => {
closeTooltip()
editingStatus = { ...detail }
}}
on:delete={deleteStatus}
/>
{/if}
</div>
{/each}
<ExpandCollapse duration={200} isExpanded>
{#if editingStatus && !('_id' in editingStatus) && editingStatus.category === category._id}
<StatusEditor value={editingStatus} on:cancel={cancelEditing} on:save={addStatus} {isSaving} isSingle />
{/if}
</ExpandCollapse>
</div>
{/each}
</div>
</Scroller>
{/if}
</Panel>
<style lang="scss">
.row {
position: relative;
margin-bottom: 0.25rem;
&.is-dragged-over-up::before {
position: absolute;
content: '';
inset: 0;
border-top: 1px solid var(--theme-bg-check);
}
&.is-dragged-over-down::before {
position: absolute;
content: '';
inset: 0;
border-bottom: 1px solid var(--theme-bg-check);
}
}
.category-name {
margin: 1rem 0 0.5rem 0;
&:first-child {
margin: 0 0 0.5rem 0;
}
}
</style>