Add Labels support (#2104)

Signed-off-by: Dvinyanin Alexandr <dvinyanin.alexandr@gmail.com>
This commit is contained in:
Alex
2022-06-19 23:27:47 +07:00
committed by GitHub
parent df727de184
commit 71b2cd438f
14 changed files with 295 additions and 73 deletions
+1
View File
@@ -6,6 +6,7 @@ Tracker:
- Issue state history.
- Subissue issue popup.
- Label support
Lead:
+2 -1
View File
@@ -43,6 +43,7 @@
"@anticrm/view": "~0.6.0",
"@anticrm/model-presentation": "~0.6.0",
"@anticrm/setting": "~0.6.1",
"@anticrm/task": "~0.6.0"
"@anticrm/task": "~0.6.0",
"@anticrm/tags": "~0.6.2"
}
}
+3 -2
View File
@@ -51,6 +51,7 @@ import {
Team
} from '@anticrm/tracker'
import { KeyBinding } from '@anticrm/view'
import tags from '@anticrm/tags'
import tracker from './plugin'
import presentation from '@anticrm/model-presentation'
@@ -184,8 +185,8 @@ export class TIssue extends TAttachedDoc implements Issue {
@Prop(Collection(attachment.class.Attachment), tracker.string.Attachments)
attachments!: number
// @Prop(Collection(core.class.TypeString), tracker.string.Labels)
labels?: string[]
@Prop(Collection(tags.class.TagReference), tracker.string.Labels)
labels?: number
declare space: Ref<Team>
+15 -1
View File
@@ -14,8 +14,9 @@
//
import core, { Doc, generateId, Ref, SortingOrder, TxOperations, TxResult } from '@anticrm/core'
import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@anticrm/model'
import { createOrUpdate, MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@anticrm/model'
import { IssueStatus, IssueStatusCategory, Team, genRanks, Issue } from '@anticrm/tracker'
import tags from '@anticrm/tags'
import { DOMAIN_TRACKER } from '.'
import tracker from './plugin'
@@ -256,6 +257,19 @@ async function upgradeProjectIcons (tx: TxOperations): Promise<void> {
async function createDefaults (tx: TxOperations): Promise<void> {
await createDefaultTeam(tx)
await createOrUpdate(
tx,
tags.class.TagCategory,
tags.space.Tags,
{
icon: tags.icon.Tags,
label: 'Other',
targetClass: tracker.class.Issue,
tags: [],
default: true
},
tracker.category.Other
)
}
async function upgradeTeams (tx: TxOperations): Promise<void> {
+1
View File
@@ -51,6 +51,7 @@
"Unassigned": "Unassigned",
"Back": "Back",
"List": "List",
"NumberLabels": "{count, plural, =0 {no labels} =1 {1 label} other {# labels}}",
"CategoryBacklog": "Backlog",
"CategoryUnstarted": "Unstarted",
+1
View File
@@ -51,6 +51,7 @@
"Unassigned": "Не назначен",
"Back": "Назад",
"List": "Список",
"NumberLabels": "{count, plural, =0 {нет меток} =1 {1 метка} =2 {2 метки} =3 {3 метки} =4 {4 метки} other {# меток}}",
"CategoryBacklog": "Пул",
"CategoryUnstarted": "Не запущенные",
+1
View File
@@ -41,6 +41,7 @@
"@anticrm/login": "~0.6.1",
"@anticrm/setting": "~0.6.1",
"@anticrm/calendar": "~0.6.0",
"@anticrm/tags": "~0.6.2",
"@anticrm/task": "~0.6.0",
"@anticrm/chunter": "~0.6.1",
"@anticrm/notification": "~0.6.0",
@@ -15,12 +15,14 @@
<script lang="ts">
import { AttachmentStyledBox } from '@anticrm/attachment-resources'
import { Employee } from '@anticrm/contact'
import core, { AttachedData, generateId, Ref, SortingOrder, WithLookup } from '@anticrm/core'
import { Card, createQuery, getClient, SpaceSelector } from '@anticrm/presentation'
import core, { Account, AttachedData, Doc, generateId, Ref, SortingOrder, WithLookup } from '@anticrm/core'
import { Card, createQuery, getClient, KeyedAttribute, SpaceSelector } from '@anticrm/presentation'
import { calcRank, Issue, IssuePriority, IssueStatus, Project, Team } from '@anticrm/tracker'
import tags, { TagElement, TagReference } from '@anticrm/tags'
import {
ActionIcon,
Button,
Component,
DatePresenter,
EditBox,
IconAttachment,
@@ -48,6 +50,7 @@
let currentAssignee: Ref<Employee> | null = assignee
let issueStatuses: WithLookup<IssueStatus>[] | undefined
let parentIssue: Issue | undefined
let labels: TagReference[] = []
let objectId: Ref<Issue> = generateId()
let object: AttachedData<Issue> = {
@@ -70,6 +73,11 @@
let descriptionBox: AttachmentStyledBox
const key: KeyedAttribute = {
key: 'labels',
attr: client.getHierarchy().getAttribute(tracker.class.Issue, 'labels')
}
$: _space = space
$: updateIssueStatusId(space, status)
$: canSave = getTitle(object.title ?? '').length > 0
@@ -147,6 +155,13 @@
value,
objectId
)
for (const label of labels) {
await client.addCollection(label._class, label.space, objectId, tracker.class.Issue, 'labels', {
title: label.title,
color: label.color,
tag: label.tag
})
}
await descriptionBox.createAttachments()
objectId = generateId()
}
@@ -201,6 +216,25 @@
object = { ...object, project: projectId }
}
function addTagRef (tag: TagElement): void {
labels = [
...labels,
{
_class: tags.class.TagReference,
_id: generateId() as Ref<TagReference>,
attachedTo: '' as Ref<Doc>,
attachedToClass: tracker.class.Issue,
collection: 'labels',
space: tags.space.Tags,
modifiedOn: 0,
modifiedBy: '' as Ref<Account>,
title: tag.title,
tag: tag._id,
color: tag.color
}
]
}
</script>
<Card
@@ -246,14 +280,16 @@
/>
<svelte:fragment slot="pool">
{#if issueStatuses}
<StatusEditor
value={object}
statuses={issueStatuses}
kind="no-border"
size="small"
shouldShowLabel={true}
on:change={({ detail }) => (object.status = detail)}
/>
<div id="status-editor">
<StatusEditor
value={object}
statuses={issueStatuses}
kind="no-border"
size="small"
shouldShowLabel={true}
on:change={({ detail }) => (object.status = detail)}
/>
</div>
<PriorityEditor
value={object}
shouldShowLabel
@@ -270,13 +306,21 @@
tooltipFill={false}
on:change={({ detail }) => (currentAssignee = detail)}
/>
<!-- <Button
label={tracker.string.Labels}
icon={tracker.icon.Labels}
width="min-content"
size="small"
kind="no-border"
/> -->
<Component
is={tags.component.TagsDropdownEditor}
props={{
items: labels,
key,
targetClass: tracker.class.Issue,
countLabel: tracker.string.NumberLabels
}}
on:open={(evt) => {
addTagRef(evt.detail)
}}
on:delete={(evt) => {
labels = labels.filter((it) => it._id !== evt.detail)
}}
/>
<ProjectSelector value={object.project} onProjectIdChange={handleProjectIdChanged} />
{#if object.dueDate !== null}
<DatePresenter bind:value={object.dueDate} editable />
@@ -15,7 +15,8 @@
<script lang="ts">
import { WithLookup } from '@anticrm/core'
import type { Issue, IssueStatus } from '@anticrm/tracker'
import { Button, Label } from '@anticrm/ui'
import { Component, Label } from '@anticrm/ui'
import tags from '@anticrm/tags'
import tracker from '../../../plugin'
import PriorityEditor from '../PriorityEditor.svelte'
import StatusEditor from '../StatusEditor.svelte'
@@ -46,14 +47,7 @@
<span class="label">
<Label label={tracker.string.Labels} />
</span>
<Button
label={tracker.string.Labels}
icon={tracker.icon.Labels}
size={'large'}
kind={'link'}
width={'100%'}
justify={'left'}
/>
<Component is={tags.component.TagsAttributeEditor} props={{ object: issue }} />
<div class="divider" />
@@ -14,11 +14,12 @@
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import core, { AttachedData, Ref, SortingOrder, WithLookup } from '@anticrm/core'
import presentation, { getClient } from '@anticrm/presentation'
import core, { Account, AttachedData, Doc, generateId, Ref, SortingOrder, WithLookup } from '@anticrm/core'
import presentation, { getClient, KeyedAttribute } from '@anticrm/presentation'
import { StyledTextArea } from '@anticrm/text-editor'
import { IssueStatus, IssuePriority, Issue, Team, calcRank } from '@anticrm/tracker'
import { Button, EditBox } from '@anticrm/ui'
import { Button, Component, EditBox } from '@anticrm/ui'
import tags, { TagElement, TagReference } from '@anticrm/tags'
import tracker from '../../../plugin'
import AssigneeEditor from '../AssigneeEditor.svelte'
import StatusEditor from '../StatusEditor.svelte'
@@ -34,6 +35,12 @@
let newIssue: AttachedData<Issue> = getIssueDefaults()
let thisRef: HTMLDivElement
let focusIssueTitle: () => void
let labels: TagReference[] = []
const key: KeyedAttribute = {
key: 'labels',
attr: client.getHierarchy().getAttribute(tracker.class.Issue, 'labels')
}
function getIssueDefaults (): AttachedData<Issue> {
return {
@@ -90,10 +97,43 @@
rank: calcRank(lastOne, undefined)
}
await client.addCollection(tracker.class.Issue, space, parentIssue._id, parentIssue._class, 'subIssues', value)
const objectId = await client.addCollection(
tracker.class.Issue,
space,
parentIssue._id,
parentIssue._class,
'subIssues',
value
)
for (const label of labels) {
await client.addCollection(label._class, label.space, objectId, tracker.class.Issue, 'labels', {
title: label.title,
color: label.color,
tag: label.tag
})
}
resetToDefaults()
}
function addTagRef (tag: TagElement): void {
labels = [
...labels,
{
_class: tags.class.TagReference,
_id: generateId() as Ref<TagReference>,
attachedTo: '' as Ref<Doc>,
attachedToClass: tracker.class.Issue,
collection: 'labels',
space: tags.space.Tags,
modifiedOn: 0,
modifiedBy: '' as Ref<Account>,
title: tag.title,
tag: tag._id,
color: tag.color
}
]
}
$: thisRef && thisRef.scrollIntoView({ behavior: 'smooth' })
$: canSave = getTitle(newIssue.title ?? '').length > 0
$: if (!newIssue.status && currentTeam?.defaultIssueStatus) {
@@ -150,6 +190,21 @@
tooltipFill={false}
on:change={({ detail }) => (newIssue.assignee = detail)}
/>
<Component
is={tags.component.TagsDropdownEditor}
props={{
items: labels,
key,
targetClass: tracker.class.Issue,
countLabel: tracker.string.NumberLabels
}}
on:open={(evt) => {
addTagRef(evt.detail)
}}
on:delete={(evt) => {
labels = labels.filter((it) => it._id !== evt.detail)
}}
/>
</div>
<div class="buttons-group small-gap">
<Button label={presentation.string.Cancel} size="small" kind="transparent" on:click={close} />
+1
View File
@@ -136,6 +136,7 @@ export default mergeIds(trackerId, tracker, {
ProjectLeadSearchPlaceholder: '' as IntlString,
ProjectMembersSearchPlaceholder: '' as IntlString,
List: '' as IntlString,
NumberLabels: '' as IntlString,
IssueTitlePlaceholder: '' as IntlString,
IssueDescriptionPlaceholder: '' as IntlString,
+1
View File
@@ -34,6 +34,7 @@
"@anticrm/chunter": "~0.6.1",
"@anticrm/attachment": "~0.6.1",
"@anticrm/task": "~0.6.0",
"@anticrm/tags": "~0.6.2",
"lexorank": "~1.0.4"
}
}
+3 -1
View File
@@ -19,6 +19,7 @@ import { Action, ActionCategory } from '@anticrm/view'
import type { Asset, IntlString, Plugin } from '@anticrm/platform'
import { plugin } from '@anticrm/platform'
import { AnyComponent } from '@anticrm/ui'
import type { TagCategory } from '@anticrm/tags'
/**
* @public
@@ -114,7 +115,7 @@ export interface Issue extends AttachedDoc {
comments: number
attachments?: number
labels?: string[]
labels?: number
space: Ref<Team>
@@ -261,6 +262,7 @@ export default plugin(trackerId, {
CopyBranch: '' as Asset
},
category: {
Other: '' as Ref<TagCategory>,
Tracker: '' as Ref<ActionCategory>
},
action: {
+143 -38
View File
@@ -3,35 +3,6 @@ import { generateId, PlatformSetting, PlatformURI } from './utils'
test.use({
storageState: PlatformSetting
})
test('create-issue-and-sub-issue', 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`)
await page.click('button:has-text("New issue")')
await page.click('[placeholder="Issue\\ title"]')
await page.fill('[placeholder="Issue\\ title"]', 'test-issue')
await page.fill('.ProseMirror', 'some description')
await page.click('button:has-text("Backlog")')
await page.click('button:has-text("Todo")')
await page.click('button:has-text("No priority")')
await page.click('button:has-text("Urgent")')
await page.click('button:has-text("Save issue")')
await page.click('.antiNav-element__dropbox :text("Issues")')
await page.click('.antiList__row :has-text("test-issue") .issuePresenter')
await page.click('#add-sub-issue')
await page.click('[placeholder="Issue\\ title"]')
await page.fill('[placeholder="Issue\\ title"]', 'sub-issue')
await page.fill('.ProseMirror', 'sub-issue description')
await page.click('#status-editor')
await page.click('.selectPopup button:has-text("In Progress")')
await page.click('.button:has-text("Assignee")')
await page.click('.selectPopup button:has-text("John Appleseed")')
await page.click('button:has-text("No priority")')
await page.click('.selectPopup button:has-text("High")')
await page.click('button:has-text("Save")')
await page.click('span.name:text("sub-issue")')
})
async function navigate (page: Page): Promise<void> {
await page.goto(`${PlatformURI}/workbench%3Acomponent%3AWorkbenchApp`)
@@ -39,17 +10,153 @@ async function navigate (page: Page): Promise<void> {
await expect(page).toHaveURL(`${PlatformURI}/workbench%3Acomponent%3AWorkbenchApp/tracker%3Aapp%3ATracker`)
}
async function createIssue (page: Page, props: { [p: string]: string } = {}): Promise<void> {
await page.click('button:has-text("New issue")')
await page.click('[placeholder="Issue\\ title"]')
await page.fill('[placeholder="Issue\\ title"]', props.name ?? '')
if (props.status !== undefined) {
await page.click('button:has-text("Backlog")')
await page.click(`.menu-item:has-text("${props.status}")`)
interface IssueProps {
name: string
description?: string
status?: string
labels?: string[]
priority?: string
assignee?: string
}
async function fillIssueForm (
page: Page,
{ name, description, status, assignee, labels, priority }: IssueProps
): Promise<void> {
await page.fill('[placeholder="Issue\\ title"]', name)
if (description !== undefined) {
await page.fill('.ProseMirror', description)
}
if (status !== undefined) {
await page.click('#status-editor')
await page.click(`.menu-item:has-text("${status}")`)
}
if (priority !== undefined) {
await page.click('button:has-text("No priority")')
await page.click(`.selectPopup button:has-text("${priority}")`)
}
if (labels !== undefined) {
await page.click('.button:has-text("Labels")')
for (const label of labels) {
await page.click(`.selectPopup button:has-text("${label}") >> nth=0`)
}
await page.keyboard.press('Escape')
}
if (assignee !== undefined) {
await page.click('.button:has-text("Assignee")')
await page.click(`.selectPopup button:has-text("${assignee}")`)
}
}
async function createIssue (page: Page, props: IssueProps): Promise<void> {
await page.click('button:has-text("New issue")')
await fillIssueForm(page, props)
await page.click('button:has-text("Save issue")')
}
async function createSubissue (page: Page, props: IssueProps): Promise<void> {
await page.click('button:has-text("Add sub-issue")')
await fillIssueForm(page, props)
await page.click('button:has-text("Save")')
}
interface LabelProps {
label: string
}
async function createLabel (page: Page, { label }: LabelProps): Promise<void> {
await page.click('button:has-text("New issue")')
await page.click('button:has-text("Labels")')
await page.click('.buttons-group >> button >> nth=-1')
await page.fill('[id="tags:string:AddTag"] >> input >> nth=0', label)
await page.click('[id="tags:string:AddTag"] >> button:has-text("Create")')
await page.waitForTimeout(100)
await page.keyboard.press('Escape')
await page.waitForTimeout(100)
await page.keyboard.press('Escape')
}
async function checkIssue (
page: Page,
{ name, description, status, assignee, labels, priority }: IssueProps
): Promise<void> {
if (name !== undefined) {
await expect(page.locator('.popupPanel')).toContainText(name)
}
if (description !== undefined) {
await expect(page.locator('.popupPanel')).toContainText(description)
}
const asideLocator = page.locator('.popupPanel-body__aside')
if (status !== undefined) {
await expect(asideLocator).toContainText(status)
}
if (labels !== undefined) {
await expect(asideLocator).toContainText(labels)
}
if (priority !== undefined) {
await expect(asideLocator).toContainText(priority)
}
if (assignee !== undefined) {
await expect(asideLocator).toContainText(assignee)
}
}
const defaultStatuses = ['Backlog', 'Todo', 'In Progress', 'Done', 'Canceled']
const defaultPriorities = ['No priority', 'Urgent', 'High', 'Medium', 'Low']
const defaultUser = 'John Appleseed'
test.describe('create-issue-and-sub-issue', () => {
const labels = ['label', 'another-label']
async function testIssue (page: Page, props: IssueProps): Promise<void> {
await createIssue(page, props)
await page.click('text="Issues"')
await page.click(`.antiList__row :has-text("${props.name}") .issuePresenter`)
await checkIssue(page, props)
props.name = `sub${props.name}`
await createSubissue(page, props)
await page.click(`span:has-text("${props.name}")`)
await checkIssue(page, props)
}
test.beforeEach(async ({ page }) => await navigate(page))
test('with-description', async ({ page }) =>
await testIssue(page, { name: getIssueName(), description: 'some-description' }))
test('with-assignee', async ({ page }) => await testIssue(page, { name: getIssueName(), assignee: defaultUser }))
test.describe('with-status', () => {
for (const status of defaultStatuses) {
test(status, async ({ page }) => await testIssue(page, { name: getIssueName(), status }))
}
})
test.describe('with-priority', () => {
for (const priority of defaultPriorities) {
test(priority, async ({ page }) => await testIssue(page, { name: getIssueName(), priority }))
}
})
test('with-labels', async ({ page }) => {
for (const label of labels) {
await createLabel(page, { label })
}
await testIssue(page, { name: getIssueName(), labels })
})
test('with-all-props', async ({ page }) => {
for (const label of labels) {
await createLabel(page, { label })
}
await testIssue(page, {
name: getIssueName(),
description: 'description',
labels,
status: defaultStatuses[0],
priority: defaultPriorities[0],
assignee: defaultUser
})
})
})
const getIssueName = (postfix: string = generateId(5)): string => `issue-${postfix}`
test('use-kanban', async ({ page }) => {
@@ -63,8 +170,6 @@ test('use-kanban', async ({ page }) => {
await expect(page.locator(`.panel-container:has-text("${status}")`)).toContainText(name)
})
const defaultStatuses = ['Backlog', 'Todo', 'In Progress', 'Done', 'Canceled']
test.describe('issues-status-display', () => {
const panelStatusMap = new Map([
['Issues', defaultStatuses],