feat: Ability to rearrange attributes in sidebar (#10784)

This commit is contained in:
Sivin Varghese
2025-01-30 15:24:02 +05:30
committed by GitHub
parent d997734837
commit 2c75ccb004
3 changed files with 233 additions and 82 deletions

View File

@@ -2,6 +2,7 @@
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
@@ -14,6 +15,8 @@ const props = defineProps({
const { t } = useI18n();
const { uiSettings } = useUISettings();
const searchQuery = ref('');
const contactAttributes = useMapGetter('attributes/getContactAttributes') || [];
@@ -46,20 +49,49 @@ const processContactAttributes = (
}, []);
};
const sortAttributesOrder = computed(
() =>
uiSettings.value.conversation_elements_order_conversation_contact_panel ??
[]
);
const sortByUISettings = attributes => {
// Get saved order from UI settings
// Same as conversation panel contact attribute order
const order = sortAttributesOrder.value;
// If no order defined, return original array
if (!order?.length) return attributes;
const orderMap = new Map(order.map((key, index) => [key, index]));
// Sort attributes based on their position in saved order
return [...attributes].sort((a, b) => {
// Get positions, use Infinity if not found in order (pushes to end)
const aPos = orderMap.get(a.attributeKey) ?? Infinity;
const bPos = orderMap.get(b.attributeKey) ?? Infinity;
return aPos - bPos;
});
};
const usedAttributes = computed(() => {
return processContactAttributes(
const attributes = processContactAttributes(
contactAttributes.value,
props.selectedContact?.customAttributes,
(key, custom) => key in custom
);
return sortByUISettings(attributes);
});
const unusedAttributes = computed(() => {
return processContactAttributes(
const attributes = processContactAttributes(
contactAttributes.value,
props.selectedContact?.customAttributes,
(key, custom) => !(key in custom)
);
return sortByUISettings(attributes);
});
const filteredUnusedAttributes = computed(() => {

View File

@@ -47,63 +47,68 @@ const staticElements = computed(() =>
{
content: initiatedAt,
title: 'CONTACT_PANEL.INITIATED_AT',
key: 'static-initiated-at',
type: 'static_attribute',
},
{
content: browserLanguage,
title: 'CONTACT_PANEL.BROWSER_LANGUAGE',
key: 'static-browser-language',
type: 'static_attribute',
},
{
content: referer,
title: 'CONTACT_PANEL.INITIATED_FROM',
type: 'link',
key: 'static-referer',
type: 'static_attribute',
},
{
content: browserName,
title: 'CONTACT_PANEL.BROWSER',
key: 'static-browser',
type: 'static_attribute',
},
{
content: platformName,
title: 'CONTACT_PANEL.OS',
key: 'static-platform',
type: 'static_attribute',
},
{
content: createdAtIp,
title: 'CONTACT_PANEL.IP_ADDRESS',
key: 'static-ip-address',
type: 'static_attribute',
},
].filter(attribute => !!attribute.content.value)
);
const evenClass = [
'[&>*:nth-child(odd)]:!bg-white [&>*:nth-child(even)]:!bg-slate-25',
'dark:[&>*:nth-child(odd)]:!bg-slate-900 dark:[&>*:nth-child(even)]:!bg-slate-800/50',
];
</script>
<template>
<div class="conversation--details">
<div :class="evenClass">
<ContactDetailsItem
v-for="element in staticElements"
:key="element.title"
:title="$t(element.title)"
:value="element.content.value"
class="border-b border-solid border-slate-50 dark:border-slate-700/50"
>
<a
v-if="element.type === 'link'"
:href="referer"
rel="noopener noreferrer nofollow"
target="_blank"
class="text-woot-400 dark:text-woot-600"
>
{{ referer }}
</a>
</ContactDetailsItem>
</div>
<CustomAttributes
:start-at="staticElements.length % 2 === 0 ? 'even' : 'odd'"
:static-elements="staticElements"
attribute-class="conversation--attribute"
attribute-from="conversation_panel"
attribute-type="conversation_attribute"
/>
>
<template #staticItem="{ element }">
<ContactDetailsItem
:key="element.title"
:title="$t(element.title)"
:value="element.content.value"
>
<a
v-if="element.key === 'static-referer'"
:href="element.content.value"
rel="noopener noreferrer nofollow"
target="_blank"
class="text-n-brand"
>
{{ element.content.value }}
</a>
</ContactDetailsItem>
</template>
</CustomAttributes>
</div>
</template>

View File

@@ -1,5 +1,6 @@
<script setup>
import { computed, onMounted } from 'vue';
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';
@@ -23,10 +24,11 @@ const props = defineProps({
type: String,
default: '',
},
startAt: {
type: String,
default: 'even',
validator: value => value === 'even' || value === 'odd',
// Combine static elements with custom attributes components
// To allow for custom ordering
staticElements: {
type: Array,
default: () => [],
},
});
@@ -36,6 +38,8 @@ 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);
@@ -68,7 +72,7 @@ const toggleButtonText = computed(() =>
: t('CUSTOM_ATTRIBUTES.SHOW_LESS')
);
const filteredAttributes = computed(() =>
const filteredCustomAttributes = computed(() =>
attributes.value.map(attribute => {
// Check if the attribute key exists in customAttributes
const hasValue = Object.hasOwnProperty.call(
@@ -80,6 +84,8 @@ const filteredAttributes = computed(() =>
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]
@@ -88,27 +94,112 @@ const filteredAttributes = computed(() =>
})
);
const displayedAttributes = computed(() => {
// Show only the first 5 attributes or all depending on showAllAttributes
if (showAllAttributes.value || filteredAttributes.value.length <= 5) {
return filteredAttributes.value;
}
return filteredAttributes.value.slice(0, 5);
});
const showMoreUISettingsKey = computed(
() => `show_all_attributes_${props.attributeFrom}`
// 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[showMoreUISettingsKey.value] || false;
uiSettings.value[`show_all_attributes_${props.attributeFrom}`] || false;
};
const onClickToggle = () => {
toggleShowAllAttributes();
updateUISettings({
[showMoreUISettingsKey.value]: showAllAttributes.value,
[`show_all_attributes_${props.attributeFrom}`]: showAllAttributes.value,
});
};
@@ -169,48 +260,65 @@ 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',
];
const oddClass = [
'[&>*:nth-child(odd)]:!bg-n-slate-2 [&>*:nth-child(even)]:!bg-n-background',
'dark:[&>*:nth-child(odd)]:!bg-n-solid-1 dark:[&>*:nth-child(even)]:!bg-n-background',
];
const wrapperClass = computed(() => {
return props.startAt === 'even' ? evenClass : oddClass;
});
</script>
<!-- TODO: After migration to Vue 3, remove the top level div -->
<template>
<div :class="wrapperClass" class="last:rounded-b-lg">
<CustomAttribute
v-for="attribute in displayedAttributes"
:key="attribute.id"
class="last:rounded-b-lg border-b border-n-weak/50 dark:border-n-weak/90"
:attribute-key="attribute.attribute_key"
:attribute-type="attribute.attribute_display_type"
:values="attribute.attribute_values"
:label="attribute.attribute_display_name"
:description="attribute.attribute_description"
:value="attribute.value"
show-actions
:attribute-regex="attribute.regex_pattern"
:regex-cue="attribute.regex_cue"
:contact-id="contactId"
@update="onUpdate"
@delete="onDelete"
@copy="onCopy"
/>
<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 overflow-hidden"
: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="!displayedAttributes.length && emptyStateMessage"
class="p-3 text-center last:rounded-b-lg"
v-if="!displayedElements.length && emptyStateMessage"
class="p-3 text-center"
>
{{ emptyStateMessage }}
</p>
<!-- Show more and show less buttons show it if the filteredAttributes length is greater than 5 -->
<div
v-if="filteredAttributes.length > 5"
class="flex px-2 py-2 last:rounded-b-lg"
>
<!-- 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'"
@@ -224,3 +332,9 @@ const wrapperClass = computed(() => {
</div>
</div>
</template>
<style lang="scss" scoped>
.ghost {
@apply opacity-50 bg-n-slate-3 dark:bg-n-slate-9;
}
</style>