feat: Ability to rearrange attributes in sidebar (#10784)
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
|
||||||
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
|
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ const props = defineProps({
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const { uiSettings } = useUISettings();
|
||||||
|
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
|
|
||||||
const contactAttributes = useMapGetter('attributes/getContactAttributes') || [];
|
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(() => {
|
const usedAttributes = computed(() => {
|
||||||
return processContactAttributes(
|
const attributes = processContactAttributes(
|
||||||
contactAttributes.value,
|
contactAttributes.value,
|
||||||
props.selectedContact?.customAttributes,
|
props.selectedContact?.customAttributes,
|
||||||
(key, custom) => key in custom
|
(key, custom) => key in custom
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return sortByUISettings(attributes);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unusedAttributes = computed(() => {
|
const unusedAttributes = computed(() => {
|
||||||
return processContactAttributes(
|
const attributes = processContactAttributes(
|
||||||
contactAttributes.value,
|
contactAttributes.value,
|
||||||
props.selectedContact?.customAttributes,
|
props.selectedContact?.customAttributes,
|
||||||
(key, custom) => !(key in custom)
|
(key, custom) => !(key in custom)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return sortByUISettings(attributes);
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredUnusedAttributes = computed(() => {
|
const filteredUnusedAttributes = computed(() => {
|
||||||
|
|||||||
@@ -47,63 +47,68 @@ const staticElements = computed(() =>
|
|||||||
{
|
{
|
||||||
content: initiatedAt,
|
content: initiatedAt,
|
||||||
title: 'CONTACT_PANEL.INITIATED_AT',
|
title: 'CONTACT_PANEL.INITIATED_AT',
|
||||||
|
key: 'static-initiated-at',
|
||||||
|
type: 'static_attribute',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
content: browserLanguage,
|
content: browserLanguage,
|
||||||
title: 'CONTACT_PANEL.BROWSER_LANGUAGE',
|
title: 'CONTACT_PANEL.BROWSER_LANGUAGE',
|
||||||
|
key: 'static-browser-language',
|
||||||
|
type: 'static_attribute',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
content: referer,
|
content: referer,
|
||||||
title: 'CONTACT_PANEL.INITIATED_FROM',
|
title: 'CONTACT_PANEL.INITIATED_FROM',
|
||||||
type: 'link',
|
key: 'static-referer',
|
||||||
|
type: 'static_attribute',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
content: browserName,
|
content: browserName,
|
||||||
title: 'CONTACT_PANEL.BROWSER',
|
title: 'CONTACT_PANEL.BROWSER',
|
||||||
|
key: 'static-browser',
|
||||||
|
type: 'static_attribute',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
content: platformName,
|
content: platformName,
|
||||||
title: 'CONTACT_PANEL.OS',
|
title: 'CONTACT_PANEL.OS',
|
||||||
|
key: 'static-platform',
|
||||||
|
type: 'static_attribute',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
content: createdAtIp,
|
content: createdAtIp,
|
||||||
title: 'CONTACT_PANEL.IP_ADDRESS',
|
title: 'CONTACT_PANEL.IP_ADDRESS',
|
||||||
|
key: 'static-ip-address',
|
||||||
|
type: 'static_attribute',
|
||||||
},
|
},
|
||||||
].filter(attribute => !!attribute.content.value)
|
].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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="conversation--details">
|
<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
|
<CustomAttributes
|
||||||
:start-at="staticElements.length % 2 === 0 ? 'even' : 'odd'"
|
:static-elements="staticElements"
|
||||||
attribute-class="conversation--attribute"
|
attribute-class="conversation--attribute"
|
||||||
attribute-from="conversation_panel"
|
attribute-from="conversation_panel"
|
||||||
attribute-type="conversation_attribute"
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import Draggable from 'vuedraggable';
|
||||||
import { useToggle } from '@vueuse/core';
|
import { useToggle } from '@vueuse/core';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||||
@@ -23,10 +24,11 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
startAt: {
|
// Combine static elements with custom attributes components
|
||||||
type: String,
|
// To allow for custom ordering
|
||||||
default: 'even',
|
staticElements: {
|
||||||
validator: value => value === 'even' || value === 'odd',
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,6 +38,8 @@ const route = useRoute();
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { uiSettings, updateUISettings } = useUISettings();
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
|
||||||
|
const dragging = ref(false);
|
||||||
|
|
||||||
const [showAllAttributes, toggleShowAllAttributes] = useToggle(false);
|
const [showAllAttributes, toggleShowAllAttributes] = useToggle(false);
|
||||||
|
|
||||||
const currentChat = computed(() => getters.getSelectedChat.value);
|
const currentChat = computed(() => getters.getSelectedChat.value);
|
||||||
@@ -68,7 +72,7 @@ const toggleButtonText = computed(() =>
|
|||||||
: t('CUSTOM_ATTRIBUTES.SHOW_LESS')
|
: t('CUSTOM_ATTRIBUTES.SHOW_LESS')
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredAttributes = computed(() =>
|
const filteredCustomAttributes = computed(() =>
|
||||||
attributes.value.map(attribute => {
|
attributes.value.map(attribute => {
|
||||||
// Check if the attribute key exists in customAttributes
|
// Check if the attribute key exists in customAttributes
|
||||||
const hasValue = Object.hasOwnProperty.call(
|
const hasValue = Object.hasOwnProperty.call(
|
||||||
@@ -80,6 +84,8 @@ const filteredAttributes = computed(() =>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...attribute,
|
...attribute,
|
||||||
|
type: 'custom_attribute',
|
||||||
|
key: attribute.attribute_key,
|
||||||
// Set value from customAttributes if it exists, otherwise use default value
|
// Set value from customAttributes if it exists, otherwise use default value
|
||||||
value: hasValue
|
value: hasValue
|
||||||
? customAttributes.value[attribute.attribute_key]
|
? customAttributes.value[attribute.attribute_key]
|
||||||
@@ -88,27 +94,112 @@ const filteredAttributes = computed(() =>
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const displayedAttributes = computed(() => {
|
// Order key name for UI settings
|
||||||
// Show only the first 5 attributes or all depending on showAllAttributes
|
const orderKey = computed(
|
||||||
if (showAllAttributes.value || filteredAttributes.value.length <= 5) {
|
() => `conversation_elements_order_${props.attributeFrom}`
|
||||||
return filteredAttributes.value;
|
|
||||||
}
|
|
||||||
return filteredAttributes.value.slice(0, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
const showMoreUISettingsKey = computed(
|
|
||||||
() => `show_all_attributes_${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 initializeSettings = () => {
|
||||||
|
const currentOrder = uiSettings.value[orderKey.value];
|
||||||
|
if (!currentOrder) {
|
||||||
|
const initialOrder = combinedElements.value.map(element => element.key);
|
||||||
|
updateUISettings({
|
||||||
|
[orderKey.value]: initialOrder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
showAllAttributes.value =
|
showAllAttributes.value =
|
||||||
uiSettings.value[showMoreUISettingsKey.value] || false;
|
uiSettings.value[`show_all_attributes_${props.attributeFrom}`] || false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickToggle = () => {
|
const onClickToggle = () => {
|
||||||
toggleShowAllAttributes();
|
toggleShowAllAttributes();
|
||||||
updateUISettings({
|
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',
|
'[&>*: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',
|
'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>
|
</script>
|
||||||
|
|
||||||
<!-- TODO: After migration to Vue 3, remove the top level div -->
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="wrapperClass" class="last:rounded-b-lg">
|
<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
|
<CustomAttribute
|
||||||
v-for="attribute in displayedAttributes"
|
:key="element.id"
|
||||||
:key="attribute.id"
|
:attribute-key="element.attribute_key"
|
||||||
class="last:rounded-b-lg border-b border-n-weak/50 dark:border-n-weak/90"
|
:attribute-type="element.attribute_display_type"
|
||||||
:attribute-key="attribute.attribute_key"
|
:values="element.attribute_values"
|
||||||
:attribute-type="attribute.attribute_display_type"
|
:label="element.attribute_display_name"
|
||||||
:values="attribute.attribute_values"
|
:description="element.attribute_description"
|
||||||
:label="attribute.attribute_display_name"
|
:value="element.value"
|
||||||
:description="attribute.attribute_description"
|
|
||||||
:value="attribute.value"
|
|
||||||
show-actions
|
show-actions
|
||||||
:attribute-regex="attribute.regex_pattern"
|
:attribute-regex="element.regex_pattern"
|
||||||
:regex-cue="attribute.regex_cue"
|
:regex-cue="element.regex_cue"
|
||||||
:contact-id="contactId"
|
:contact-id="contactId"
|
||||||
@update="onUpdate"
|
@update="onUpdate"
|
||||||
@delete="onDelete"
|
@delete="onDelete"
|
||||||
@copy="onCopy"
|
@copy="onCopy"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="!displayedAttributes.length && emptyStateMessage"
|
v-if="!displayedElements.length && emptyStateMessage"
|
||||||
class="p-3 text-center last:rounded-b-lg"
|
class="p-3 text-center"
|
||||||
>
|
>
|
||||||
{{ emptyStateMessage }}
|
{{ emptyStateMessage }}
|
||||||
</p>
|
</p>
|
||||||
<!-- Show more and show less buttons show it if the filteredAttributes length is greater than 5 -->
|
<!-- Show more and show less buttons show it if the combinedElements length is greater than 5 -->
|
||||||
<div
|
<div v-if="combinedElements.length > 5" class="flex px-2 py-2">
|
||||||
v-if="filteredAttributes.length > 5"
|
|
||||||
class="flex px-2 py-2 last:rounded-b-lg"
|
|
||||||
>
|
|
||||||
<woot-button
|
<woot-button
|
||||||
size="small"
|
size="small"
|
||||||
:icon="showAllAttributes ? 'chevron-up' : 'chevron-down'"
|
:icon="showAllAttributes ? 'chevron-up' : 'chevron-down'"
|
||||||
@@ -224,3 +332,9 @@ const wrapperClass = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.ghost {
|
||||||
|
@apply opacity-50 bg-n-slate-3 dark:bg-n-slate-9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user