Tracker: Add relation (#2174)

Signed-off-by: Dvinyanin Alexandr <dvinyanin.alexandr@gmail.com>
This commit is contained in:
Alex 2022-07-01 13:14:31 +07:00 committed by GitHub
parent ce71d23f49
commit 25afe12cc2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 416 additions and 7 deletions

View File

@ -15,6 +15,7 @@ HR:
Tracker: Tracker:
- Manual issues ordering - Manual issues ordering
- Issue relations
Workbench Workbench
- Use application aliases in URL - Use application aliases in URL

View File

@ -853,7 +853,7 @@ export function createModel (builder: Builder): void {
builder, builder,
{ {
action: view.actionImpl.Move, action: view.actionImpl.Move,
label: view.string.Move, label: tracker.string.MoveToTeam,
icon: view.icon.Move, icon: view.icon.Move,
keyBinding: [], keyBinding: [],
input: 'none', input: 'none',
@ -862,9 +862,32 @@ export function createModel (builder: Builder): void {
context: { context: {
mode: ['context', 'browser'], mode: ['context', 'browser'],
application: tracker.app.Tracker, application: tracker.app.Tracker,
group: 'edit' group: 'associate'
} }
}, },
tracker.action.CopyIssueLink tracker.action.MoveToTeam
)
// TODO: fix icon
createAction(
builder,
{
action: view.actionImpl.ValueSelector,
actionPopup: tracker.component.RelationsPopup,
actionProps: {
attribute: ''
},
label: tracker.string.Relations,
icon: tracker.icon.Document,
keyBinding: [],
input: 'focus',
category: tracker.category.Tracker,
target: tracker.class.Issue,
context: {
mode: ['context', 'browser'],
application: tracker.app.Tracker,
group: 'associate'
}
},
tracker.action.Relations
) )
} }

View File

@ -34,6 +34,7 @@
"dependencies": { "dependencies": {
"@anticrm/platform": "~0.6.6", "@anticrm/platform": "~0.6.6",
"@anticrm/theme": "~0.6.0", "@anticrm/theme": "~0.6.0",
"@anticrm/core": "~0.6.16",
"svelte": "^3.47", "svelte": "^3.47",
"@types/jest": "~28.1.0" "@types/jest": "~28.1.0"
} }

View File

@ -14,6 +14,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import { afterUpdate, createEventDispatcher, onDestroy, onMount } from 'svelte' import { afterUpdate, createEventDispatcher, onDestroy, onMount } from 'svelte'
import { generateId } from '@anticrm/core'
import ui from '../plugin' import ui from '../plugin'
import { closePopup, showPopup } from '../popups' import { closePopup, showPopup } from '../popups'
import { Action } from '../types' import { Action } from '../types'
@ -27,6 +28,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const btns: HTMLElement[] = [] const btns: HTMLElement[] = []
let activeElement: HTMLElement let activeElement: HTMLElement
const category = generateId()
const keyDown = (ev: KeyboardEvent): void => { const keyDown = (ev: KeyboardEvent): void => {
if (ev.key === 'Tab') { if (ev.key === 'Tab') {
@ -51,7 +53,7 @@
} }
if (ev.key === 'ArrowLeft') { if (ev.key === 'ArrowLeft') {
dispatch('update', 'left') dispatch('update', 'left')
closePopup('submenu') closePopup(category)
ev.preventDefault() ev.preventDefault()
ev.stopPropagation() ev.stopPropagation()
} }
@ -72,11 +74,11 @@
} }
}) })
onDestroy(() => { onDestroy(() => {
closePopup('submenu') closePopup(category)
}) })
function showActionPopup (action: Action, target: HTMLElement): void { function showActionPopup (action: Action, target: HTMLElement): void {
closePopup('submenu') closePopup(category)
if (action.component !== undefined) { if (action.component !== undefined) {
console.log(action.props) console.log(action.props)
showPopup( showPopup(
@ -87,7 +89,7 @@
dispatch('close') dispatch('close')
}, },
undefined, undefined,
{ category: 'submenu', overlay: false } { category, overlay: false }
) )
} }
} }

View File

@ -123,6 +123,7 @@
"ProjectLeadSearchPlaceholder": "Set project lead\u2026", "ProjectLeadSearchPlaceholder": "Set project lead\u2026",
"ProjectMembersSearchPlaceholder": "Change project members\u2026", "ProjectMembersSearchPlaceholder": "Change project members\u2026",
"Roadmap": "Roadmap", "Roadmap": "Roadmap",
"MoveToTeam": "Move to team",
"GotoIssues": "Go to issues", "GotoIssues": "Go to issues",
"GotoActive": "Go to active issues", "GotoActive": "Go to active issues",
@ -141,6 +142,20 @@
"Created": "Created", "Created": "Created",
"Subscribed": "Subscribed", "Subscribed": "Subscribed",
"Relations": "Relations",
"RemoveRelation": "Remove relation...",
"AddBlockedBy": "Mark as blocked by...",
"AddIsBlocking": "Mark as bloking...",
"AddRelatedIssue": "Reference another issue...",
"RelatedIssue": "Related issue {id} - {title}",
"BlockedIssue": "Blocked issue {id} - {title}",
"BlockingIssue": "Blocking issue {id} - {title}",
"BlockedBySearchPlaceholder": "Search for issue to mark blocked by...",
"IsBlockingSearchPlaceholder": "Search for issue to mark as blocking...",
"RelatedIssueSearchPlaceholder": "Search for issue to reference...",
"Blocks": "Blocks",
"Related": "Related",
"EditIssue": "Edit {title}", "EditIssue": "Edit {title}",
"Save": "Save", "Save": "Save",

View File

@ -123,6 +123,7 @@
"ProjectLeadSearchPlaceholder": "Назначьте руководителя проекта\u2026", "ProjectLeadSearchPlaceholder": "Назначьте руководителя проекта\u2026",
"ProjectMembersSearchPlaceholder": "Измененить участников проекта\u2026", "ProjectMembersSearchPlaceholder": "Измененить участников проекта\u2026",
"Roadmap": "Планирование", "Roadmap": "Планирование",
"MoveToTeam": "Изменить команду",
"GotoIssues": "Перейти к задачам", "GotoIssues": "Перейти к задачам",
"GotoActive": "Перейти к активным задачам", "GotoActive": "Перейти к активным задачам",
@ -141,6 +142,20 @@
"Created": "Созданные", "Created": "Созданные",
"Subscribed": "Отслеживаемые", "Subscribed": "Отслеживаемые",
"Relations": "Зависимости",
"RemoveRelation": "Удалить зависимость...",
"AddBlockedBy": "Отметить как блокируемую...",
"AddIsBlocking": "Отметить как блокирующую...",
"AddRelatedIssue": "Связать с задачей...",
"RelatedIssue": "Связанная задача {id} - {title}",
"BlockedIssue": "Блокируемая задача {id} - {title}",
"BlockingIssue": "Блокирующая {id} - {title}",
"BlockedBySearchPlaceholder": "Поиск блокирующей задачи...",
"IsBlockingSearchPlaceholder": "Поиск блокируемой задачи...",
"RelatedIssueSearchPlaceholder": "Поиск связанной задачи...",
"Blocks": "Блокирует",
"Related": "Связан",
"EditIssue": "Редактирование {title}", "EditIssue": "Редактирование {title}",
"Save": "Сохранить", "Save": "Сохранить",

View File

@ -0,0 +1,90 @@
<script lang="ts">
import { Ref } from '@anticrm/core'
import { createQuery, getClient } from '@anticrm/presentation'
import { Issue } from '@anticrm/tracker'
import { Action, closePopup, Menu, showPopup } from '@anticrm/ui'
import SelectIssuePopup from './SelectIssuePopup.svelte'
import SelectRelationPopup from './SelectRelationPopup.svelte'
import tracker from '../plugin'
import { updateIssueRelation } from '../issues'
import { IntlString } from '@anticrm/platform'
export let value: Issue
const client = getClient()
const query = createQuery()
$: relations = {
blockedBy: value.blockedBy ?? [],
relatedIssue: value.relatedIssue ?? [],
isBlocking: isBlocking ?? []
}
let isBlocking: Ref<Issue>[] = []
$: query.query(tracker.class.Issue, { blockedBy: value._id }, (result) => {
isBlocking = result.map(({ _id }) => _id)
})
$: hasRelation = Object.values(relations).some(({ length }) => length)
async function updateRelation (issue: Issue, type: keyof typeof relations, operation: '$push' | '$pull') {
const prop = type === 'isBlocking' ? 'blockedBy' : type
if (type !== 'isBlocking') {
await updateIssueRelation(client, value, issue._id, prop, operation)
}
if (type !== 'blockedBy') {
await updateIssueRelation(client, issue, value._id, prop, operation)
}
}
const makeAddAction = (type: keyof typeof relations, placeholder: IntlString) => async () => {
closePopup('popup')
showPopup(
SelectIssuePopup,
{ ignoreObjects: [value._id, ...relations[type]], placeholder },
undefined,
async (issue: Issue | undefined) => {
if (!issue) return
await updateRelation(issue, type, '$push')
}
)
}
async function removeRelation () {
closePopup('popup')
showPopup(
SelectRelationPopup,
relations,
undefined,
async (result: { type: keyof typeof relations; issue: Issue } | undefined) => {
if (!result) return
await updateRelation(result.issue, result.type, '$pull')
}
)
}
const removeRelationAction: Action[] = [
{
action: removeRelation,
icon: tracker.icon.Issue,
label: tracker.string.RemoveRelation,
group: '1'
}
]
$: actions = [
{
action: makeAddAction('blockedBy', tracker.string.BlockedBySearchPlaceholder),
icon: tracker.icon.Issue,
label: tracker.string.AddBlockedBy
},
{
action: makeAddAction('isBlocking', tracker.string.IsBlockingSearchPlaceholder),
icon: tracker.icon.Issue,
label: tracker.string.AddIsBlocking
},
{
action: makeAddAction('relatedIssue', tracker.string.RelatedIssueSearchPlaceholder),
icon: tracker.icon.Issue,
label: tracker.string.AddRelatedIssue
},
...(hasRelation ? removeRelationAction : [])
]
</script>
<Menu {actions} />

View File

@ -0,0 +1,49 @@
<script lang="ts">
import { DocumentQuery, FindOptions, Ref, SortingOrder } from '@anticrm/core'
import { IntlString } from '@anticrm/platform'
import { ObjectPopup } from '@anticrm/presentation'
import { Issue } from '@anticrm/tracker'
import { Icon } from '@anticrm/ui'
import { getIssueId } from '../issues'
import tracker from '../plugin'
export let docQuery: DocumentQuery<Issue> | undefined = undefined
export let ignoreObjects: Ref<Issue>[] | undefined = undefined
export let placeholder: IntlString | undefined = undefined
export let width: 'medium' | 'large' | 'full' = 'large'
const options: FindOptions<Issue> = {
lookup: {
status: [tracker.class.IssueStatus, { category: tracker.class.IssueStatusCategory }],
space: tracker.class.Team
},
sort: { modifiedOn: SortingOrder.Descending }
}
</script>
<ObjectPopup
_class={tracker.class.Issue}
{options}
{docQuery}
{placeholder}
{ignoreObjects}
{width}
searchField="title"
groupBy="space"
on:update
on:close
>
<svelte:fragment slot="item" let:item={issue}>
{@const { icon } = issue.$lookup?.status.$lookup?.category ?? {}}
{@const issueId = getIssueId(issue.$lookup.space, issue)}
{#if issueId && icon}
<div class="flex-center clear-mins w-full h-9">
<div class="icon mr-4 h-8">
<Icon {icon} size="small" />
</div>
<span class="overflow-label flex-no-shrink mr-3">{issueId}</span>
<span class="overflow-label w-full issue-title">{issue.title}</span>
</div>
{/if}
</svelte:fragment>
</ObjectPopup>

View File

@ -0,0 +1,70 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { Ref } from '@anticrm/core'
import { getClient } from '@anticrm/presentation'
import { Issue } from '@anticrm/tracker'
import { SelectPopup, Loading } from '@anticrm/ui'
import { translate } from '@anticrm/platform'
import { getIssueId } from '../issues'
import tracker from '../plugin'
export let blockedBy: Ref<Issue>[] = []
export let isBlocking: Ref<Issue>[] = []
export let relatedIssue: Ref<Issue>[] = []
// TODO: fix icons
const config = {
blockedBy: {
label: tracker.string.BlockedIssue,
icon: tracker.icon.Issue
},
isBlocking: {
label: tracker.string.BlockingIssue,
icon: tracker.icon.Issues
},
relatedIssue: {
label: tracker.string.RelatedIssue,
icon: tracker.icon.Team
}
}
const client = getClient()
const dispatch = createEventDispatcher()
async function getValue () {
const issues = await client.findAll(
tracker.class.Issue,
{ _id: { $in: [...blockedBy, ...isBlocking, ...relatedIssue] } },
{ lookup: { space: tracker.class.Team } }
)
const valueFactory = (type: keyof typeof config) => async (issueId: Ref<Issue>) => {
const issue = issues.find(({ _id }) => _id === issueId)
if (!issue?.$lookup?.space) return
const { label, icon } = config[type]
const text = await translate(label, { id: getIssueId(issue.$lookup.space, issue), title: issue.title })
return { text, icon, issue, type }
}
return (
await Promise.all([
...blockedBy.map(valueFactory('blockedBy')),
...isBlocking.map(valueFactory('isBlocking')),
...relatedIssue.map(valueFactory('relatedIssue'))
])
).map((val, id) => ({ ...val, id }))
}
</script>
{#await getValue()}
<Loading />
{:then value}
<SelectPopup
{value}
width="large"
searchable
placeholder={tracker.string.RemoveRelation}
on:close={(e) => {
if (e.detail === undefined) dispatch('close')
else dispatch('close', value[e.detail])
}}
/>
{/await}

View File

@ -0,0 +1,83 @@
<script lang="ts">
import { WithLookup } from '@anticrm/core'
import { createQuery, getClient } from '@anticrm/presentation'
import { Issue } from '@anticrm/tracker'
import { Icon, IconClose } from '@anticrm/ui'
import { getIssueId, updateIssueRelation } from '../../issues'
import tracker from '../../plugin'
export let value: Issue
export let type: 'isBlocking' | 'blockedBy' | 'relatedIssue'
const client = getClient()
const issuesQuery = createQuery()
// TODO: fix icon
$: icon = tracker.icon.Issue
$: query = type === 'isBlocking' ? { blockedBy: value._id } : { _id: { $in: value[type] } }
let issues: WithLookup<Issue>[] = []
$: issuesQuery.query(
tracker.class.Issue,
query,
(result) => {
issues = result
},
{ lookup: { space: tracker.class.Team } }
)
async function handleClick (issue: Issue) {
const prop = type === 'isBlocking' ? 'blockedBy' : type
if (type !== 'isBlocking') {
await updateIssueRelation(client, value, issue._id, prop, '$pull')
}
if (type !== 'blockedBy') {
await updateIssueRelation(client, issue, value._id, prop, '$pull')
}
}
</script>
<div class="flex-column">
{#each issues as issue}
{#if issue.$lookup?.space}
<div class="tag-container">
<Icon {icon} size={'small'} />
<span class="overflow-label ml-1-5 caption-color">{getIssueId(issue.$lookup.space, issue)}</span>
<button class="btn-close" on:click|stopPropagation={() => handleClick(issue)}>
<Icon icon={IconClose} size={'x-small'} />
</button>
</div>
{/if}
{/each}
</div>
<style lang="scss">
.tag-container {
overflow: hidden;
display: flex;
align-items: center;
flex-shrink: 0;
padding-left: 0.5rem;
height: 1.5rem;
min-width: 0;
min-height: 0;
border-radius: 0.5rem;
width: fit-content;
&:hover {
border: 1px solid var(--divider-color);
}
.btn-close {
flex-shrink: 0;
margin-left: 0.125rem;
padding: 0 0.25rem 0 0.125rem;
height: 1.75rem;
color: var(--content-color);
border-left: 1px solid transparent;
&:hover {
color: var(--caption-color);
border-left-color: var(--divider-color);
}
}
}
</style>

View File

@ -15,6 +15,7 @@
<script lang="ts"> <script lang="ts">
import { WithLookup } from '@anticrm/core' import { WithLookup } from '@anticrm/core'
import type { Issue, IssueStatus } from '@anticrm/tracker' import type { Issue, IssueStatus } from '@anticrm/tracker'
import { createQuery } from '@anticrm/presentation'
import { Component, Label } from '@anticrm/ui' import { Component, Label } from '@anticrm/ui'
import tags from '@anticrm/tags' import tags from '@anticrm/tags'
import tracker from '../../../plugin' import tracker from '../../../plugin'
@ -23,9 +24,16 @@
import ProjectEditor from '../../projects/ProjectEditor.svelte' import ProjectEditor from '../../projects/ProjectEditor.svelte'
import AssigneeEditor from '../AssigneeEditor.svelte' import AssigneeEditor from '../AssigneeEditor.svelte'
import DueDateEditor from '../DueDateEditor.svelte' import DueDateEditor from '../DueDateEditor.svelte'
import RelationEditor from '../RelationEditor.svelte'
export let issue: Issue export let issue: Issue
export let issueStatuses: WithLookup<IssueStatus>[] export let issueStatuses: WithLookup<IssueStatus>[]
const query = createQuery()
let showIsBlocking = false
query.query(tracker.class.Issue, { blockedBy: issue._id }, (result) => {
showIsBlocking = result.length > 0
})
</script> </script>
<div class="content"> <div class="content">
@ -34,6 +42,25 @@
</span> </span>
<StatusEditor value={issue} statuses={issueStatuses} shouldShowLabel /> <StatusEditor value={issue} statuses={issueStatuses} shouldShowLabel />
{#if issue.blockedBy?.length}
<span class="labelTop">
<Label label={tracker.string.BlockedBy} />
</span>
<RelationEditor value={issue} type="blockedBy" />
{/if}
{#if showIsBlocking}
<span class="labelTop">
<Label label={tracker.string.Blocks} />
</span>
<RelationEditor value={issue} type="isBlocking" />
{/if}
{#if issue.relatedIssue?.length}
<span class="labelTop">
<Label label={tracker.string.Related} />
</span>
<RelationEditor value={issue} type="relatedIssue" />
{/if}
<span class="label"> <span class="label">
<Label label={tracker.string.Priority} /> <Label label={tracker.string.Priority} />
</span> </span>

View File

@ -59,6 +59,7 @@ import KanbanView from './components/issues/KanbanView.svelte'
import tracker from './plugin' import tracker from './plugin'
import { copyToClipboard, getIssueId, getIssueTitle, resolveLocation } from './issues' import { copyToClipboard, getIssueId, getIssueTitle, resolveLocation } from './issues'
import CreateIssue from './components/CreateIssue.svelte' import CreateIssue from './components/CreateIssue.svelte'
import RelationsPopup from './components/RelationsPopup.svelte'
export async function queryIssue<D extends Issue> ( export async function queryIssue<D extends Issue> (
_class: Ref<Class<D>>, _class: Ref<Class<D>>,
@ -149,6 +150,7 @@ export default async (): Promise<Resources> => ({
TeamProjects, TeamProjects,
Roadmap, Roadmap,
IssuePreview, IssuePreview,
RelationsPopup,
CreateIssue CreateIssue
}, },
completion: { completion: {

View File

@ -95,3 +95,16 @@ export async function resolveLocation (loc: Location): Promise<Location | undefi
return undefined return undefined
} }
export async function updateIssueRelation (
client: TxOperations,
value: Issue,
id: Ref<Issue>,
prop: 'blockedBy' | 'relatedIssue',
operation: '$push' | '$pull'
): Promise<void> {
const update = Array.isArray(value[prop])
? { [operation]: { [prop]: id } }
: { [prop]: operation === '$push' ? [id] : [] }
await client.update(value, update)
}

View File

@ -140,6 +140,7 @@ export default mergeIds(trackerId, tracker, {
List: '' as IntlString, List: '' as IntlString,
NumberLabels: '' as IntlString, NumberLabels: '' as IntlString,
Roadmap: '' as IntlString, Roadmap: '' as IntlString,
MoveToTeam: '' as IntlString,
IssueTitlePlaceholder: '' as IntlString, IssueTitlePlaceholder: '' as IntlString,
IssueDescriptionPlaceholder: '' as IntlString, IssueDescriptionPlaceholder: '' as IntlString,
@ -168,6 +169,20 @@ export default mergeIds(trackerId, tracker, {
Created: '' as IntlString, Created: '' as IntlString,
Subscribed: '' as IntlString, Subscribed: '' as IntlString,
Relations: '' as IntlString,
RemoveRelation: '' as IntlString,
AddBlockedBy: '' as IntlString,
AddIsBlocking: '' as IntlString,
AddRelatedIssue: '' as IntlString,
RelatedIssue: '' as IntlString,
BlockedIssue: '' as IntlString,
BlockingIssue: '' as IntlString,
BlockedBySearchPlaceholder: '' as IntlString,
IsBlockingSearchPlaceholder: '' as IntlString,
RelatedIssueSearchPlaceholder: '' as IntlString,
Blocks: '' as IntlString,
Related: '' as IntlString,
DurMinutes: '' as IntlString, DurMinutes: '' as IntlString,
DurHours: '' as IntlString, DurHours: '' as IntlString,
DurDays: '' as IntlString, DurDays: '' as IntlString,
@ -215,6 +230,7 @@ export default mergeIds(trackerId, tracker, {
Roadmap: '' as AnyComponent, Roadmap: '' as AnyComponent,
TeamProjects: '' as AnyComponent, TeamProjects: '' as AnyComponent,
IssuePreview: '' as AnyComponent, IssuePreview: '' as AnyComponent,
RelationsPopup: '' as AnyComponent,
CreateIssue: '' as AnyComponent CreateIssue: '' as AnyComponent
}, },
function: { function: {

View File

@ -284,6 +284,8 @@ export default plugin(trackerId, {
SetProject: '' as Ref<Action>, SetProject: '' as Ref<Action>,
CopyIssueId: '' as Ref<Action>, CopyIssueId: '' as Ref<Action>,
CopyIssueTitle: '' as Ref<Action>, CopyIssueTitle: '' as Ref<Action>,
MoveToTeam: '' as Ref<Action>,
Relations: '' as Ref<Action>,
CopyIssueLink: '' as Ref<Action> CopyIssueLink: '' as Ref<Action>
}, },
team: { team: {