mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 03:57:52 +00:00
feat: Ability to rearrange attributes in sidebar (#10784)
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user