feat: show attribute modal based on resolve action
This commit is contained in:
+33
-65
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import TextArea from 'next/textarea/TextArea.vue';
|
||||
@@ -7,77 +7,18 @@ import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ChoiceToggle from 'dashboard/components-next/input/ChoiceToggle.vue';
|
||||
|
||||
const emit = defineEmits(['submit']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const attributes = [
|
||||
{
|
||||
id: 1,
|
||||
attribute_display_name: 'Severity',
|
||||
attribute_display_type: 'text',
|
||||
attribute_key: 'severity',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
attribute_display_name: 'Cloud customer',
|
||||
attribute_display_type: 'checkbox',
|
||||
attribute_key: 'cloud_customer',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
attribute_display_name: 'Plan',
|
||||
attribute_display_type: 'list',
|
||||
attribute_key: 'plan',
|
||||
attribute_values: ['gold', 'silver', 'bronze'],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
attribute_display_name: 'Signup date',
|
||||
attribute_display_type: 'date',
|
||||
attribute_key: 'signup_date',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
attribute_display_name: 'Home page',
|
||||
attribute_display_type: 'link',
|
||||
attribute_key: 'home_page',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
attribute_display_name: 'Reg number',
|
||||
attribute_display_type: 'number',
|
||||
attribute_key: 'reg_number',
|
||||
},
|
||||
].map(attribute => ({
|
||||
...attribute,
|
||||
value: attribute.attribute_key,
|
||||
label: attribute.attribute_display_name,
|
||||
type: attribute.attribute_display_type,
|
||||
}));
|
||||
|
||||
const visibleAttributes = ref([]);
|
||||
const formValues = ref({});
|
||||
|
||||
const resetForm = () => {
|
||||
formValues.value = attributes.reduce((acc, attribute) => {
|
||||
acc[attribute.value] = '';
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
resetForm();
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
close();
|
||||
};
|
||||
|
||||
const getPlaceholder = type => {
|
||||
const placeholders = {
|
||||
text: t(
|
||||
@@ -100,6 +41,28 @@ const getPlaceholder = type => {
|
||||
return placeholders[type] || '';
|
||||
};
|
||||
|
||||
const isFormComplete = computed(() =>
|
||||
visibleAttributes.value.every(attribute => {
|
||||
const value = formValues.value?.[attribute.value];
|
||||
return value !== undefined && value !== null && String(value).trim() !== '';
|
||||
})
|
||||
);
|
||||
|
||||
const open = (attributes = [], initialValues = {}) => {
|
||||
visibleAttributes.value = attributes;
|
||||
formValues.value = attributes.reduce((acc, attribute) => {
|
||||
const presetValue = initialValues[attribute.value];
|
||||
acc[attribute.value] =
|
||||
presetValue !== undefined && presetValue !== null ? presetValue : '';
|
||||
return acc;
|
||||
}, {});
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('submit', { ...formValues.value });
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
@@ -117,11 +80,12 @@ defineExpose({ open, close });
|
||||
:cancel-button-label="
|
||||
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.CANCEL')
|
||||
"
|
||||
:show-confirm-button="isFormComplete"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="attribute in attributes"
|
||||
v-for="attribute in visibleAttributes"
|
||||
:key="attribute.value"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
@@ -170,7 +134,11 @@ defineExpose({ open, close });
|
||||
<ComboBox
|
||||
v-model="formValues[attribute.value]"
|
||||
:options="
|
||||
(attribute.attribute_values || []).map(option => ({
|
||||
(
|
||||
attribute.attribute_values ||
|
||||
attribute.attributeValues ||
|
||||
[]
|
||||
).map(option => ({
|
||||
value: option,
|
||||
label: option,
|
||||
}))
|
||||
|
||||
@@ -3,9 +3,14 @@ import { ref, computed } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import {
|
||||
useStore,
|
||||
useStoreGetters,
|
||||
useMapGetter,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
@@ -17,13 +22,19 @@ import {
|
||||
|
||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const { currentAccount } = useAccount();
|
||||
const conversationAttributes = useMapGetter(
|
||||
'attributes/getConversationAttributes'
|
||||
);
|
||||
|
||||
const arrowDownButtonRef = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
const closeDropdown = () => toggleDropdown(false);
|
||||
@@ -52,31 +63,103 @@ const showOpenButton = computed(() => {
|
||||
return isPending.value || isSnoozed.value;
|
||||
});
|
||||
|
||||
const getConversationParams = () => {
|
||||
const allConversations = document.querySelectorAll(
|
||||
'.conversations-list .conversation'
|
||||
);
|
||||
|
||||
const activeConversation = document.querySelector(
|
||||
'div.conversations-list div.conversation.active'
|
||||
);
|
||||
const activeConversationIndex = [...allConversations].indexOf(
|
||||
activeConversation
|
||||
);
|
||||
const lastConversationIndex = allConversations.length - 1;
|
||||
|
||||
return {
|
||||
all: allConversations,
|
||||
activeIndex: activeConversationIndex,
|
||||
lastIndex: lastConversationIndex,
|
||||
};
|
||||
};
|
||||
|
||||
const openSnoozeModal = () => {
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open({ parent: 'snooze_conversation' });
|
||||
};
|
||||
|
||||
const requiredAttributeKeys = computed(
|
||||
() => currentAccount.value?.settings?.conversation_required_attributes || []
|
||||
);
|
||||
|
||||
const requiredAttributes = computed(() =>
|
||||
requiredAttributeKeys.value.map(key => {
|
||||
const definition = conversationAttributes.value?.find(
|
||||
attribute => attribute.attributeKey === key
|
||||
);
|
||||
if (!definition) {
|
||||
return {
|
||||
value: key,
|
||||
label: key,
|
||||
type: 'text',
|
||||
attribute_values: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...definition,
|
||||
value: definition.attributeKey,
|
||||
label: definition.attributeDisplayName,
|
||||
type: definition.attributeDisplayType,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const getMissingRequiredAttributes = () => {
|
||||
if (!currentChat.value?.id) return [];
|
||||
const existingValues = currentChat.value.custom_attributes || {};
|
||||
return requiredAttributes.value.filter(attribute => {
|
||||
const value = existingValues?.[attribute.value];
|
||||
return value === undefined || value === null || String(value) === '';
|
||||
});
|
||||
};
|
||||
|
||||
const toggleStatusInternal = async (status, snoozedUntil = null) => {
|
||||
closeDropdown();
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await store.dispatch('toggleStatus', {
|
||||
conversationId: currentChat.value.id,
|
||||
status,
|
||||
snoozedUntil,
|
||||
});
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveWithAttributes = async customAttributes => {
|
||||
if (!currentChat.value?.id) return;
|
||||
try {
|
||||
isLoading.value = true;
|
||||
if (requiredAttributeKeys.value.length) {
|
||||
await store.dispatch('updateCustomAttributes', {
|
||||
conversationId: currentChat.value.id,
|
||||
customAttributes,
|
||||
});
|
||||
}
|
||||
await toggleStatusInternal(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.ERROR'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openResolveModal = () => {
|
||||
closeDropdown();
|
||||
if (!currentChat.value?.id) return;
|
||||
const missing = getMissingRequiredAttributes();
|
||||
if (!missing.length) {
|
||||
resolveWithAttributes(currentChat.value.custom_attributes || {});
|
||||
return;
|
||||
}
|
||||
resolveAttributesModalRef.value?.open(
|
||||
missing,
|
||||
currentChat.value.custom_attributes || {}
|
||||
);
|
||||
};
|
||||
|
||||
const handleModalSubmit = async values => {
|
||||
if (!currentChat.value?.id) return;
|
||||
resolveAttributesModalRef.value?.close();
|
||||
await resolveWithAttributes({
|
||||
...(currentChat.value.custom_attributes || {}),
|
||||
...values,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleStatus = (status, snoozedUntil) => {
|
||||
closeDropdown();
|
||||
isLoading.value = true;
|
||||
@@ -97,7 +180,7 @@ const onCmdOpenConversation = () => {
|
||||
};
|
||||
|
||||
const onCmdResolveConversation = () => {
|
||||
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
openResolveModal();
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
@@ -106,21 +189,11 @@ const keyboardEvents = {
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyE': {
|
||||
action: async () => {
|
||||
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
},
|
||||
action: () => openResolveModal(),
|
||||
},
|
||||
'$mod+Alt+KeyE': {
|
||||
action: async event => {
|
||||
const { all, activeIndex, lastIndex } = getConversationParams();
|
||||
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
|
||||
if (activeIndex < lastIndex) {
|
||||
all[activeIndex + 1].click();
|
||||
} else if (all.length > 1) {
|
||||
all[0].click();
|
||||
document.querySelector('.conversations-list').scrollTop = 0;
|
||||
}
|
||||
action: event => {
|
||||
openResolveModal();
|
||||
event.preventDefault();
|
||||
},
|
||||
},
|
||||
@@ -133,9 +206,13 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex items-center justify-end resolve-actions">
|
||||
<div class="flex relative justify-end items-center resolve-actions">
|
||||
<ConversationResolveAttributesModal
|
||||
ref="resolveAttributesModalRef"
|
||||
@submit="handleModalSubmit"
|
||||
/>
|
||||
<ButtonGroup
|
||||
class="rounded-lg shadow outline-1 outline flex-shrink-0"
|
||||
class="flex-shrink-0 rounded-lg shadow outline-1 outline"
|
||||
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
import { ref, unref } from 'vue';
|
||||
import { computed, ref, unref } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
export function useBulkActions() {
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { currentAccount } = useAccount();
|
||||
|
||||
const selectedConversations = useMapGetter(
|
||||
'bulkActions/getSelectedConversationIds'
|
||||
);
|
||||
const allConversations = useMapGetter('getAllConversations');
|
||||
const selectedInboxes = ref([]);
|
||||
|
||||
const requiredAttributeKeys = computed(
|
||||
() => currentAccount.value?.settings?.conversation_required_attributes || []
|
||||
);
|
||||
|
||||
const conversationsById = computed(() =>
|
||||
(allConversations.value || []).reduce((acc, conversation) => {
|
||||
acc[conversation.id] = conversation;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const hasAllRequiredValues = conversation => {
|
||||
if (!conversation) return false;
|
||||
const attrs = conversation.custom_attributes || {};
|
||||
return requiredAttributeKeys.value.every(key => {
|
||||
const value = attrs?.[key];
|
||||
return (
|
||||
value !== undefined && value !== null && String(value).trim() !== ''
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
function selectConversation(conversationId, inboxId) {
|
||||
store.dispatch('bulkActions/setSelectedConversationIds', conversationId);
|
||||
selectedInboxes.value = [...selectedInboxes.value, inboxId];
|
||||
@@ -116,17 +141,58 @@ export function useBulkActions() {
|
||||
}
|
||||
|
||||
async function onUpdateConversations(status, snoozedUntil) {
|
||||
const selectedIds = selectedConversations.value;
|
||||
const requiresCheck =
|
||||
status === 'resolved' && requiredAttributeKeys.value.length > 0;
|
||||
|
||||
const resolveCandidates = requiresCheck
|
||||
? selectedIds.reduce(
|
||||
(result, id) => {
|
||||
const conversation = conversationsById.value[id];
|
||||
if (hasAllRequiredValues(conversation)) {
|
||||
result.resolvable.push(id);
|
||||
} else {
|
||||
result.blocked.push(id);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{ resolvable: [], blocked: [] }
|
||||
)
|
||||
: { resolvable: selectedIds, blocked: [] };
|
||||
|
||||
if (!resolveCandidates.resolvable.length) {
|
||||
if (requiresCheck) {
|
||||
useAlert(t('BULK_ACTION.UPDATE.REQUIRED_ATTRIBUTES_MISSING'));
|
||||
} else {
|
||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: selectedConversations.value,
|
||||
ids: resolveCandidates.resolvable,
|
||||
fields: {
|
||||
status,
|
||||
},
|
||||
snoozed_until: snoozedUntil,
|
||||
});
|
||||
|
||||
// Update selection to keep only blocked conversations, if any.
|
||||
store.dispatch('bulkActions/clearSelectedConversationIds');
|
||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
|
||||
selectedInboxes.value = [];
|
||||
if (resolveCandidates.blocked.length) {
|
||||
store.dispatch('bulkActions/setSelectedConversationIds', [
|
||||
...resolveCandidates.blocked,
|
||||
]);
|
||||
selectedInboxes.value = resolveCandidates.blocked
|
||||
.map(id => conversationsById.value[id]?.inbox_id)
|
||||
.filter(Boolean);
|
||||
useAlert(t('BULK_ACTION.UPDATE.REQUIRED_ATTRIBUTES_MISSING'));
|
||||
} else {
|
||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
|
||||
}
|
||||
} catch (err) {
|
||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"CHANGE_STATUS": "Change status",
|
||||
"SNOOZE_UNTIL": "Snooze",
|
||||
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
|
||||
"UPDATE_FAILED": "Failed to update conversations. Please try again."
|
||||
"UPDATE_FAILED": "Failed to update conversations. Please try again.",
|
||||
"REQUIRED_ATTRIBUTES_MISSING": "Some conversations need required attributes before resolving and were skipped."
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
|
||||
Reference in New Issue
Block a user