Tracker: Updating cards in Kanban (#2032)

* Add ProgressCircle, SubIssuesSelector. Open card on click.

Signed-off-by: Alexander Platov <sas_lord@mail.ru>

* Fix format

Signed-off-by: Alexander Platov <sas_lord@mail.ru>

* Fix

Signed-off-by: Alexander Platov <sas_lord@mail.ru>

* Fix scrolls in Kanban

Signed-off-by: Alexander Platov <sas_lord@mail.ru>
This commit is contained in:
Alexander Platov 2022-06-08 06:11:18 +03:00 committed by GitHub
parent 676aace675
commit d234102402
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 282 additions and 79 deletions

View File

@ -281,73 +281,71 @@
} }
</script> </script>
<div class="flex-col kanban-container top-divider"> <div class="kanban-container top-divider">
<div class="scrollable"> <ScrollBox>
<ScrollBox> <div class="kanban-content">
<div class="kanban-content"> {#each states as state, si}
{#each states as state, si} {@const stateObjects = getStateObjects(objects, state, dragCard)}
{@const stateObjects = getStateObjects(objects, state, dragCard)}
<div <div
class="panel-container step-lr75" class="panel-container step-lr75"
bind:this={stateRefs[si]} bind:this={stateRefs[si]}
on:dragover={(event) => panelDragOver(event, state)} on:dragover={(event) => panelDragOver(event, state)}
on:drop={() => { on:drop={() => {
move(state._id) move(state._id)
isDragging = false isDragging = false
}} }}
> >
{#if $$slots.header !== undefined} {#if $$slots.header !== undefined}
<slot name="header" state={toAny(state)} count={stateObjects.length} /> <slot name="header" state={toAny(state)} count={stateObjects.length} />
{:else} {:else}
<div class="header"> <div class="header">
<div class="bar" style="background-color: {getPlatformColor(state.color)}" /> <div class="bar" style="background-color: {getPlatformColor(state.color)}" />
<div class="flex-between label"> <div class="flex-between label">
<div> <div>
<span class="lines-limit-2">{state.title}</span> <span class="lines-limit-2">{state.title}</span>
</div>
</div> </div>
</div> </div>
{/if} </div>
<Scroller padding={'.5rem 0'} on:dragover on:drop> {/if}
<slot name="beforeCard" {state} /> <Scroller padding={'.5rem 0'} on:dragover on:drop>
{#each stateObjects as object} <slot name="beforeCard" {state} />
{@const dragged = isDragging && object.it._id === dragCard?._id} {#each stateObjects as object}
{@const dragged = isDragging && object.it._id === dragCard?._id}
<div
transition:slideD|local={{ isDragging }}
class="step-tb75"
on:dragover|preventDefault={(evt) => cardDragOver(evt, object)}
on:drop|preventDefault={(evt) => cardDrop(evt, object)}
>
<div <div
transition:slideD|local={{ isDragging }} class="card-container"
class="step-tb75" class:selection={selection !== undefined ? objects[selection]?._id === object.it._id : false}
on:dragover|preventDefault={(evt) => cardDragOver(evt, object)} class:checked={checkedSet.has(object.it._id)}
on:drop|preventDefault={(evt) => cardDrop(evt, object)} on:mouseover={() => dispatch('obj-focus', object.it)}
on:focus={() => {}}
on:contextmenu={(evt) => showMenu(evt, object)}
draggable={true}
class:draggable={true}
on:dragstart
on:dragend
class:dragged
on:dragstart={() => onDragStart(object, state)}
on:dragend={() => {
isDragging = false
}}
> >
<div <slot name="card" object={toAny(object.it)} {dragged} />
class="card-container"
class:selection={selection !== undefined ? objects[selection]?._id === object.it._id : false}
class:checked={checkedSet.has(object.it._id)}
on:mouseover={() => dispatch('obj-focus', object.it)}
on:focus={() => {}}
on:contextmenu={(evt) => showMenu(evt, object)}
draggable={true}
class:draggable={true}
on:dragstart
on:dragend
class:dragged
on:dragstart={() => onDragStart(object, state)}
on:dragend={() => {
isDragging = false
}}
>
<slot name="card" object={toAny(object.it)} {dragged} />
</div>
</div> </div>
{/each} </div>
<slot name="afterCard" {space} {state} /> {/each}
</Scroller> <slot name="afterCard" {space} {state} />
</div> </Scroller>
{/each} </div>
<slot name="afterPanel" /> {/each}
</div> <slot name="afterPanel" />
</ScrollBox> </div>
</div> </ScrollBox>
{#if isDragging} {#if isDragging}
<slot name="doneBar" onDone={updateDone} /> <slot name="doneBar" onDone={updateDone} />
{/if} {/if}
@ -362,7 +360,6 @@
.kanban-content { .kanban-content {
display: flex; display: flex;
padding: 1.5rem 2rem 0; padding: 1.5rem 2rem 0;
height: 100%;
} }
.scrollable { .scrollable {
@ -416,9 +413,7 @@
.panel-container { .panel-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch;
width: 20rem; width: 20rem;
height: 100%;
background-color: transparent; background-color: transparent;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 0.25rem; border-radius: 0.25rem;
@ -428,7 +423,6 @@
flex-direction: column; flex-direction: column;
height: 4rem; height: 4rem;
min-height: 4rem; min-height: 4rem;
user-select: none;
.bar { .bar {
height: 0.375rem; height: 0.375rem;

View File

@ -221,7 +221,7 @@ input.search {
min-width: 0; min-width: 0;
font-weight: 500; font-weight: 500;
text-align: left; text-align: left;
color: var(--theme-content-accent-color); color: var(--accent-color);
overflow: hidden; overflow: hidden;
visibility: visible; visibility: visible;
@ -259,6 +259,16 @@ input.search {
align-items: baseline; align-items: baseline;
.icon { transform: translateY(.2rem); } .icon { transform: translateY(.2rem); }
} }
// Presenters on the card
.card-container .flex-presenter,
.card-container .inline-presenter {
.icon { display: none; }
.label {
font-size: .75rem;
color: var(--dark-color);
}
&:hover .label { color: var(--content-color); }
}
.buttons-group { .buttons-group {
display: grid; display: grid;

View File

@ -298,7 +298,7 @@
&.link-bordered { &.link-bordered {
padding: 0 0.375rem; padding: 0 0.375rem;
color: var(--accent-color); color: var(--accent-color);
border-color: var(--button-border-color); border-color: var(--divider-color);
&:hover { &:hover {
color: var(--accent-color); color: var(--accent-color);
background-color: var(--button-bg-hover); background-color: var(--button-bg-hover);

View File

@ -0,0 +1,67 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
//
// 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 { getPlatformColor } from '../colors'
import { IconSize } from '../types'
export let value: number
export let min: number = 0
export let max: number = 100
export let color: number = 5
export let size: IconSize = 'small'
export let primary: boolean = false
if (value > max) value = max
if (value < min) value = min
const lenghtC: number = Math.PI * 14 - 1
const procC: number = lenghtC / (max - min)
$: dashOffset = (value - min) * procC
</script>
<svg class="svg-{size}" fill="none" viewBox="0 0 16 16">
<circle
cx={8}
cy={8}
r={7}
class="progress-circle"
style:stroke={'var(--divider-color)'}
style:opacity={'.5'}
style:transform={`rotate(${-78 + ((dashOffset + 1) * 360) / (lenghtC + 1)}deg)`}
style:stroke-dasharray={lenghtC}
style:stroke-dashoffset={dashOffset === 0 ? 0 : dashOffset + 3}
/>
<circle
cx={8}
cy={8}
r={7}
class="progress-circle"
style:stroke={primary ? 'var(--primary-bg-color)' : getPlatformColor(color)}
style:opacity={dashOffset === 0 ? 0 : 1}
style:transform={'rotate(-82deg)'}
style:stroke-dasharray={lenghtC}
style:stroke-dashoffset={dashOffset === 0 ? lenghtC : lenghtC - dashOffset + 1}
/>
</svg>
<style lang="scss">
.progress-circle {
stroke-width: 2px;
stroke-linecap: round;
transform-origin: center;
transition: transform 0.6s ease 0s, stroke-dashoffset 0.6s ease 0s, stroke-dasharray 0.6s ease 0s,
opacity 0.6s ease 0s;
}
</style>

View File

@ -54,6 +54,7 @@ export { default as Tooltip } from './components/Tooltip.svelte'
export { default as TooltipInstance } from './components/TooltipInstance.svelte' export { default as TooltipInstance } from './components/TooltipInstance.svelte'
export { default as CheckBox } from './components/CheckBox.svelte' export { default as CheckBox } from './components/CheckBox.svelte'
export { default as Progress } from './components/Progress.svelte' export { default as Progress } from './components/Progress.svelte'
export { default as ProgressCircle } from './components/ProgressCircle.svelte'
export { default as Tabs } from './components/Tabs.svelte' export { default as Tabs } from './components/Tabs.svelte'
export { default as ScrollBox } from './components/ScrollBox.svelte' export { default as ScrollBox } from './components/ScrollBox.svelte'
export { default as PopupMenu } from './components/PopupMenu.svelte' export { default as PopupMenu } from './components/PopupMenu.svelte'

View File

@ -32,6 +32,7 @@
const prioritiesInfo = defaultPriorities.map((p) => ({ id: p, ...issuePriorities[p] })) const prioritiesInfo = defaultPriorities.map((p) => ({ id: p, ...issuePriorities[p] }))
const handlePriorityEditorOpened = (event: MouseEvent) => { const handlePriorityEditorOpened = (event: MouseEvent) => {
event.stopPropagation()
if (!isEditable) { if (!isEditable) {
return return
} }

View File

@ -78,6 +78,7 @@
} }
const handleProjectEditorOpened = (event: MouseEvent) => { const handleProjectEditorOpened = (event: MouseEvent) => {
event.stopPropagation()
if (!isEditable) { if (!isEditable) {
return return
} }

View File

@ -17,8 +17,8 @@
import { Class, Doc, FindOptions, Ref, SortingOrder, WithLookup } from '@anticrm/core' import { Class, Doc, FindOptions, Ref, SortingOrder, WithLookup } from '@anticrm/core'
import { Kanban, TypeState } from '@anticrm/kanban' import { Kanban, TypeState } from '@anticrm/kanban'
import { createQuery } from '@anticrm/presentation' import { createQuery } from '@anticrm/presentation'
import { Issue, Team } from '@anticrm/tracker' import type { Issue, IssueStatus, Team } from '@anticrm/tracker'
import { Button, Icon, IconAdd, showPopup, Tooltip } from '@anticrm/ui' import { Button, Icon, IconAdd, showPopup, Tooltip, showPanel } from '@anticrm/ui'
import { focusStore, ListSelectionProvider, SelectDirection, selectionStore } from '@anticrm/view-resources' import { focusStore, ListSelectionProvider, SelectDirection, selectionStore } from '@anticrm/view-resources'
import ActionContext from '@anticrm/view-resources/src/components/ActionContext.svelte' import ActionContext from '@anticrm/view-resources/src/components/ActionContext.svelte'
import Menu from '@anticrm/view-resources/src/components/Menu.svelte' import Menu from '@anticrm/view-resources/src/components/Menu.svelte'
@ -29,6 +29,7 @@
import IssuePresenter from './IssuePresenter.svelte' import IssuePresenter from './IssuePresenter.svelte'
import PriorityEditor from './PriorityEditor.svelte' import PriorityEditor from './PriorityEditor.svelte'
import ProjectEditor from '../projects/ProjectEditor.svelte' import ProjectEditor from '../projects/ProjectEditor.svelte'
import SubIssuesSelector from './edit/SubIssuesSelector.svelte'
export let currentSpace: Ref<Team> export let currentSpace: Ref<Team>
export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined
@ -43,17 +44,19 @@
currentTeam = res.shift() currentTeam = res.shift()
}) })
let issueStatuses: WithLookup<IssueStatus>[] | undefined
let states: TypeState[] | undefined let states: TypeState[] | undefined
$: statusesQuery.query( $: statusesQuery.query(
tracker.class.IssueStatus, tracker.class.IssueStatus,
{ attachedTo: currentSpace }, { attachedTo: currentSpace },
(issueStatuses) => { (is) => {
states = issueStatuses.map((status) => ({ states = is.map((status) => ({
_id: status._id, _id: status._id,
title: status.name, title: status.name,
color: status.color ?? status.$lookup?.category?.color ?? 0, color: status.color ?? status.$lookup?.category?.color ?? 0,
icon: status.$lookup?.category?.icon ?? undefined icon: status.$lookup?.category?.icon ?? undefined
})) }))
issueStatuses = is
}, },
{ {
lookup: { category: tracker.class.IssueStatusCategory }, lookup: { category: tracker.class.IssueStatusCategory },
@ -152,7 +155,12 @@
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="card" let:object> <svelte:fragment slot="card" let:object>
{@const issue = toIssue(object)} {@const issue = toIssue(object)}
<div class="tracker-card"> <div
class="tracker-card"
on:click={() => {
showPanel(tracker.component.EditIssue, object._id, object._class, 'content')
}}
>
<div class="flex-col mr-6"> <div class="flex-col mr-6">
<IssuePresenter value={object} {currentTeam} /> <IssuePresenter value={object} {currentTeam} />
<span class="fs-bold caption-color mt-1 lines-limit-2"> <span class="fs-bold caption-color mt-1 lines-limit-2">
@ -168,7 +176,10 @@
isEditable={true} isEditable={true}
/> />
</div> </div>
<div class="buttons-group xsmall-gap mt-10px"> <div class="buttons-group xxsmall-gap mt-10px">
{#if issue && issueStatuses && issue.subIssues > 0}
<SubIssuesSelector {issue} {currentTeam} {issueStatuses} />
{/if}
<PriorityEditor <PriorityEditor
value={issue} value={issue}
isEditable={true} isEditable={true}

View File

@ -15,7 +15,7 @@
<script lang="ts"> <script lang="ts">
import { Issue, IssuePriority } from '@anticrm/tracker' import { Issue, IssuePriority } from '@anticrm/tracker'
import { getClient } from '@anticrm/presentation' import { getClient } from '@anticrm/presentation'
import { Tooltip } from '@anticrm/ui' import { tooltip } from '@anticrm/ui'
import type { ButtonKind, ButtonSize } from '@anticrm/ui' import type { ButtonKind, ButtonSize } from '@anticrm/ui'
import tracker from '../../plugin' import tracker from '../../plugin'
import PrioritySelector from '../PrioritySelector.svelte' import PrioritySelector from '../PrioritySelector.svelte'
@ -50,7 +50,7 @@
{#if value} {#if value}
{#if isEditable} {#if isEditable}
<Tooltip label={tracker.string.SetPriority} fill> <div class="clear-mins" use:tooltip={{ label: tracker.string.SetPriority }}>
<PrioritySelector <PrioritySelector
{kind} {kind}
{size} {size}
@ -61,7 +61,7 @@
priority={value.priority} priority={value.priority}
onPriorityChange={handlePriorityChanged} onPriorityChange={handlePriorityChanged}
/> />
</Tooltip> </div>
{:else} {:else}
<PrioritySelector {kind} {size} {width} {justify} {isEditable} {shouldShowLabel} priority={value.priority} /> <PrioritySelector {kind} {size} {width} {justify} {isEditable} {shouldShowLabel} priority={value.priority} />
{/if} {/if}

View File

@ -0,0 +1,115 @@
<!--
// 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 { SortingOrder, WithLookup, Ref, Doc } from '@anticrm/core'
import { createQuery } from '@anticrm/presentation'
import { Issue, IssueStatus, Team } from '@anticrm/tracker'
import { Button, ProgressCircle, showPopup, SelectPopup, closeTooltip, showPanel } from '@anticrm/ui'
import type { ButtonKind, ButtonSize } from '@anticrm/ui'
import tracker from '../../../plugin'
export let issue: Issue
export let currentTeam: Team | undefined
export let issueStatuses: WithLookup<IssueStatus>[] | undefined
export let kind: ButtonKind = 'link-bordered'
export let size: ButtonSize = 'inline'
export let justify: 'left' | 'center' = 'left'
export let width: string | undefined = 'min-contet'
const subIssuesQuery = createQuery()
let btn: HTMLElement
let subIssues: Issue[] | undefined
let doneStatus: Ref<Doc> | undefined
let countComplate: number = 0
$: hasSubIssues = issue.subIssues > 0
$: subIssuesQuery.query(tracker.class.Issue, { attachedTo: issue._id }, async (result) => (subIssues = result), {
sort: { rank: SortingOrder.Ascending }
})
$: if (issueStatuses && subIssues) {
doneStatus = issueStatuses.find((s) => s.category === tracker.issueStatusCategory.Completed)?._id ?? undefined
if (doneStatus) countComplate = subIssues.filter((si) => si.status === doneStatus).length
}
function getIssueStatusIcon (issue: Issue) {
return issueStatuses?.find((s) => issue.status === s._id)?.$lookup?.category?.icon ?? null
}
function getIssueId (issue: Issue) {
return `${currentTeam?.identifier}-${issue.number}`
}
function openIssue (target: Ref<Issue>) {
if (target !== issue._id) {
showPanel(tracker.component.EditIssue, target, issue._class, 'content')
}
}
function showSubIssues () {
if (subIssues) {
closeTooltip()
showPopup(
SelectPopup,
{
value: subIssues.map((iss) => ({
id: iss._id,
icon: getIssueStatusIcon(iss),
text: `${getIssueId(iss)} ${iss.title}`,
isSelected: iss._id === issue._id
})),
width: 'large'
},
{
getBoundingClientRect: () => {
const rect = btn.getBoundingClientRect()
const offsetX = 0
const offsetY = 0
return DOMRect.fromRect({ width: 1, height: 1, x: rect.left + offsetX, y: rect.bottom + offsetY })
}
},
(selectedIssue) => selectedIssue !== undefined && openIssue(selectedIssue)
)
}
}
</script>
{#if hasSubIssues}
<div class="flex-center clear-mins" bind:this={btn}>
<Button
{width}
{kind}
{size}
{justify}
on:click={(ev) => {
ev.stopPropagation()
if (subIssues) showSubIssues()
}}
>
<svelte:fragment slot="content">
{#if subIssues}
<div class="flex-row-center content-color text-sm">
<div class="mr-1">
<ProgressCircle bind:value={countComplate} bind:max={subIssues.length} size={'inline'} primary />
</div>
{countComplate}/{subIssues.length}
</div>
{/if}
</svelte:fragment>
</Button>
</div>
{/if}

View File

@ -16,7 +16,7 @@
import { Ref } from '@anticrm/core' import { Ref } from '@anticrm/core'
import { Issue, Project } from '@anticrm/tracker' import { Issue, Project } from '@anticrm/tracker'
import { getClient } from '@anticrm/presentation' import { getClient } from '@anticrm/presentation'
import { ButtonKind, ButtonShape, ButtonSize, Tooltip } from '@anticrm/ui' import { ButtonKind, ButtonShape, ButtonSize, tooltip } from '@anticrm/ui'
import { IntlString } from '@anticrm/platform' import { IntlString } from '@anticrm/platform'
import tracker from '../../plugin' import tracker from '../../plugin'
import ProjectSelector from '../ProjectSelector.svelte' import ProjectSelector from '../ProjectSelector.svelte'
@ -52,7 +52,10 @@
</script> </script>
{#if value.project || shouldShowPlaceholder} {#if value.project || shouldShowPlaceholder}
<Tooltip label={value.project ? tracker.string.MoveToProject : tracker.string.AddToProject} fill> <div
class="clear-mins"
use:tooltip={{ label: value.project ? tracker.string.MoveToProject : tracker.string.AddToProject }}
>
<ProjectSelector <ProjectSelector
{kind} {kind}
{size} {size}
@ -65,5 +68,5 @@
value={value.project} value={value.project}
onProjectIdChange={handleProjectIdChanged} onProjectIdChange={handleProjectIdChanged}
/> />
</Tooltip> </div>
{/if} {/if}