iachat/app/javascript/dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue
Sivin Varghese 3dc7045340
fix: Dropdown for custom attributes in conversation sidebar hides under the list (#11099)
# Pull Request Template

## Description

This PR fixes the issue where the custom attributes type list dropdown
in the conversation sidebar gets hidden under the section.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Screenshots
**Before**
<img width="310" alt="image"
src="https://github.com/user-attachments/assets/1aec7f08-8ca8-4868-914a-d545eab34dce"
/>


**After**
<img width="310" alt="image"
src="https://github.com/user-attachments/assets/eb9006f3-0bc1-4008-ac0d-1feeeadc139d"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-03-17 20:51:56 -07:00

341 lines
10 KiB
Vue

<script setup>
import { computed, onMounted, ref } from 'vue';
import Draggable from 'vuedraggable';
import { useToggle } from '@vueuse/core';
import { useRoute } from 'vue-router';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import CustomAttribute from 'dashboard/components/CustomAttribute.vue';
const props = defineProps({
attributeType: {
type: String,
default: 'conversation_attribute',
},
contactId: { type: Number, default: null },
attributeFrom: {
type: String,
required: true,
},
emptyStateMessage: {
type: String,
default: '',
},
// Combine static elements with custom attributes components
// To allow for custom ordering
staticElements: {
type: Array,
default: () => [],
},
});
const store = useStore();
const getters = useStoreGetters();
const route = useRoute();
const { t } = useI18n();
const { uiSettings, updateUISettings } = useUISettings();
const dragging = ref(false);
const [showAllAttributes, toggleShowAllAttributes] = useToggle(false);
const currentChat = computed(() => getters.getSelectedChat.value);
const attributes = computed(() =>
getters['attributes/getAttributesByModel'].value(props.attributeType)
);
const contactIdentifier = computed(
() =>
currentChat.value.meta?.sender?.id ||
route.params.contactId ||
props.contactId
);
const contact = computed(() =>
getters['contacts/getContact'].value(contactIdentifier.value)
);
const customAttributes = computed(() => {
if (props.attributeType === 'conversation_attribute')
return currentChat.value.custom_attributes || {};
return contact.value.custom_attributes || {};
});
const conversationId = computed(() => currentChat.value.id);
const toggleButtonText = computed(() =>
!showAllAttributes.value
? t('CUSTOM_ATTRIBUTES.SHOW_MORE')
: t('CUSTOM_ATTRIBUTES.SHOW_LESS')
);
const filteredCustomAttributes = computed(() =>
attributes.value.map(attribute => {
// Check if the attribute key exists in customAttributes
const hasValue = Object.hasOwnProperty.call(
customAttributes.value,
attribute.attribute_key
);
const isCheckbox = attribute.attribute_display_type === 'checkbox';
const defaultValue = isCheckbox ? false : '';
return {
...attribute,
type: 'custom_attribute',
key: attribute.attribute_key,
// Set value from customAttributes if it exists, otherwise use default value
value: hasValue
? customAttributes.value[attribute.attribute_key]
: defaultValue,
};
})
);
// Order key name for UI settings
const orderKey = computed(
() => `conversation_elements_order_${props.attributeFrom}`
);
const combinedElements = computed(() => {
// Get saved order from UI settings
const savedOrder = uiSettings.value[orderKey.value] ?? [];
const allElements = [
...props.staticElements,
...filteredCustomAttributes.value,
];
// If no saved order exists, return in default order
if (!savedOrder.length) return allElements;
return allElements.sort((a, b) => {
// Find positions of elements in saved order
const aPosition = savedOrder.indexOf(a.key);
const bPosition = savedOrder.indexOf(b.key);
// Handle cases where elements are not in saved order:
// - New elements (not in saved order) go to the end
// - If both elements are new, maintain their relative order
if (aPosition === -1 && bPosition === -1) return 0;
if (aPosition === -1) return 1;
if (bPosition === -1) return -1;
return aPosition - bPosition;
});
});
const displayedElements = computed(() => {
if (showAllAttributes.value || combinedElements.value.length <= 5) {
return combinedElements.value;
}
// Show first 5 elements in the order they appear
return combinedElements.value.slice(0, 5);
});
// Reorder elements with static elements position preserved
// There is case where all the static elements will not be available (API, Email channels, etc).
// In that case, we need to preserve the order of the static elements and
// insert them in the correct position.
const reorderElementsWithStaticPreservation = (
savedOrder = [],
currentOrder = []
) => {
const finalOrder = [...currentOrder];
const visibleKeys = new Set(currentOrder);
// Process hidden static elements from saved order
savedOrder
// Find static elements that aren't currently visible
.filter(key => key.startsWith('static-') && !visibleKeys.has(key))
.forEach(staticKey => {
// Find next visible element after this static element in saved order
const nextVisible = savedOrder
.slice(savedOrder.indexOf(staticKey))
.find(key => visibleKeys.has(key));
// If next visible element found, insert before it; otherwise add to end
if (nextVisible) {
finalOrder.splice(finalOrder.indexOf(nextVisible), 0, staticKey);
} else {
finalOrder.push(staticKey);
}
});
return finalOrder;
};
const onDragEnd = () => {
dragging.value = false;
// Get the saved and current saved order
const savedOrder = uiSettings.value[orderKey.value] ?? [];
const currentOrder = combinedElements.value.map(({ key }) => key);
const finalOrder = reorderElementsWithStaticPreservation(
savedOrder,
currentOrder
);
updateUISettings({
[orderKey.value]: finalOrder,
});
};
const initializeSettings = () => {
const currentOrder = uiSettings.value[orderKey.value];
if (!currentOrder) {
const initialOrder = combinedElements.value.map(element => element.key);
updateUISettings({
[orderKey.value]: initialOrder,
});
}
showAllAttributes.value =
uiSettings.value[`show_all_attributes_${props.attributeFrom}`] || false;
};
const onClickToggle = () => {
toggleShowAllAttributes();
updateUISettings({
[`show_all_attributes_${props.attributeFrom}`]: showAllAttributes.value,
});
};
const onUpdate = async (key, value) => {
const updatedAttributes = { ...customAttributes.value, [key]: value };
try {
if (props.attributeType === 'conversation_attribute') {
await store.dispatch('updateCustomAttributes', {
conversationId: conversationId.value,
customAttributes: updatedAttributes,
});
} else {
store.dispatch('contacts/update', {
id: props.contactId,
custom_attributes: updatedAttributes,
});
}
useAlert(t('CUSTOM_ATTRIBUTES.FORM.UPDATE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message || t('CUSTOM_ATTRIBUTES.FORM.UPDATE.ERROR');
useAlert(errorMessage);
}
};
const onDelete = async key => {
try {
const { [key]: remove, ...updatedAttributes } = customAttributes.value;
if (props.attributeType === 'conversation_attribute') {
await store.dispatch('updateCustomAttributes', {
conversationId: conversationId.value,
customAttributes: updatedAttributes,
});
} else {
store.dispatch('contacts/deleteCustomAttributes', {
id: props.contactId,
customAttributes: [key],
});
}
useAlert(t('CUSTOM_ATTRIBUTES.FORM.DELETE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message || t('CUSTOM_ATTRIBUTES.FORM.DELETE.ERROR');
useAlert(errorMessage);
}
};
const onCopy = async attributeValue => {
await copyTextToClipboard(attributeValue);
useAlert(t('CUSTOM_ATTRIBUTES.COPY_SUCCESSFUL'));
};
onMounted(() => {
initializeSettings();
});
const evenClass = [
'[&>*:nth-child(odd)]:!bg-n-background [&>*:nth-child(even)]:!bg-n-slate-2',
'dark:[&>*:nth-child(odd)]:!bg-n-background dark:[&>*:nth-child(even)]:!bg-n-solid-1',
];
</script>
<template>
<div class="conversation--details">
<Draggable
:list="displayedElements"
:disabled="!showAllAttributes"
animation="200"
ghost-class="ghost"
handle=".drag-handle"
item-key="key"
class="last:rounded-b-lg"
:class="evenClass"
@start="dragging = true"
@end="onDragEnd"
>
<template #item="{ element }">
<div
class="drag-handle relative border-b border-n-weak/50 dark:border-n-weak/90"
:class="{
'cursor-grab': showAllAttributes,
'last:border-transparent dark:last:border-transparent':
combinedElements.length <= 5,
}"
>
<template v-if="element.type === 'static_attribute'">
<slot name="staticItem" :element="element" />
</template>
<template v-else>
<CustomAttribute
:key="element.id"
:attribute-key="element.attribute_key"
:attribute-type="element.attribute_display_type"
:values="element.attribute_values"
:label="element.attribute_display_name"
:description="element.attribute_description"
:value="element.value"
show-actions
:attribute-regex="element.regex_pattern"
:regex-cue="element.regex_cue"
:contact-id="contactId"
@update="onUpdate"
@delete="onDelete"
@copy="onCopy"
/>
</template>
</div>
</template>
</Draggable>
<p
v-if="!displayedElements.length && emptyStateMessage"
class="p-3 text-center"
>
{{ emptyStateMessage }}
</p>
<!-- Show more and show less buttons show it if the combinedElements length is greater than 5 -->
<div v-if="combinedElements.length > 5" class="flex px-2 py-2">
<woot-button
size="small"
:icon="showAllAttributes ? 'chevron-up' : 'chevron-down'"
variant="clear"
color-scheme="primary"
class="!px-2 hover:!bg-transparent dark:hover:!bg-transparent"
@click="onClickToggle"
>
{{ toggleButtonText }}
</woot-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.ghost {
@apply opacity-50 bg-n-slate-3 dark:bg-n-slate-9;
}
</style>