From 25afe12cc20c0476ca0cc9f4cf1d1ed5b29e5874 Mon Sep 17 00:00:00 2001 From: Alex <41288429+Dvinyanin@users.noreply.github.com> Date: Fri, 1 Jul 2022 13:14:31 +0700 Subject: [PATCH] Tracker: Add relation (#2174) Signed-off-by: Dvinyanin Alexandr <dvinyanin.alexandr@gmail.com> --- changelog.md | 1 + models/tracker/src/index.ts | 29 +++++- packages/ui/package.json | 1 + packages/ui/src/components/Menu.svelte | 10 ++- plugins/tracker-assets/lang/en.json | 15 ++++ plugins/tracker-assets/lang/ru.json | 15 ++++ .../src/components/RelationsPopup.svelte | 90 +++++++++++++++++++ .../src/components/SelectIssuePopup.svelte | 49 ++++++++++ .../src/components/SelectRelationPopup.svelte | 70 +++++++++++++++ .../components/issues/RelationEditor.svelte | 83 +++++++++++++++++ .../issues/edit/ControlPanel.svelte | 27 ++++++ plugins/tracker-resources/src/index.ts | 2 + plugins/tracker-resources/src/issues.ts | 13 +++ plugins/tracker-resources/src/plugin.ts | 16 ++++ plugins/tracker/src/index.ts | 2 + 15 files changed, 416 insertions(+), 7 deletions(-) create mode 100644 plugins/tracker-resources/src/components/RelationsPopup.svelte create mode 100644 plugins/tracker-resources/src/components/SelectIssuePopup.svelte create mode 100644 plugins/tracker-resources/src/components/SelectRelationPopup.svelte create mode 100644 plugins/tracker-resources/src/components/issues/RelationEditor.svelte diff --git a/changelog.md b/changelog.md index 665748398f..748487f1be 100644 --- a/changelog.md +++ b/changelog.md @@ -15,6 +15,7 @@ HR: Tracker: - Manual issues ordering +- Issue relations Workbench - Use application aliases in URL diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index 7dca28a859..4b796185da 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -853,7 +853,7 @@ export function createModel (builder: Builder): void { builder, { action: view.actionImpl.Move, - label: view.string.Move, + label: tracker.string.MoveToTeam, icon: view.icon.Move, keyBinding: [], input: 'none', @@ -862,9 +862,32 @@ export function createModel (builder: Builder): void { context: { mode: ['context', 'browser'], 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 ) } diff --git a/packages/ui/package.json b/packages/ui/package.json index 366314db8e..4471c94d71 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -34,6 +34,7 @@ "dependencies": { "@anticrm/platform": "~0.6.6", "@anticrm/theme": "~0.6.0", + "@anticrm/core": "~0.6.16", "svelte": "^3.47", "@types/jest": "~28.1.0" } diff --git a/packages/ui/src/components/Menu.svelte b/packages/ui/src/components/Menu.svelte index 63ba26ee4b..888fac62b5 100644 --- a/packages/ui/src/components/Menu.svelte +++ b/packages/ui/src/components/Menu.svelte @@ -14,6 +14,7 @@ --> <script lang="ts"> import { afterUpdate, createEventDispatcher, onDestroy, onMount } from 'svelte' + import { generateId } from '@anticrm/core' import ui from '../plugin' import { closePopup, showPopup } from '../popups' import { Action } from '../types' @@ -27,6 +28,7 @@ const dispatch = createEventDispatcher() const btns: HTMLElement[] = [] let activeElement: HTMLElement + const category = generateId() const keyDown = (ev: KeyboardEvent): void => { if (ev.key === 'Tab') { @@ -51,7 +53,7 @@ } if (ev.key === 'ArrowLeft') { dispatch('update', 'left') - closePopup('submenu') + closePopup(category) ev.preventDefault() ev.stopPropagation() } @@ -72,11 +74,11 @@ } }) onDestroy(() => { - closePopup('submenu') + closePopup(category) }) function showActionPopup (action: Action, target: HTMLElement): void { - closePopup('submenu') + closePopup(category) if (action.component !== undefined) { console.log(action.props) showPopup( @@ -87,7 +89,7 @@ dispatch('close') }, undefined, - { category: 'submenu', overlay: false } + { category, overlay: false } ) } } diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index ea6d8067e1..88556696d2 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -123,6 +123,7 @@ "ProjectLeadSearchPlaceholder": "Set project lead\u2026", "ProjectMembersSearchPlaceholder": "Change project members\u2026", "Roadmap": "Roadmap", + "MoveToTeam": "Move to team", "GotoIssues": "Go to issues", "GotoActive": "Go to active issues", @@ -141,6 +142,20 @@ "Created": "Created", "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}", "Save": "Save", diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index 4d91cdaaea..658adc3906 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -123,6 +123,7 @@ "ProjectLeadSearchPlaceholder": "Назначьте руководителя проекта\u2026", "ProjectMembersSearchPlaceholder": "Измененить участников проекта\u2026", "Roadmap": "Планирование", + "MoveToTeam": "Изменить команду", "GotoIssues": "Перейти к задачам", "GotoActive": "Перейти к активным задачам", @@ -141,6 +142,20 @@ "Created": "Созданные", "Subscribed": "Отслеживаемые", + "Relations": "Зависимости", + "RemoveRelation": "Удалить зависимость...", + "AddBlockedBy": "Отметить как блокируемую...", + "AddIsBlocking": "Отметить как блокирующую...", + "AddRelatedIssue": "Связать с задачей...", + "RelatedIssue": "Связанная задача {id} - {title}", + "BlockedIssue": "Блокируемая задача {id} - {title}", + "BlockingIssue": "Блокирующая {id} - {title}", + "BlockedBySearchPlaceholder": "Поиск блокирующей задачи...", + "IsBlockingSearchPlaceholder": "Поиск блокируемой задачи...", + "RelatedIssueSearchPlaceholder": "Поиск связанной задачи...", + "Blocks": "Блокирует", + "Related": "Связан", + "EditIssue": "Редактирование {title}", "Save": "Сохранить", diff --git a/plugins/tracker-resources/src/components/RelationsPopup.svelte b/plugins/tracker-resources/src/components/RelationsPopup.svelte new file mode 100644 index 0000000000..2639d1833f --- /dev/null +++ b/plugins/tracker-resources/src/components/RelationsPopup.svelte @@ -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} /> diff --git a/plugins/tracker-resources/src/components/SelectIssuePopup.svelte b/plugins/tracker-resources/src/components/SelectIssuePopup.svelte new file mode 100644 index 0000000000..bdbfd396a9 --- /dev/null +++ b/plugins/tracker-resources/src/components/SelectIssuePopup.svelte @@ -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> diff --git a/plugins/tracker-resources/src/components/SelectRelationPopup.svelte b/plugins/tracker-resources/src/components/SelectRelationPopup.svelte new file mode 100644 index 0000000000..c2ce98c9e8 --- /dev/null +++ b/plugins/tracker-resources/src/components/SelectRelationPopup.svelte @@ -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} diff --git a/plugins/tracker-resources/src/components/issues/RelationEditor.svelte b/plugins/tracker-resources/src/components/issues/RelationEditor.svelte new file mode 100644 index 0000000000..531ba84ccc --- /dev/null +++ b/plugins/tracker-resources/src/components/issues/RelationEditor.svelte @@ -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> diff --git a/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte b/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte index 6ff36cac79..65b714e4fa 100644 --- a/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte +++ b/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte @@ -15,6 +15,7 @@ <script lang="ts"> import { WithLookup } from '@anticrm/core' import type { Issue, IssueStatus } from '@anticrm/tracker' + import { createQuery } from '@anticrm/presentation' import { Component, Label } from '@anticrm/ui' import tags from '@anticrm/tags' import tracker from '../../../plugin' @@ -23,9 +24,16 @@ import ProjectEditor from '../../projects/ProjectEditor.svelte' import AssigneeEditor from '../AssigneeEditor.svelte' import DueDateEditor from '../DueDateEditor.svelte' + import RelationEditor from '../RelationEditor.svelte' export let issue: Issue 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> <div class="content"> @@ -34,6 +42,25 @@ </span> <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"> <Label label={tracker.string.Priority} /> </span> diff --git a/plugins/tracker-resources/src/index.ts b/plugins/tracker-resources/src/index.ts index 97d6f7fc51..914fd32b75 100644 --- a/plugins/tracker-resources/src/index.ts +++ b/plugins/tracker-resources/src/index.ts @@ -59,6 +59,7 @@ import KanbanView from './components/issues/KanbanView.svelte' import tracker from './plugin' import { copyToClipboard, getIssueId, getIssueTitle, resolveLocation } from './issues' import CreateIssue from './components/CreateIssue.svelte' +import RelationsPopup from './components/RelationsPopup.svelte' export async function queryIssue<D extends Issue> ( _class: Ref<Class<D>>, @@ -149,6 +150,7 @@ export default async (): Promise<Resources> => ({ TeamProjects, Roadmap, IssuePreview, + RelationsPopup, CreateIssue }, completion: { diff --git a/plugins/tracker-resources/src/issues.ts b/plugins/tracker-resources/src/issues.ts index e5761a23ff..82588863dd 100644 --- a/plugins/tracker-resources/src/issues.ts +++ b/plugins/tracker-resources/src/issues.ts @@ -95,3 +95,16 @@ export async function resolveLocation (loc: Location): Promise<Location | undefi 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) +} diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index 508ac29f2e..d76f965a07 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -140,6 +140,7 @@ export default mergeIds(trackerId, tracker, { List: '' as IntlString, NumberLabels: '' as IntlString, Roadmap: '' as IntlString, + MoveToTeam: '' as IntlString, IssueTitlePlaceholder: '' as IntlString, IssueDescriptionPlaceholder: '' as IntlString, @@ -168,6 +169,20 @@ export default mergeIds(trackerId, tracker, { Created: '' 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, DurHours: '' as IntlString, DurDays: '' as IntlString, @@ -215,6 +230,7 @@ export default mergeIds(trackerId, tracker, { Roadmap: '' as AnyComponent, TeamProjects: '' as AnyComponent, IssuePreview: '' as AnyComponent, + RelationsPopup: '' as AnyComponent, CreateIssue: '' as AnyComponent }, function: { diff --git a/plugins/tracker/src/index.ts b/plugins/tracker/src/index.ts index de2afbc6c3..743dd595f9 100644 --- a/plugins/tracker/src/index.ts +++ b/plugins/tracker/src/index.ts @@ -284,6 +284,8 @@ export default plugin(trackerId, { SetProject: '' as Ref<Action>, CopyIssueId: '' as Ref<Action>, CopyIssueTitle: '' as Ref<Action>, + MoveToTeam: '' as Ref<Action>, + Relations: '' as Ref<Action>, CopyIssueLink: '' as Ref<Action> }, team: {