iachat/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue
Muhsin Keloth 4303007786
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


![link-multiple-issues](https://github.com/user-attachments/assets/b56cfa7d-6f98-42db-b4bb-361ae59d0eae)

  #### Integration CTA


![link-multiple-issues](https://github.com/user-attachments/assets/a895fcbe-780a-47f8-9fa4-3a2af8b243e1)

---------

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>
2025-06-10 15:40:02 -04:00

325 lines
10 KiB
Vue

<script setup>
import { computed, watch, onMounted, ref } from 'vue';
import {
useMapGetter,
useFunctionGetter,
useStore,
} from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue';
import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue';
import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import Draggable from 'vuedraggable';
import MacrosList from './Macros/List.vue';
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
import LinearIssuesList from 'dashboard/components/widgets/conversation/linear/IssuesList.vue';
import LinearSetupCTA from 'dashboard/components/widgets/conversation/linear/LinearSetupCTA.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
inboxId: {
type: Number,
default: undefined,
},
});
const {
updateUISettings,
isContactSidebarItemOpen,
conversationSidebarItemsOrder,
toggleSidebarUIState,
} = useUISettings();
const dragging = ref(false);
const conversationSidebarItems = ref([]);
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const shopifyIntegration = useFunctionGetter(
'integrations/getIntegration',
'shopify'
);
const isShopifyFeatureEnabled = computed(
() => shopifyIntegration.value.enabled
);
const linearIntegration = useFunctionGetter(
'integrations/getIntegration',
'linear'
);
const isLinearIntegrationEnabled = computed(
() => linearIntegration.value?.enabled || false
);
const isLinearFeatureEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.LINEAR
);
const store = useStore();
const currentChat = useMapGetter('getSelectedChat');
const conversationId = computed(() => props.conversationId);
const conversationMetadataGetter = useMapGetter(
'conversationMetadata/getConversationMetadata'
);
const currentConversationMetaData = computed(() =>
conversationMetadataGetter.value(conversationId.value)
);
const conversationAdditionalAttributes = computed(
() => currentConversationMetaData.value.additional_attributes || {}
);
const channelType = computed(() => currentChat.value.meta?.channel);
const contactGetter = useMapGetter('contacts/getContact');
const contactId = computed(() => currentChat.value.meta?.sender?.id);
const contact = computed(() => contactGetter.value(contactId.value));
const contactAdditionalAttributes = computed(
() => contact.value.additional_attributes || {}
);
const getContactDetails = () => {
if (contactId.value) {
store.dispatch('contacts/show', { id: contactId.value });
}
};
watch(conversationId, (newConversationId, prevConversationId) => {
if (newConversationId && newConversationId !== prevConversationId) {
getContactDetails();
}
});
watch(contactId, getContactDetails);
const onDragEnd = () => {
dragging.value = false;
updateUISettings({
conversation_sidebar_items_order: conversationSidebarItems.value,
});
};
const closeContactPanel = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
};
onMounted(() => {
conversationSidebarItems.value = conversationSidebarItemsOrder.value;
getContactDetails();
store.dispatch('attributes/get', 0);
// Load integrations to ensure linear integration state is available
store.dispatch('integrations/get', 'linear');
});
</script>
<template>
<div class="w-full">
<SidebarActionsHeader
:title="$t('CONVERSATION.SIDEBAR.CONTACT')"
@close="closeContactPanel"
/>
<ContactInfo :contact="contact" :channel-type="channelType" />
<div class="pb-8 list-group px-2">
<Draggable
:list="conversationSidebarItems"
animation="200"
ghost-class="ghost"
handle=".drag-handle"
item-key="name"
class="flex flex-col gap-3"
@start="dragging = true"
@end="onDragEnd"
>
<template #item="{ element }">
<div
v-if="element.name === 'conversation_actions'"
class="conversation--actions"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_ACTIONS')"
:is-open="isContactSidebarItemOpen('is_conv_actions_open')"
@toggle="
value => toggleSidebarUIState('is_conv_actions_open', value)
"
>
<ConversationAction
:conversation-id="conversationId"
:inbox-id="inboxId"
/>
</AccordionItem>
</div>
<div
v-else-if="element.name === 'conversation_participants'"
class="conversation--actions"
>
<AccordionItem
:title="$t('CONVERSATION_PARTICIPANTS.SIDEBAR_TITLE')"
:is-open="isContactSidebarItemOpen('is_conv_participants_open')"
@toggle="
value =>
toggleSidebarUIState('is_conv_participants_open', value)
"
>
<ConversationParticipant
:conversation-id="conversationId"
:inbox-id="inboxId"
/>
</AccordionItem>
</div>
<div v-else-if="element.name === 'conversation_info'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_INFO')"
:is-open="isContactSidebarItemOpen('is_conv_details_open')"
compact
@toggle="
value => toggleSidebarUIState('is_conv_details_open', value)
"
>
<ConversationInfo
:conversation-attributes="conversationAdditionalAttributes"
:contact-attributes="contactAdditionalAttributes"
/>
</AccordionItem>
</div>
<div v-else-if="element.name === 'contact_attributes'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_ATTRIBUTES')"
:is-open="isContactSidebarItemOpen('is_contact_attributes_open')"
compact
@toggle="
value =>
toggleSidebarUIState('is_contact_attributes_open', value)
"
>
<CustomAttributes
attribute-type="contact_attribute"
attribute-from="conversation_contact_panel"
:contact-id="contact.id"
:empty-state-message="
$t('CONVERSATION_CUSTOM_ATTRIBUTES.NO_RECORDS_FOUND')
"
/>
</AccordionItem>
</div>
<div v-else-if="element.name === 'previous_conversation'">
<AccordionItem
v-if="contact.id"
:title="
$t('CONVERSATION_SIDEBAR.ACCORDION.PREVIOUS_CONVERSATION')
"
:is-open="isContactSidebarItemOpen('is_previous_conv_open')"
compact
@toggle="
value => toggleSidebarUIState('is_previous_conv_open', value)
"
>
<ContactConversations
:contact-id="contact.id"
:conversation-id="conversationId"
/>
</AccordionItem>
</div>
<woot-feature-toggle
v-else-if="element.name === 'macros'"
feature-key="macros"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.MACROS')"
:is-open="isContactSidebarItemOpen('is_macro_open')"
compact
@toggle="value => toggleSidebarUIState('is_macro_open', value)"
>
<MacrosList :conversation-id="conversationId" />
</AccordionItem>
</woot-feature-toggle>
<div
v-else-if="
element.name === 'linear_issues' && isLinearFeatureEnabled
"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.LINEAR_ISSUES')"
:is-open="isContactSidebarItemOpen('is_linear_issues_open')"
compact
@toggle="
value => toggleSidebarUIState('is_linear_issues_open', value)
"
>
<LinearSetupCTA v-if="!isLinearIntegrationEnabled" />
<LinearIssuesList v-else :conversation-id="conversationId" />
</AccordionItem>
</div>
<div
v-else-if="
element.name === 'shopify_orders' && isShopifyFeatureEnabled
"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.SHOPIFY_ORDERS')"
:is-open="isContactSidebarItemOpen('is_shopify_orders_open')"
compact
@toggle="
value => toggleSidebarUIState('is_shopify_orders_open', value)
"
>
<ShopifyOrdersList :contact-id="contactId" />
</AccordionItem>
</div>
<div v-else-if="element.name === 'contact_notes'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_NOTES')"
:is-open="isContactSidebarItemOpen('is_contact_notes_open')"
compact
@toggle="
value => toggleSidebarUIState('is_contact_notes_open', value)
"
>
<ContactNotes :contact-id="contactId" />
</AccordionItem>
</div>
</template>
</Draggable>
</div>
</div>
</template>
<style lang="scss" scoped>
::v-deep {
.contact--profile {
@apply pb-3 border-b border-solid border-slate-75 dark:border-slate-700;
}
.conversation--actions .multiselect-wrap--small {
.multiselect {
@apply box-border pl-6;
}
.multiselect__element {
span {
@apply w-full;
}
}
}
}
</style>