feat: Custom attribute page redesign (#13087)
Fixes https://linear.app/chatwoot/issue/CW-6096/custom-attributes-page-redesign <img width="2390" height="1822" alt="524664368-b92a6eb7-7f6c-40e6-bf23-6a5310f2d9c5" src="https://github.com/user-attachments/assets/a29726e7-3d28-4811-8429-6483056d57cb" /> --------- Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
attribute: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
badges: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['edit', 'delete']);
|
||||||
|
|
||||||
|
const iconByType = {
|
||||||
|
text: 'i-lucide-align-justify',
|
||||||
|
checkbox: 'i-lucide-circle-check-big',
|
||||||
|
list: 'i-lucide-list',
|
||||||
|
date: 'i-lucide-calendar',
|
||||||
|
link: 'i-lucide-link',
|
||||||
|
number: 'i-lucide-hash',
|
||||||
|
};
|
||||||
|
|
||||||
|
const attributeIcon = computed(() => {
|
||||||
|
const typeKey = props.attribute.type?.toLowerCase();
|
||||||
|
return iconByType[typeKey] || 'i-lucide-align-justify';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2 justify-between items-center">
|
||||||
|
<div class="flex flex-wrap gap-2 items-center min-w-0">
|
||||||
|
<h4 class="text-sm font-medium truncate text-n-slate-12">
|
||||||
|
{{ attribute.label }}
|
||||||
|
</h4>
|
||||||
|
<div class="w-px h-3 bg-n-strong" />
|
||||||
|
<div class="flex gap-2 items-center text-sm text-n-slate-11">
|
||||||
|
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||||
|
<Icon :icon="attributeIcon" class="size-4" />
|
||||||
|
<span class="text-sm">{{ attribute.type }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-px h-3 bg-n-weak" />
|
||||||
|
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||||
|
<Icon icon="i-lucide-key-round" class="size-4" />
|
||||||
|
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<AttributeBadge
|
||||||
|
v-for="badge in badges"
|
||||||
|
:key="badge.type"
|
||||||
|
:type="badge.type"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="badges.length > 0"
|
||||||
|
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-pencil-line"
|
||||||
|
size="sm"
|
||||||
|
color="slate"
|
||||||
|
ghost
|
||||||
|
@click="emit('edit', attribute)"
|
||||||
|
/>
|
||||||
|
<div class="w-px h-3 bg-n-strong" />
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
size="sm"
|
||||||
|
color="slate"
|
||||||
|
ghost
|
||||||
|
@click="emit('delete', attribute)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mb-0 text-sm text-n-slate-11">
|
||||||
|
{{ attribute.attribute_description || attribute.description || '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'resolution',
|
||||||
|
validator: value => ['pre-chat', 'resolution'].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const attributeConfig = {
|
||||||
|
'pre-chat': {
|
||||||
|
colorClass: 'text-n-blue-11',
|
||||||
|
icon: 'i-lucide-message-circle',
|
||||||
|
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
|
||||||
|
},
|
||||||
|
resolution: {
|
||||||
|
colorClass: 'text-n-teal-11',
|
||||||
|
icon: 'i-lucide-circle-check-big',
|
||||||
|
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const config = computed(
|
||||||
|
() => attributeConfig[props.type] || attributeConfig.resolution
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
|
||||||
|
>
|
||||||
|
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
|
||||||
|
<span class="text-xs" :class="config.colorClass">{{
|
||||||
|
t(config.labelKey)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -129,6 +129,10 @@
|
|||||||
"ENABLE_REGEX": {
|
"ENABLE_REGEX": {
|
||||||
"LABEL": "Enable regex validation"
|
"LABEL": "Enable regex validation"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"BADGES": {
|
||||||
|
"PRE_CHAT": "Pre-chat",
|
||||||
|
"RESOLUTION": "Resolution"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,49 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||||
import AddAttribute from './AddAttribute.vue';
|
import AddAttribute from './AddAttribute.vue';
|
||||||
import CustomAttribute from './CustomAttribute.vue';
|
import EditAttribute from './EditAttribute.vue';
|
||||||
import SettingsLayout from '../SettingsLayout.vue';
|
import SettingsLayout from '../SettingsLayout.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||||
|
import AttributeListItem from 'dashboard/components-next/ConversationWorkflow/AttributeListItem.vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useStoreGetters, useStore } from 'dashboard/composables/store';
|
import {
|
||||||
|
useStoreGetters,
|
||||||
|
useStore,
|
||||||
|
useMapGetter,
|
||||||
|
} from 'dashboard/composables/store';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const getters = useStoreGetters();
|
const getters = useStoreGetters();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
const { currentAccount } = useAccount();
|
||||||
|
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||||
|
|
||||||
const showAddPopup = ref(false);
|
const [showAddPopup, toggleAddPopup] = useToggle(false);
|
||||||
const selectedTabIndex = ref(0);
|
const selectedTabIndex = ref(0);
|
||||||
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
|
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
|
||||||
|
const [showEditPopup, toggleEditPopup] = useToggle(false);
|
||||||
|
const [showDeletePopup, toggleDeletePopup] = useToggle(false);
|
||||||
|
const selectedAttribute = ref({});
|
||||||
|
|
||||||
const openAddPopup = () => {
|
const openAddPopup = () => {
|
||||||
showAddPopup.value = true;
|
toggleAddPopup(true);
|
||||||
};
|
};
|
||||||
const hideAddPopup = () => {
|
const hideAddPopup = () => {
|
||||||
showAddPopup.value = false;
|
toggleAddPopup(false);
|
||||||
|
};
|
||||||
|
const hideEditPopup = () => {
|
||||||
|
toggleEditPopup(false);
|
||||||
|
selectedAttribute.value = {};
|
||||||
|
};
|
||||||
|
const closeDelete = () => {
|
||||||
|
toggleDeletePopup(false);
|
||||||
|
selectedAttribute.value = {};
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
@@ -37,6 +59,10 @@ const tabs = computed(() => {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tabsForTabBar = computed(() =>
|
||||||
|
tabs.value.map(tab => ({ label: tab.name, key: tab.key }))
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.dispatch('attributes/get');
|
store.dispatch('attributes/get');
|
||||||
});
|
});
|
||||||
@@ -49,9 +75,75 @@ const attributes = computed(() =>
|
|||||||
getters['attributes/getAttributesByModel'].value(attributeModel.value)
|
getters['attributes/getAttributesByModel'].value(attributeModel.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
const onClickTabChange = index => {
|
const onClickTabChange = tab => {
|
||||||
selectedTabIndex.value = index;
|
selectedTabIndex.value = tab.key;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditAttribute = attribute => {
|
||||||
|
selectedAttribute.value = attribute;
|
||||||
|
toggleEditPopup(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAttribute = attribute => {
|
||||||
|
selectedAttribute.value = attribute;
|
||||||
|
toggleDeletePopup(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteAttribute = async () => {
|
||||||
|
try {
|
||||||
|
await store.dispatch('attributes/delete', selectedAttribute.value.id);
|
||||||
|
useAlert(t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
|
||||||
|
closeDelete();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error?.response?.message || t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE');
|
||||||
|
useAlert(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requiredAttributeKeys = computed(
|
||||||
|
() => currentAccount.value?.settings?.conversation_required_attributes || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasPreChatBadge = attribute => {
|
||||||
|
return (inboxes.value || []).some(inbox => {
|
||||||
|
const fields =
|
||||||
|
inbox?.pre_chat_form_options?.pre_chat_fields ||
|
||||||
|
inbox?.channel?.pre_chat_form_options?.pre_chat_fields ||
|
||||||
|
[];
|
||||||
|
return fields.some(field => field.name === attribute.attribute_key);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildBadges = attribute => {
|
||||||
|
const badges = [];
|
||||||
|
if (hasPreChatBadge(attribute)) {
|
||||||
|
badges.push({
|
||||||
|
type: 'pre-chat',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
attribute.attribute_model === 'conversation_attribute' &&
|
||||||
|
requiredAttributeKeys.value.includes(attribute.attribute_key)
|
||||||
|
) {
|
||||||
|
badges.push({
|
||||||
|
type: 'resolution',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return badges;
|
||||||
|
};
|
||||||
|
|
||||||
|
const derivedAttributes = computed(() =>
|
||||||
|
attributes.value.map(attribute => ({
|
||||||
|
...attribute,
|
||||||
|
label: attribute.attribute_display_name,
|
||||||
|
type: attribute.attribute_display_type,
|
||||||
|
value: attribute.attribute_key,
|
||||||
|
badges: buildBadges(attribute),
|
||||||
|
}))
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -77,27 +169,25 @@ const onClickTabChange = index => {
|
|||||||
</template>
|
</template>
|
||||||
</BaseSettingsHeader>
|
</BaseSettingsHeader>
|
||||||
</template>
|
</template>
|
||||||
<template #preBody>
|
|
||||||
<woot-tabs
|
|
||||||
class="font-medium [&_ul]:p-0 mb-4"
|
|
||||||
:index="selectedTabIndex"
|
|
||||||
@change="onClickTabChange"
|
|
||||||
>
|
|
||||||
<woot-tabs-item
|
|
||||||
v-for="(tab, index) in tabs"
|
|
||||||
:key="tab.key"
|
|
||||||
:index="index"
|
|
||||||
:name="tab.name"
|
|
||||||
:show-badge="false"
|
|
||||||
is-compact
|
|
||||||
/>
|
|
||||||
</woot-tabs>
|
|
||||||
</template>
|
|
||||||
<template #body>
|
<template #body>
|
||||||
<CustomAttribute
|
<div class="flex flex-col gap-6">
|
||||||
:key="attributeModel"
|
<TabBar
|
||||||
:attribute-model="attributeModel"
|
:tabs="tabsForTabBar"
|
||||||
/>
|
:initial-active-tab="selectedTabIndex"
|
||||||
|
class="max-w-xl"
|
||||||
|
@tab-changed="onClickTabChange"
|
||||||
|
/>
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<AttributeListItem
|
||||||
|
v-for="attribute in derivedAttributes"
|
||||||
|
:key="attribute.id"
|
||||||
|
:attribute="attribute"
|
||||||
|
:badges="attribute.badges"
|
||||||
|
@edit="handleEditAttribute"
|
||||||
|
@delete="handleDeleteAttribute"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<AddAttribute
|
<AddAttribute
|
||||||
v-if="showAddPopup"
|
v-if="showAddPopup"
|
||||||
@@ -105,5 +195,34 @@ const onClickTabChange = index => {
|
|||||||
:on-close="hideAddPopup"
|
:on-close="hideAddPopup"
|
||||||
:selected-attribute-model-tab="selectedTabIndex"
|
:selected-attribute-model-tab="selectedTabIndex"
|
||||||
/>
|
/>
|
||||||
|
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
|
||||||
|
<EditAttribute
|
||||||
|
:selected-attribute="selectedAttribute"
|
||||||
|
:is-updating="uiFlags.isUpdating"
|
||||||
|
@on-close="hideEditPopup"
|
||||||
|
/>
|
||||||
|
</woot-modal>
|
||||||
|
<woot-confirm-delete-modal
|
||||||
|
v-if="showDeletePopup"
|
||||||
|
v-model:show="showDeletePopup"
|
||||||
|
:title="
|
||||||
|
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
|
||||||
|
attributeName: selectedAttribute.attribute_display_name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:message="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.MESSAGE')"
|
||||||
|
:confirm-text="`${$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${
|
||||||
|
selectedAttribute.attribute_display_name || ''
|
||||||
|
}`"
|
||||||
|
:reject-text="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO')"
|
||||||
|
:confirm-value="selectedAttribute.attribute_display_name"
|
||||||
|
:confirm-place-holder-text="
|
||||||
|
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
|
||||||
|
attributeName: selectedAttribute.attribute_display_name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@on-confirm="confirmDeleteAttribute"
|
||||||
|
@on-close="closeDelete"
|
||||||
|
/>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user