Merge branch 'develop' into feat/email-forwarding

This commit is contained in:
Sivin Varghese
2025-06-12 15:46:58 +05:30
committed by GitHub
736 changed files with 10702 additions and 1919 deletions
@@ -22,6 +22,7 @@ import {
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import ChatListHeader from './ChatListHeader.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
@@ -82,6 +83,7 @@ const emit = defineEmits(['conversationLoad']);
const { uiSettings } = useUISettings();
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const store = useStore();
const conversationListRef = ref(null);
@@ -107,6 +109,7 @@ const advancedFilterTypes = ref(
attributeName: t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
}))
);
const isInitialLoad = ref(false);
const currentUser = useMapGetter('getCurrentUser');
const chatLists = useMapGetter('getFilteredConversations');
@@ -374,6 +377,7 @@ function setFiltersFromUISettings() {
function emitConversationLoaded() {
emit('conversationLoad');
isInitialLoad.value = false;
// [VITE] removing this since the library has changed
// nextTick(() => {
// // Addressing a known issue in the virtual list library where dynamically added items
@@ -418,6 +422,7 @@ function onApplyFilter(payload) {
foldersQuery.value = filterQueryGenerator(payload);
store.dispatch('conversationPage/reset');
store.dispatch('emptyAllConversations');
isInitialLoad.value = true;
fetchFilteredConversations(payload);
}
@@ -572,6 +577,7 @@ function resetAndFetchData() {
store.dispatch('conversationPage/reset');
store.dispatch('emptyAllConversations');
store.dispatch('clearConversationFilters');
isInitialLoad.value = true;
if (hasActiveFolders.value) {
const payload = activeFolder.value.query;
fetchSavedFilteredConversations(payload);
@@ -646,6 +652,30 @@ function openLastItemAfterDeleteInFolder() {
}
}
function redirectToConversationList() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = route;
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
}
async function assignPriority(priority, conversationId = null) {
store.dispatch('setCurrentChatPriority', {
priority,
@@ -670,26 +700,7 @@ async function markAsUnread(conversationId) {
await store.dispatch('markMessagesUnread', {
id: conversationId,
});
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = useRoute();
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
redirectToConversationList();
} catch (error) {
// Ignore error
}
@@ -703,6 +714,7 @@ async function markAsRead(conversationId) {
// Ignore error
}
}
async function onAssignTeam(team, conversationId = null) {
try {
await store.dispatch('assignTeam', {
@@ -764,6 +776,26 @@ onMounted(() => {
}
});
const deleteConversationDialogRef = ref(null);
const selectedConversationId = ref(null);
async function deleteConversation() {
try {
await store.dispatch('deleteConversation', selectedConversationId.value);
redirectToConversationList();
selectedConversationId.value = null;
deleteConversationDialogRef.value.close();
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
} catch (error) {
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
}
}
const handleDelete = conversationId => {
selectedConversationId.value = conversationId;
deleteConversationDialogRef.value.open();
};
provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
@@ -775,6 +807,7 @@ provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority);
provide('isConversationSelected', isConversationSelected);
provide('deleteConversation', handleDelete);
watch(activeTeam, () => resetAndFetchData());
@@ -824,6 +857,8 @@ watch(conversationFilters, (newVal, oldVal) => {
:has-active-folders="hasActiveFolders"
:active-status="activeStatus"
:is-on-expanded-layout="isOnExpandedLayout"
:conversation-stats="conversationStats"
:is-list-loading="isInitialLoad"
@add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal"
@@ -938,6 +973,19 @@ watch(conversationFilters, (newVal, oldVal) => {
</template>
</DynamicScroller>
</div>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
:title="
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
conversationId: selectedConversationId,
})
"
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
@confirm="deleteConversation"
@close="selectedConversationId = null"
/>
<TeleportWithDirection
v-if="showAdvancedFilters"
to="#conversationFilterTeleportTarget"
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store.js';
import { formatNumber } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -10,26 +11,13 @@ import SwitchLayout from 'dashboard/routes/dashboard/conversation/search/SwitchL
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
pageTitle: {
type: String,
required: true,
},
hasAppliedFilters: {
type: Boolean,
required: true,
},
hasActiveFolders: {
type: Boolean,
required: true,
},
activeStatus: {
type: String,
required: true,
},
isOnExpandedLayout: {
type: Boolean,
required: true,
},
pageTitle: { type: String, required: true },
hasAppliedFilters: { type: Boolean, required: true },
hasActiveFolders: { type: Boolean, required: true },
activeStatus: { type: String, required: true },
isOnExpandedLayout: { type: Boolean, required: true },
conversationStats: { type: Object, required: true },
isListLoading: { type: Boolean, required: true },
});
const emit = defineEmits([
@@ -62,6 +50,9 @@ const showV4View = computed(() => {
);
});
const allCount = computed(() => props.conversationStats?.allCount || 0);
const formattedAllCount = computed(() => formatNumber(allCount.value));
const toggleConversationLayout = () => {
const { LAYOUT_TYPES } = wootConstants;
const {
@@ -92,6 +83,15 @@ const toggleConversationLayout = () => {
>
{{ pageTitle }}
</h1>
<span
v-if="
allCount > 0 && hasAppliedFiltersOrActiveFolders && !isListLoading
"
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
:title="allCount"
>
{{ formattedAllCount }}
</span>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
@@ -16,6 +16,7 @@ export default {
'markAsRead',
'assignPriority',
'isConversationSelected',
'deleteConversation',
],
props: {
source: {
@@ -67,5 +68,6 @@ export default {
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/>
</template>
@@ -78,6 +78,7 @@ export default {
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
],
data() {
return {
@@ -237,6 +238,10 @@ export default {
this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu();
},
async deleteConversation() {
this.$emit('deleteConversation', this.chat.id);
this.closeContextMenu();
},
},
};
</script>
@@ -363,6 +368,7 @@ export default {
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/>
</ContextMenu>
</div>
@@ -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>
@@ -8,6 +8,7 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
export default {
components: {
@@ -45,7 +46,14 @@ export default {
'assignAgent',
'assignTeam',
'assignLabel',
'deleteConversation',
],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
STATUS_TYPE: wootConstants.STATUS_TYPE,
@@ -121,6 +129,11 @@ export default {
icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
},
deleteOption: {
key: 'delete',
icon: 'delete',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
},
};
},
computed: {
@@ -178,6 +191,9 @@ export default {
assignPriority(priority) {
this.$emit('assignPriority', priority);
},
deleteConversation() {
this.$emit('deleteConversation', this.chatId);
},
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
@@ -277,5 +293,13 @@ export default {
@click.stop="$emit('assignTeam', team)"
/>
</MenuItemWithSubmenu>
<template v-if="isAdmin">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItem
:option="deleteOption"
variant="icon"
@click.stop="deleteConversation"
/>
</template>
</div>
</template>
@@ -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>