mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 03:57:52 +00:00
feat: Enhance Linear integration UX with multi-issue support and improved placement (#11668)
Fixes https://linear.app/chatwoot/issue/CW-4150/support-for-multiple-issues-linking-in-linear This PR significantly improves the Linear integration user experience by relocating the Linear integration from the conversation header to the contact panel and adding support for multiple issue linking per conversation. ### Key Changes - **Relocated Linear integration**: Moved from conversation header to contact panel for better organization and accessibility - **Multi-issue support**: Added ability to link/create multiple Linear issues for a single conversation - **Integration CTA**: Added a dedicated call-to-action section for users who haven't connected their Linear account yet - **UI/UX improvements**: Enhanced design consistency and user flow <details> <summary>Screenshots</summary> #### Multiple Issues Support  #### Integration CTA  --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com> Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
@@ -11,8 +11,6 @@ import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Linear from './linear/index.vue';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
@@ -36,12 +34,6 @@ const { isAWebWidgetInbox } = useInbox();
|
||||
|
||||
const currentChat = computed(() => store.getters.getSelectedChat);
|
||||
const accountId = computed(() => store.getters.getCurrentAccountId);
|
||||
const isFeatureEnabledonAccount = computed(
|
||||
() => store.getters['accounts/isFeatureEnabledonAccount']
|
||||
);
|
||||
const appIntegrations = computed(
|
||||
() => store.getters['integrations/getAppIntegrations']
|
||||
);
|
||||
|
||||
const chatMetadata = computed(() => props.chat.meta);
|
||||
|
||||
@@ -92,16 +84,6 @@ const hasMultipleInboxes = computed(
|
||||
);
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
const isLinearIntegrationEnabled = computed(() =>
|
||||
appIntegrations.value.find(
|
||||
integration => integration.id === 'linear' && !!integration.hooks.length
|
||||
)
|
||||
);
|
||||
|
||||
const isLinearFeatureEnabled = computed(() =>
|
||||
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.LINEAR)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -162,12 +144,6 @@ const isLinearFeatureEnabled = computed(() =>
|
||||
:parent-width="width"
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
<Linear
|
||||
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
|
||||
:conversation-id="currentChat.id"
|
||||
:parent-width="width"
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
<MoreActions :conversation-id="currentChat.id" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<script setup>
|
||||
import { format } from 'date-fns';
|
||||
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
|
||||
import IssueHeader from './IssueHeader.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
issue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
linkId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['unlinkIssue']);
|
||||
|
||||
const priorityMap = {
|
||||
1: 'Urgent',
|
||||
2: 'High',
|
||||
3: 'Medium',
|
||||
4: 'Low',
|
||||
};
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const { createdAt } = props.issue;
|
||||
return format(new Date(createdAt), 'hh:mm a, MMM dd');
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const assigneeDetails = props.issue.assignee;
|
||||
|
||||
if (!assigneeDetails) return null;
|
||||
const { name, avatarUrl } = assigneeDetails;
|
||||
|
||||
return {
|
||||
name,
|
||||
thumbnail: avatarUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const labels = computed(() => {
|
||||
return props.issue.labels?.nodes || [];
|
||||
});
|
||||
|
||||
const priorityLabel = computed(() => {
|
||||
return priorityMap[props.issue.priority];
|
||||
});
|
||||
|
||||
const unlinkIssue = () => {
|
||||
emit('unlinkIssue', props.linkId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute flex flex-col items-start bg-n-alpha-3 backdrop-blur-[100px] z-50 px-4 py-3 border border-solid border-n-container w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
|
||||
>
|
||||
<div class="flex flex-col w-full">
|
||||
<IssueHeader
|
||||
:identifier="issue.identifier"
|
||||
:link-id="linkId"
|
||||
:issue-url="issue.url"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
|
||||
<span class="mt-2 text-sm font-medium text-n-slate-12">
|
||||
{{ issue.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="issue.description"
|
||||
class="mt-1 text-sm text-n-slate-11 line-clamp-3"
|
||||
>
|
||||
{{ issue.description }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-row items-center h-6 gap-2">
|
||||
<UserAvatarWithName v-if="assignee" :user="assignee" class="py-1" />
|
||||
<div v-if="assignee" class="w-px h-3 bg-n-slate-4" />
|
||||
<div class="flex items-center gap-1 py-1">
|
||||
<fluent-icon
|
||||
icon="status"
|
||||
size="14"
|
||||
:style="{ color: issue.state.color }"
|
||||
/>
|
||||
<h6 class="text-xs text-n-slate-12">
|
||||
{{ issue.state.name }}
|
||||
</h6>
|
||||
</div>
|
||||
<div v-if="priorityLabel" class="w-px h-3 bg-n-slate-4" />
|
||||
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
|
||||
<fluent-icon
|
||||
:icon="`priority-${priorityLabel.toLowerCase()}`"
|
||||
size="14"
|
||||
view-box="0 0 12 12"
|
||||
/>
|
||||
<h6 class="text-xs text-n-slate-12">{{ priorityLabel }}</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
|
||||
<woot-label
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:title="label.name"
|
||||
:description="label.description"
|
||||
:color="label.color"
|
||||
variant="smooth"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{
|
||||
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
|
||||
createdAt: formattedDate,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -15,8 +14,6 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['unlinkIssue']);
|
||||
|
||||
const isUnlinking = inject('isUnlinking');
|
||||
|
||||
const unlinkIssue = () => {
|
||||
emit('unlinkIssue');
|
||||
};
|
||||
@@ -27,36 +24,33 @@ const openIssue = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
class="flex items-center justify-center gap-1 h-[24px] px-2 py-1 border rounded-lg border-ash-200"
|
||||
class="flex items-center gap-2 px-2 py-1.5 border rounded-lg border-n-strong"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="linear"
|
||||
size="19"
|
||||
class="text-[#5E6AD2]"
|
||||
view-box="0 0 19 19"
|
||||
/>
|
||||
<span class="text-xs font-medium text-ash-900">{{ identifier }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<fluent-icon
|
||||
icon="linear"
|
||||
size="16"
|
||||
class="text-[#5E6AD2]"
|
||||
view-box="0 0 19 19"
|
||||
/>
|
||||
<span class="text-xs font-medium text-n-slate-12">
|
||||
{{ identifier }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="w-px h-3 text-n-weak bg-n-weak" />
|
||||
|
||||
<Button
|
||||
ghost
|
||||
link
|
||||
xs
|
||||
slate
|
||||
icon="i-lucide-unlink"
|
||||
class="!transition-none"
|
||||
:is-loading="isUnlinking"
|
||||
@click="unlinkIssue"
|
||||
/>
|
||||
<Button
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="!transition-none"
|
||||
icon="i-lucide-arrow-up-right"
|
||||
class="!size-4"
|
||||
@click="openIssue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button ghost xs slate icon="i-lucide-unlink" @click="unlinkIssue" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch } from 'vue';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
|
||||
import LinearIssueItem from './LinearIssueItem.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const linkedIssues = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const shouldShowCreateModal = ref(false);
|
||||
|
||||
const currentAccountId = getters.getCurrentAccountId;
|
||||
|
||||
const conversation = computed(
|
||||
() => getters.getConversationById.value(props.conversationId) || {}
|
||||
);
|
||||
|
||||
const hasIssues = computed(() => linkedIssues.value.length > 0);
|
||||
|
||||
const loadLinkedIssues = async () => {
|
||||
isLoading.value = true;
|
||||
linkedIssues.value = [];
|
||||
try {
|
||||
const response = await LinearAPI.getLinkedIssue(props.conversationId);
|
||||
linkedIssues.value = response.data || [];
|
||||
} catch (error) {
|
||||
// Silent fail - not critical for UX
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const unlinkIssue = async linkId => {
|
||||
try {
|
||||
await LinearAPI.unlinkIssue(linkId);
|
||||
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
|
||||
linkedIssues.value = linkedIssues.value.filter(
|
||||
issue => issue.id !== linkId
|
||||
);
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
shouldShowCreateModal.value = true;
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
shouldShowCreateModal.value = false;
|
||||
loadLinkedIssues();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
() => {
|
||||
loadLinkedIssues();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadLinkedIssues();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="px-4 pt-3 pb-2">
|
||||
<NextButton
|
||||
ghost
|
||||
xs
|
||||
icon="i-lucide-plus"
|
||||
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')"
|
||||
@click="openCreateModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="flex justify-center p-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!hasIssues" class="flex justify-center p-4">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.NO_LINKED_ISSUES') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[300px] overflow-y-auto">
|
||||
<LinearIssueItem
|
||||
v-for="linkedIssue in linkedIssues"
|
||||
:key="linkedIssue.id"
|
||||
class="pt-3 px-4 pb-4 border-b border-n-weak last:border-b-0"
|
||||
:linked-issue="linkedIssue"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<woot-modal
|
||||
v-model:show="shouldShowCreateModal"
|
||||
:on-close="closeCreateModal"
|
||||
:close-on-backdrop-click="false"
|
||||
class="!items-start [&>div]:!top-12 [&>div]:sticky"
|
||||
>
|
||||
<CreateOrLinkIssue
|
||||
:conversation="conversation"
|
||||
:account-id="currentAccountId"
|
||||
@close="closeCreateModal"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
|
||||
import IssueHeader from './IssueHeader.vue';
|
||||
|
||||
const props = defineProps({
|
||||
linkedIssue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['unlinkIssue']);
|
||||
|
||||
const priorityMap = {
|
||||
1: 'Urgent',
|
||||
2: 'High',
|
||||
3: 'Medium',
|
||||
4: 'Low',
|
||||
};
|
||||
|
||||
const issue = computed(() => props.linkedIssue.issue);
|
||||
|
||||
const assignee = computed(() => {
|
||||
const assigneeDetails = issue.value.assignee;
|
||||
if (!assigneeDetails) return null;
|
||||
return {
|
||||
name: assigneeDetails.name,
|
||||
thumbnail: assigneeDetails.avatarUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const labels = computed(() => issue.value.labels?.nodes || []);
|
||||
|
||||
const priorityLabel = computed(() => priorityMap[issue.value.priority]);
|
||||
|
||||
const unlinkIssue = () => {
|
||||
emit('unlinkIssue', props.linkedIssue.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col w-full">
|
||||
<IssueHeader
|
||||
:identifier="issue.identifier"
|
||||
:link-id="linkedIssue.id"
|
||||
:issue-url="issue.url"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
|
||||
<h3 class="mt-2 text-sm font-medium text-n-slate-12">
|
||||
{{ issue.title }}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
v-if="issue.description"
|
||||
class="mt-1 text-sm text-n-slate-11 line-clamp-3"
|
||||
>
|
||||
{{ issue.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="assignee" class="flex items-center gap-1.5">
|
||||
<Avatar :src="assignee.thumbnail" :name="assignee.name" :size="16" />
|
||||
<span class="text-xs capitalize truncate text-n-slate-12">
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="assignee" class="w-px h-3 bg-n-slate-4" />
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon
|
||||
icon="i-lucide-activity"
|
||||
class="size-4"
|
||||
:style="{ color: issue.state?.color }"
|
||||
/>
|
||||
<span class="text-xs text-n-slate-12">
|
||||
{{ issue.state?.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="priorityLabel" class="w-px h-3 bg-n-slate-4" />
|
||||
|
||||
<div v-if="priorityLabel" class="flex items-center gap-1.5">
|
||||
<CardPriorityIcon :priority="priorityLabel.toLowerCase()" />
|
||||
<span class="text-xs text-n-slate-12">
|
||||
{{ priorityLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="labels.length" class="flex flex-wrap">
|
||||
<woot-label
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:title="label.name"
|
||||
:description="label.description"
|
||||
:color="label.color"
|
||||
variant="smooth"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
|
||||
const { isAdmin } = useAdmin();
|
||||
const getters = useStoreGetters();
|
||||
const accountId = getters.getCurrentAccountId;
|
||||
|
||||
const integrationId = 'linear';
|
||||
|
||||
const actionURL = computed(() =>
|
||||
frontendURL(
|
||||
`accounts/${accountId.value}/settings/integrations/${integrationId}`
|
||||
)
|
||||
);
|
||||
|
||||
const openLinearAccount = () => {
|
||||
window.open(actionURL.value, '_blank');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col p-3">
|
||||
<div class="w-12 h-12 mb-3">
|
||||
<img
|
||||
:src="`/dashboard/images/integrations/${integrationId}.png`"
|
||||
class="object-contain w-full h-full border rounded-md shadow-sm border-n-weak dark:hidden dark:bg-n-alpha-2"
|
||||
/>
|
||||
<img
|
||||
:src="`/dashboard/images/integrations/${integrationId}-dark.png`"
|
||||
class="hidden object-contain w-full h-full border rounded-md shadow-sm border-n-weak dark:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 mb-4">
|
||||
<h3 class="mb-1.5 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.TITLE') }}
|
||||
</h3>
|
||||
<p v-if="isAdmin" class="text-sm text-n-slate-11">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.DESCRIPTION') }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-n-slate-11">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.AGENT_DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NextButton v-if="isAdmin" faded slate @click="openLinearAccount">
|
||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.BUTTON_TEXT') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,161 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch, defineOptions, provide } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
|
||||
import Issue from './Issue.vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
parentWidth: {
|
||||
type: Number,
|
||||
default: 10000,
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
name: 'Linear',
|
||||
});
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const linkedIssue = ref(null);
|
||||
const shouldShow = ref(false);
|
||||
const shouldShowPopup = ref(false);
|
||||
const isUnlinking = ref(false);
|
||||
|
||||
provide('isUnlinking', isUnlinking);
|
||||
|
||||
const currentAccountId = getters.getCurrentAccountId;
|
||||
|
||||
const conversation = computed(() =>
|
||||
getters.getConversationById.value(props.conversationId)
|
||||
);
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
return linkedIssue.value === null
|
||||
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')
|
||||
: null;
|
||||
});
|
||||
|
||||
const loadLinkedIssue = async () => {
|
||||
linkedIssue.value = null;
|
||||
try {
|
||||
const response = await LinearAPI.getLinkedIssue(props.conversationId);
|
||||
const issues = response.data;
|
||||
linkedIssue.value = issues && issues.length ? issues[0] : null;
|
||||
} catch (error) {
|
||||
// We don't want to show an error message here, as it's not critical. When someone clicks on the Linear icon, we can inform them that the integration is disabled.
|
||||
}
|
||||
};
|
||||
|
||||
const unlinkIssue = async linkId => {
|
||||
try {
|
||||
isUnlinking.value = true;
|
||||
await LinearAPI.unlinkIssue(linkId);
|
||||
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
|
||||
linkedIssue.value = null;
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
isUnlinking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowIssueIdentifier = computed(() => {
|
||||
if (!linkedIssue.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return props.parentWidth > 600;
|
||||
});
|
||||
|
||||
const openIssue = () => {
|
||||
if (!linkedIssue.value) shouldShowPopup.value = true;
|
||||
shouldShow.value = true;
|
||||
};
|
||||
|
||||
const closePopup = () => {
|
||||
shouldShowPopup.value = false;
|
||||
loadLinkedIssue();
|
||||
};
|
||||
|
||||
const closeIssue = () => {
|
||||
shouldShow.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
() => {
|
||||
loadLinkedIssue();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
loadLinkedIssue();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative after:content-[''] after:h-5 after:bg-transparent after:top-5 after:w-full after:block after:absolute after:z-0"
|
||||
:class="{ group: linkedIssue }"
|
||||
>
|
||||
<Button
|
||||
v-on-clickaway="closeIssue"
|
||||
v-tooltip="tooltipText"
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
class="!gap-1 group-hover:bg-n-alpha-2"
|
||||
@click="openIssue"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="linear"
|
||||
size="19"
|
||||
class="text-[#5E6AD2] flex-shrink-0"
|
||||
view-box="0 0 19 19"
|
||||
/>
|
||||
<span
|
||||
v-if="shouldShowIssueIdentifier"
|
||||
class="text-xs font-medium text-n-slate-11"
|
||||
>
|
||||
{{ linkedIssue.issue.identifier }}
|
||||
</span>
|
||||
</Button>
|
||||
<Issue
|
||||
v-if="linkedIssue"
|
||||
:issue="linkedIssue.issue"
|
||||
:link-id="linkedIssue.id"
|
||||
class="absolute start-0 xl:start-auto xl:end-0 top-9 invisible group-hover:visible"
|
||||
@unlink-issue="unlinkIssue"
|
||||
/>
|
||||
<woot-modal
|
||||
v-model:show="shouldShowPopup"
|
||||
:on-close="closePopup"
|
||||
:close-on-backdrop-click="false"
|
||||
class="!items-start [&>div]:!top-12 [&>div]:sticky"
|
||||
>
|
||||
<CreateOrLinkIssue
|
||||
:conversation="conversation"
|
||||
:account-id="currentAccountId"
|
||||
@close="closePopup"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user