Add kanban view (#2071)

Signed-off-by: Dvinyanin Alexandr <dvinyanin.alexandr@gmail.com>
This commit is contained in:
Alex 2022-06-15 11:59:43 +07:00 committed by GitHub
parent dd915141a0
commit 619801aaca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 346 additions and 14 deletions

View File

@ -13,6 +13,7 @@ Leads:
Tracker: Tracker:
- Attachments support - Attachments support
- Board view
## 0.6.26 ## 0.6.26

View File

@ -42,6 +42,7 @@
"@anticrm/workbench": "~0.6.1", "@anticrm/workbench": "~0.6.1",
"@anticrm/view": "~0.6.0", "@anticrm/view": "~0.6.0",
"@anticrm/model-presentation": "~0.6.0", "@anticrm/model-presentation": "~0.6.0",
"@anticrm/setting": "~0.6.1" "@anticrm/setting": "~0.6.1",
"@anticrm/task": "~0.6.0"
} }
} }

View File

@ -39,6 +39,7 @@ import workbench, { createNavigateAction } from '@anticrm/model-workbench'
import notification from '@anticrm/notification' import notification from '@anticrm/notification'
import { Asset, IntlString } from '@anticrm/platform' import { Asset, IntlString } from '@anticrm/platform'
import setting from '@anticrm/setting' import setting from '@anticrm/setting'
import task from '@anticrm/task'
import { import {
Document, Document,
Issue, Issue,
@ -307,6 +308,23 @@ export function createModel (builder: Builder): void {
tracker.viewlet.List tracker.viewlet.List
) )
builder.createDoc(view.class.Viewlet, core.space.Model, {
attachTo: tracker.class.Issue,
descriptor: tracker.viewlet.Kanban,
config: []
})
builder.createDoc(
view.class.ViewletDescriptor,
core.space.Model,
{
label: tracker.string.Board,
icon: task.icon.Kanban,
component: tracker.component.KanbanView
},
tracker.viewlet.Kanban
)
builder.createDoc( builder.createDoc(
tracker.class.IssueStatusCategory, tracker.class.IssueStatusCategory,
core.space.Model, core.space.Model,
@ -469,12 +487,6 @@ export function createModel (builder: Builder): void {
// icon: tracker.icon.TrackerApplication, // icon: tracker.icon.TrackerApplication,
component: tracker.component.Backlog component: tracker.component.Backlog
}, },
{
id: boardId,
label: tracker.string.Board,
// icon: tracker.icon.TrackerApplication,
component: tracker.component.Board
},
{ {
id: projectsId, id: projectsId,
label: tracker.string.Projects, label: tracker.string.Projects,

View File

@ -46,7 +46,8 @@ export default mergeIds(trackerId, tracker, {
Tracker: '' as Ref<Application> Tracker: '' as Ref<Application>
}, },
viewlet: { viewlet: {
List: '' as Ref<ViewletDescriptor> List: '' as Ref<ViewletDescriptor>,
Kanban: '' as Ref<ViewletDescriptor>
}, },
completion: { completion: {
IssueQuery: '' as Resource<ObjectSearchFactory>, IssueQuery: '' as Resource<ObjectSearchFactory>,

View File

@ -0,0 +1,187 @@
<!--
// 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 contact from '@anticrm/contact'
import { Class, Doc, FindOptions, Ref, WithLookup } from '@anticrm/core'
import { Kanban } from '@anticrm/kanban'
import { getClient } from '@anticrm/presentation'
import { Issue, IssuesGrouping, Team, ViewOptions } from '@anticrm/tracker'
import { Button, Icon, IconAdd, showPopup, Tooltip } from '@anticrm/ui'
import { focusStore, ListSelectionProvider, SelectDirection, selectionStore } from '@anticrm/view-resources'
import ActionContext from '@anticrm/view-resources/src/components/ActionContext.svelte'
import Menu from '@anticrm/view-resources/src/components/Menu.svelte'
import { onMount } from 'svelte'
import tracker from '../../plugin'
import { getKanbanStatuses } from '../../utils'
import CreateIssue from '../CreateIssue.svelte'
import AssigneePresenter from './AssigneePresenter.svelte'
import IssuePresenter from './IssuePresenter.svelte'
import PriorityEditor from './PriorityEditor.svelte'
export let currentSpace: Ref<Team>
export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined
export let viewOptions: ViewOptions
export let query = {}
$: ({ groupBy, shouldShowEmptyGroups, shouldShowSubIssues } = viewOptions)
$: resultQuery = {
...(shouldShowSubIssues ? {} : { attachedTo: tracker.ids.NoParent }),
space: currentSpace,
...query
}
const client = getClient()
function toIssue (object: any): WithLookup<Issue> {
return object as WithLookup<Issue>
}
const options: FindOptions<Issue> = {
lookup: {
assignee: contact.class.Employee,
space: tracker.class.Team
}
}
let kanbanUI: Kanban
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {
kanbanUI.select(offset, of, dir)
})
onMount(() => {
;(document.activeElement as HTMLElement)?.blur()
})
const showMenu = async (ev: MouseEvent, items: Doc[]): Promise<void> => {
ev.preventDefault()
showPopup(
Menu,
{ object: items, baseMenuClass },
{
getBoundingClientRect: () => DOMRect.fromRect({ width: 1, height: 1, x: ev.clientX, y: ev.clientY })
},
() => {
// selection = undefined
}
)
}
</script>
{#await getKanbanStatuses(client, groupBy, resultQuery, shouldShowEmptyGroups) then states}
<ActionContext
context={{
mode: 'browser'
}}
/>
<Kanban
bind:this={kanbanUI}
_class={tracker.class.Issue}
space={currentSpace}
search=""
{states}
{options}
query={resultQuery}
fieldName={groupBy}
rankFieldName={'rank'}
on:content={(evt) => {
listProvider.update(evt.detail)
}}
on:obj-focus={(evt) => {
listProvider.updateFocus(evt.detail)
}}
selection={listProvider.current($focusStore)}
checked={$selectionStore ?? []}
on:check={(evt) => {
listProvider.updateSelection(evt.detail.docs, evt.detail.value)
}}
on:contextmenu={(evt) => showMenu(evt.detail.evt, evt.detail.objects)}
>
<svelte:fragment slot="header" let:state let:count>
<div class="header flex-col">
<div class="flex-between label font-medium w-full h-full">
<div class="flex-row-center gap-2">
<Icon icon={state.icon} size={'small'} />
<span class="lines-limit-2 ml-2">{state.title}</span>
<span class="counter ml-2 text-md">{count}</span>
</div>
{#if groupBy === IssuesGrouping.Status}
<div class="flex gap-1">
<Tooltip label={tracker.string.AddIssueTooltip} direction={'left'}>
<Button
icon={IconAdd}
kind={'transparent'}
on:click={() => {
showPopup(CreateIssue, { space: currentSpace, status: state._id }, 'top')
}}
/>
</Tooltip>
</div>
{/if}
</div>
</div>
</svelte:fragment>
<svelte:fragment slot="card" let:object>
{@const issue = toIssue(object)}
<div class="tracker-card">
<div class="flex-col mr-6">
<IssuePresenter value={issue} />
<span class="fs-bold caption-color mt-1 lines-limit-2">
{object.title}
</span>
</div>
<div class="abs-rt-content">
<AssigneePresenter
value={issue?.$lookup?.assignee}
issueId={issue._id}
{currentSpace}
isEditable={true}
defaultClass={contact.class.Employee}
/>
</div>
<div class="buttons-group xsmall-gap mt-10px">
<PriorityEditor
value={issue}
isEditable={true}
kind={'link-bordered'}
size={'inline'}
justify={'center'}
width={''}
/>
</div>
</div>
</svelte:fragment>
</Kanban>
{/await}
<style lang="scss">
.header {
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--divider-color);
.label {
color: var(--caption-color);
.counter {
color: rgba(var(--caption-color), 0.8);
}
}
}
.tracker-card {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0.5rem 1rem;
min-height: 6.5rem;
}
</style>

View File

@ -94,7 +94,7 @@
<div class="value"> <div class="value">
<MiniToggle bind:on={shouldShowSubIssues} on:change={updateOptions} /> <MiniToggle bind:on={shouldShowSubIssues} on:change={updateOptions} />
</div> </div>
{#if _groupBy === IssuesGrouping.Status || _groupBy === IssuesGrouping.Priority} {#if _groupBy === IssuesGrouping.Status}
<span class="label"><Label label={tracker.string.ShowEmptyGroups} /></span> <span class="label"><Label label={tracker.string.ShowEmptyGroups} /></span>
<div class="value"> <div class="value">
<MiniToggle bind:on={_shouldShowEmptyGroups} on:change={updateOptions} /> <MiniToggle bind:on={_shouldShowEmptyGroups} on:change={updateOptions} />

View File

@ -54,6 +54,7 @@ import TargetDatePresenter from './components/projects/TargetDatePresenter.svelt
import SetDueDateActionPopup from './components/SetDueDateActionPopup.svelte' import SetDueDateActionPopup from './components/SetDueDateActionPopup.svelte'
import SetParentIssueActionPopup from './components/SetParentIssueActionPopup.svelte' import SetParentIssueActionPopup from './components/SetParentIssueActionPopup.svelte'
import Views from './components/views/Views.svelte' import Views from './components/views/Views.svelte'
import KanbanView from './components/issues/KanbanView.svelte'
import tracker from './plugin' import tracker from './plugin'
import { getIssueId } from './utils' import { getIssueId } from './utils'
@ -76,7 +77,6 @@ export async function queryIssue<D extends Issue> (
) )
).map((e) => [e._id, e]) ).map((e) => [e._id, e])
) )
for (const currentTeam of teams) { for (const currentTeam of teams) {
const nids: number[] = [] const nids: number[] = []
for (let n = 0; n < currentTeam.sequence; n++) { for (let n = 0; n < currentTeam.sequence; n++) {
@ -143,6 +143,7 @@ export default async (): Promise<Resources> => ({
EditProject, EditProject,
IssuesView, IssuesView,
ListView, ListView,
KanbanView,
IssuePreview IssuePreview
}, },
function: { function: {

View File

@ -194,6 +194,7 @@ export default mergeIds(trackerId, tracker, {
EditProject: '' as AnyComponent, EditProject: '' as AnyComponent,
IssuesView: '' as AnyComponent, IssuesView: '' as AnyComponent,
ListView: '' as AnyComponent, ListView: '' as AnyComponent,
KanbanView: '' as AnyComponent,
IssuePreview: '' as AnyComponent IssuePreview: '' as AnyComponent
}, },
function: { function: {

View File

@ -13,9 +13,9 @@
// limitations under the License. // limitations under the License.
// //
import { Employee, formatName } from '@anticrm/contact' import contact, { Employee, formatName } from '@anticrm/contact'
import { DocumentQuery, Ref, SortingOrder } from '@anticrm/core' import { DocumentQuery, Ref, SortingOrder, TxOperations } from '@anticrm/core'
import type { Asset, IntlString } from '@anticrm/platform' import { Asset, IntlString, translate } from '@anticrm/platform'
import { import {
IssuePriority, IssuePriority,
Team, Team,
@ -27,6 +27,7 @@ import {
IssueStatus IssueStatus
} from '@anticrm/tracker' } from '@anticrm/tracker'
import { AnyComponent, AnySvelteComponent, getMillisecondsInMonth, MILLISECONDS_IN_WEEK } from '@anticrm/ui' import { AnyComponent, AnySvelteComponent, getMillisecondsInMonth, MILLISECONDS_IN_WEEK } from '@anticrm/ui'
import { TypeState } from '@anticrm/kanban'
import tracker from './plugin' import tracker from './plugin'
export interface NavigationItem { export interface NavigationItem {
@ -392,3 +393,112 @@ export function getCategories (
export function getIssueId (team: Team, issue: Issue): string { export function getIssueId (team: Team, issue: Issue): string {
return `${team.identifier}-${issue.number}` return `${team.identifier}-${issue.number}`
} }
export async function getKanbanStatuses (
client: TxOperations,
groupBy: IssuesGrouping,
issueQuery: DocumentQuery<Issue>,
shouldShowEmptyGroups: boolean
): Promise<TypeState[]> {
if (groupBy === IssuesGrouping.Status && shouldShowEmptyGroups) {
return (
await client.findAll(
tracker.class.IssueStatus,
{ attachedTo: issueQuery.space },
{
lookup: { category: tracker.class.IssueStatusCategory },
sort: { rank: SortingOrder.Ascending }
}
)
).map((status) => ({
_id: status._id,
title: status.name,
color: status.color ?? status.$lookup?.category?.color ?? 0,
icon: status.$lookup?.category?.icon ?? undefined
}))
}
if (groupBy === IssuesGrouping.NoGrouping) return []
if (groupBy === IssuesGrouping.Priority) {
const issues = await client.findAll(tracker.class.Issue, issueQuery, {
sort: { priority: SortingOrder.Ascending }
})
const states = issues.reduce<TypeState[]>((result, issue) => {
const { priority } = issue
if (result.find(({ _id }) => _id === priority) !== undefined) return result
return [
...result,
{
_id: priority,
title: issuePriorities[priority].label,
color: 0,
icon: issuePriorities[priority].icon
}
]
}, [])
await Promise.all(
states.map(async (state) => {
state.title = await translate(state.title as IntlString, {})
})
)
return states
}
if (groupBy === IssuesGrouping.Status) {
const issues = await client.findAll(tracker.class.Issue, issueQuery, {
lookup: { status: [tracker.class.IssueStatus, { category: tracker.class.IssueStatusCategory }] },
sort: { '$lookup.status.rank': SortingOrder.Ascending }
})
return issues.reduce<TypeState[]>((result, issue) => {
const status = issue.$lookup?.status
if (status === undefined || result.find(({ _id }) => _id === status._id) !== undefined) return result
const icon = '$lookup' in status ? status.$lookup?.category?.icon : undefined
return [
...result,
{
_id: status._id,
title: status.name,
color: status.color ?? 0,
icon
}
]
}, [])
}
if (groupBy === IssuesGrouping.Assignee) {
const issues = await client.findAll(tracker.class.Issue, issueQuery, {
lookup: { assignee: contact.class.Employee },
sort: { '$lookup.assignee.name': SortingOrder.Ascending }
})
const noAssignee = await translate(tracker.string.NoAssignee, {})
return issues.reduce<TypeState[]>((result, issue) => {
if (result.find(({ _id }) => _id === issue.assignee) !== undefined) return result
return [
...result,
{
_id: issue.assignee,
title: issue.$lookup?.assignee?.name ?? noAssignee,
color: 0,
icon: undefined
}
]
}, [])
}
if (groupBy === IssuesGrouping.Project) {
const issues = await client.findAll(tracker.class.Issue, issueQuery, {
lookup: { project: tracker.class.Project },
sort: { '$lookup.project.label': SortingOrder.Ascending }
})
const noProject = await translate(tracker.string.NoProject, {})
return issues.reduce<TypeState[]>((result, issue) => {
if (result.find(({ _id }) => _id === issue.project) !== undefined) return result
return [
...result,
{
_id: issue.project,
title: issue.$lookup?.project?.label ?? noProject,
color: 0,
icon: undefined
}
]
}, [])
}
return []
}

View File

@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test' import { test, expect } from '@playwright/test'
import { PlatformSetting, PlatformURI } from './utils' import { generateId, PlatformSetting, PlatformURI } from './utils'
test.use({ test.use({
storageState: PlatformSetting storageState: PlatformSetting
}) })
@ -32,3 +32,21 @@ test('create-issue-and-sub-issue', async ({ page }) => {
await page.click('button:has-text("Save")') await page.click('button:has-text("Save")')
await page.click('span.name:text("sub-issue")') await page.click('span.name:text("sub-issue")')
}) })
test('use-kanban', async ({ page }) => {
await page.goto(`${PlatformURI}/workbench%3Acomponent%3AWorkbenchApp`)
await page.click('[id="app-tracker\\:string\\:TrackerApplication"]')
await expect(page).toHaveURL(`${PlatformURI}/workbench%3Acomponent%3AWorkbenchApp/tracker%3Aapp%3ATracker`)
const issueName = 'issue-' + generateId(5)
await page.click('button:has-text("New issue")')
await page.click('[placeholder="Issue\\ title"]')
await page.fill('[placeholder="Issue\\ title"]', issueName)
await page.click('button:has-text("Backlog")')
await page.click('button:has-text("In Progress")')
await page.click('button:has-text("Save issue")')
await page.locator('text="Issues"').click()
await page.click('[name="tooltip-tracker:string:Board"]')
await expect(page.locator('.panel-container:has-text("In Progress")')).toContainText(issueName)
})