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: {