feat: Contact attributes

This commit is contained in:
iamsivin
2024-11-11 21:07:12 +05:30
parent d6cc5422d0
commit 0fe6d404b9
12 changed files with 750 additions and 11 deletions
@@ -0,0 +1,95 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttribute.vue';
import CheckboxAttribute from 'dashboard/components-next/CustomAttributes/CheckboxAttribute.vue';
import DateAttribute from 'dashboard/components-next/CustomAttributes/DateAttribute.vue';
import OtherAttribute from 'dashboard/components-next/CustomAttributes/OtherAttribute.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const store = useStore();
const { t } = useI18n();
const route = useRoute();
const handleDelete = async () => {
try {
await store.dispatch('contacts/deleteCustomAttributes', {
id: route.params.contactId,
customAttributes: [props.attribute.attributeKey],
});
useAlert(
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.DELETE_SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error?.response?.message ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.DELETE_ERROR')
);
}
};
const handleUpdate = async value => {
try {
await store.dispatch('contacts/update', {
id: route.params.contactId,
custom_attributes: {
[props.attribute.attributeKey]: value,
},
});
useAlert(t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.response?.message ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.API.UPDATE_ERROR')
);
}
};
const componentMap = {
list: ListAttribute,
checkbox: CheckboxAttribute,
date: DateAttribute,
default: OtherAttribute,
};
const CurrentAttributeComponent = computed(() => {
return (
componentMap[props.attribute.attributeDisplayType] || componentMap.default
);
});
</script>
<template>
<div
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-1"
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="CurrentAttributeComponent"
:attribute="attribute"
:is-editing-view="isEditingView"
@update="handleUpdate"
@delete="handleDelete"
/>
</div>
</template>
@@ -0,0 +1,130 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
const props = defineProps({
selectedContact: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const searchQuery = ref('');
const attributes = useMapGetter('attributes/getAttributesByModelType');
const contactAttributes = computed(() => attributes.value('contact_attribute'));
// Convert attribute key value from snake_case to camelCase
const toCamelCase = str =>
str.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
const usedAttributes = computed(() => {
if (!contactAttributes.value || !props.selectedContact?.customAttributes) {
return [];
}
return contactAttributes.value
.filter(attribute => {
const camelKey = toCamelCase(attribute.attributeKey);
return props.selectedContact.customAttributes[camelKey] !== undefined;
})
.map(attribute => ({
...attribute,
value:
props.selectedContact.customAttributes[
toCamelCase(attribute.attributeKey)
] ?? '',
}));
});
const unusedAttributes = computed(() => {
if (!contactAttributes.value || !props.selectedContact?.customAttributes) {
return [];
}
return contactAttributes.value
.filter(attribute => {
const camelKey = toCamelCase(attribute.attributeKey);
const attributeValue = props.selectedContact.customAttributes[camelKey];
// For checkbox type, consider both true/false as valid values
if (attribute.attributeDisplayType === 'checkbox') {
return typeof attributeValue !== 'boolean';
}
// For other types, check if value doesn't exist or is null/undefined
return attributeValue === undefined || attributeValue === null;
})
.map(attribute => ({
...attribute,
value:
props.selectedContact.customAttributes[
toCamelCase(attribute.attributeKey)
] ?? '',
}));
});
const filteredUnusedAttributes = computed(() => {
return unusedAttributes.value?.filter(attribute =>
attribute.attributeDisplayName
.toLowerCase()
.includes(searchQuery.value.toLowerCase())
);
});
const unusedAttributesCount = computed(() => unusedAttributes.value?.length);
</script>
<template>
<div class="flex flex-col gap-6 px-6 py-6">
<div class="flex flex-col gap-2">
<ContactCustomAttributeItem
v-for="attribute in usedAttributes"
:key="attribute.id"
is-editing-view
:attribute="attribute"
/>
</div>
<div class="flex items-center gap-3">
<div class="flex-1 h-[1px] bg-n-slate-5" />
<span class="text-sm font-medium text-n-slate-10">{{
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
count: unusedAttributesCount,
})
}}</span>
<div class="flex-1 h-[1px] bg-n-slate-5" />
</div>
<div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
v-model="searchQuery"
type="search"
:placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-xl bg-n-solid-1 text-n-slate-12"
/>
</div>
<div v-if="filteredUnusedAttributes.length === 0">
<p class="text-sm text-n-slate-11">
{{
unusedAttributesCount === 0
? t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.EMPTY_STATE')
: t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.NO_ATTRIBUTES')
}}
</p>
</div>
<div class="flex flex-col gap-2">
<ContactCustomAttributeItem
v-for="attribute in filteredUnusedAttributes"
:key="attribute.id"
:attribute="attribute"
/>
</div>
</div>
</template>
@@ -109,8 +109,8 @@ const handleAvatarDelete = async () => {
<div class="flex flex-col items-start gap-8 pb-6">
<div class="flex flex-col items-start gap-3">
<Avatar
:src="avatarSrc"
:name="selectedContact.name"
:src="avatarSrc || ''"
:name="selectedContact.name || ''"
:size="72"
allow-upload
@upload="handleAvatarUpload"
@@ -0,0 +1,48 @@
<!-- Attribute type "Checkbox" -->
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const handleChange = event => {
emit('update', event.target.checked);
};
</script>
<template>
<div
class="flex items-center w-full gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<input
:checked="Boolean(attribute.value)"
class="px-2 py-1 text-sm border rounded bg-n-solid-2 border-n-slate-5"
type="checkbox"
@change="handleChange"
/>
<Button
v-if="isEditingView"
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
</template>
@@ -0,0 +1,124 @@
<!-- Attribute type "Date" -->
<script setup>
import { ref, computed } from 'vue';
import { parseISO } from 'date-fns';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const isEditingValue = ref(false);
const editedValue = ref(props.attribute.value || '');
const defaultDateValue = computed({
get() {
const existingDate = editedValue.value ?? props.attribute.value;
if (existingDate) return new Date(existingDate).toISOString().slice(0, 10);
return isEditingValue.value ? new Date().toISOString().slice(0, 10) : '';
},
set(value) {
editedValue.value = value ? new Date(value).toISOString() : value;
},
});
const formattedDate = computed(() => {
return props.attribute.value
? new Date(props.attribute.value).toLocaleDateString()
: t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT');
});
const toggleEditValue = value => {
isEditingValue.value =
typeof value === 'boolean' ? value : !isEditingValue.value;
if (isEditingValue.value && !editedValue.value) {
editedValue.value = new Date().toISOString();
}
};
const handleInputUpdate = async () => {
emit('update', parseISO(editedValue.value));
isEditingValue.value = false;
};
</script>
<template>
<div
class="flex items-center w-full gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<span
v-if="!isEditingValue"
class="text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12': isEditingView,
}"
@click="toggleEditValue(!isEditingView)"
>
{{ formattedDate }}
</span>
<div
v-if="isEditingView && !isEditingValue"
class="flex items-center gap-1"
>
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleEditValue(true)"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
<div
v-if="isEditingValue"
v-on-clickaway="() => toggleEditValue(false)"
class="flex items-center w-full"
>
<Input
v-model="defaultDateValue"
type="date"
class="w-full"
autofocus
custom-input-class="h-8 rounded-r-none !border-n-brand"
@keyup.enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
color="blue"
size="sm"
class="flex-shrink-0 rounded-l-none"
@click="handleInputUpdate"
/>
</div>
</div>
</template>
@@ -0,0 +1,101 @@
<!-- Attribute type "List" -->
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const [showAttributeListDropdown, toggleAttributeListDropdown] = useToggle();
const attributeListMenuItems = computed(() => {
return (
props.attribute.attributeValues?.map(value => ({
label: value,
value,
action: 'select',
isSelected: value === props.attribute.value,
})) || []
);
});
const handleAttributeAction = async action => {
emit('update', action.value);
toggleAttributeListDropdown(false);
};
</script>
<template>
<div
class="flex items-center w-full gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<div
v-on-clickaway="() => toggleAttributeListDropdown(false)"
class="relative flex items-center"
>
<span
class="text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12': isEditingView,
}"
@click="toggleAttributeListDropdown(!props.isEditingView)"
>
{{
attribute.value ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.SELECT')
}}
</span>
<DropdownMenu
v-if="showAttributeListDropdown"
:menu-items="attributeListMenuItems"
show-search
class="w-48 mt-2 top-full"
:class="{
'right-0': !isEditingView,
'left-0': isEditingView,
}"
@action="handleAttributeAction($event)"
/>
</div>
<div v-if="isEditingView" class="flex items-center gap-1">
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleAttributeListDropdown()"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
</div>
</template>
@@ -0,0 +1,186 @@
<!-- Attribute type "Text, URL, Number" -->
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { isValidURL } from 'dashboard/helper/URLHelper.js';
import { getRegexp } from 'shared/helpers/Validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const isEditingValue = ref(false);
const editedValue = ref(props.attribute.value || '');
const isAttributeTypeLink = computed(
() => props.attribute.attributeDisplayType === 'link'
);
const isAttributeTypeText = computed(
() => props.attribute.attributeDisplayType === 'text'
);
const isAttributeTypeNumber = computed(
() => props.attribute.attributeDisplayType === 'number'
);
const rules = computed(() => ({
editedValue: {
required,
...(isAttributeTypeLink.value && {
url: value => !value || isValidURL(value),
}),
...(isAttributeTypeText.value &&
props.attribute.regexPattern && {
regexValidation: value => {
if (!value) return true;
return getRegexp(props.attribute.regexPattern).test(value);
},
}),
},
}));
const v$ = useVuelidate(rules, { editedValue });
const hasError = computed(() => v$.value.$error);
const attributeErrorMessage = computed(() => {
if (!hasError.value) return '';
if (isAttributeTypeLink.value && v$.value.editedValue.url?.$invalid) {
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
if (
isAttributeTypeText.value &&
props.attribute.regexPattern &&
v$.value.editedValue.regexValidation?.$invalid
) {
return (
props.attribute.regexCue ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
);
}
if (isAttributeTypeNumber.value && v$.value.editedValue.required?.$invalid) {
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_NUMBER');
}
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.REQUIRED');
});
const getInputType = computed(() => {
switch (props.attribute.attributeDisplayType) {
case 'link':
return 'url';
case 'number':
return 'number';
default:
return 'text';
}
});
const toggleEditValue = value => {
isEditingValue.value =
typeof value === 'boolean' ? value : !isEditingValue.value;
if (isEditingValue.value) {
v$.value.$reset();
editedValue.value = props.attribute.value || '';
}
};
const handleInputUpdate = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
emit('update', editedValue.value);
toggleEditValue(false);
};
</script>
<template>
<div
class="flex items-center w-full gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<span
v-if="!isEditingValue"
class="text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12': isEditingView,
}"
@click="toggleEditValue(!isEditingView)"
>
{{
attribute.value || t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')
}}
</span>
<div
v-if="isEditingView && !isEditingValue"
class="flex items-center gap-1"
>
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleEditValue(true)"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
<div
v-if="isEditingValue"
v-on-clickaway="() => toggleEditValue(false)"
class="flex items-center w-full"
>
<Input
v-model="editedValue"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')"
:type="getInputType"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
autofocus
:message="attributeErrorMessage"
:message-type="hasError ? 'error' : 'info'"
custom-input-class="h-8 rounded-r-none !border-n-brand"
@keyup.enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
color="blue"
size="sm"
class="flex-shrink-0 rounded-l-none"
@click="handleInputUpdate"
/>
</div>
</div>
</template>
@@ -6,7 +6,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
label: {
type: String,
type: [String, Number],
default: '',
},
variant: {
@@ -47,7 +47,7 @@ const STYLE_CONFIG = {
blue: {
solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent',
faded:
'bg-n-brand/10 text-n-slate-12 hover:bg-n-brand/20 outline-transparent',
'bg-n-brand/10 text-n-blue-text hover:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-blue-border',
link: 'text-n-blue-text hover:underline outline-transparent',
},
@@ -1,5 +1,5 @@
<script setup>
import { defineProps, ref, defineEmits, computed } from 'vue';
import { defineProps, ref, defineEmits, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
@@ -45,6 +45,12 @@ const filteredMenuItems = computed(() => {
const handleAction = (action, value) => {
emit('action', { action, value });
};
onMounted(() => {
if (searchInput.value && props.showSearch) {
searchInput.value.focus();
}
});
</script>
<template>
@@ -60,7 +66,7 @@ const handleAction = (action, value) => {
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-xl bg-n-solid-1 text-n-slate-12"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<button
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, onMounted, nextTick } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
@@ -42,11 +42,22 @@ const props = defineProps({
type: String,
default: '',
},
autofocus: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'blur', 'input', 'focus']);
const emit = defineEmits([
'update:modelValue',
'blur',
'input',
'focus',
'enter',
]);
const isFocused = ref(false);
const inputRef = ref(null);
const messageClass = computed(() => {
switch (props.messageType) {
@@ -82,6 +93,18 @@ const handleBlur = event => {
emit('blur', event);
isFocused.value = false;
};
const handleEnter = event => {
emit('enter', event);
};
onMounted(() => {
if (props.autofocus) {
nextTick(() => {
inputRef.value?.focus();
});
}
});
</script>
<template>
@@ -97,6 +120,7 @@ const handleBlur = event => {
<slot name="prefix" />
<input
:id="id"
ref="inputRef"
:value="modelValue"
:class="[
customInputClass,
@@ -114,6 +138,7 @@ const handleBlur = event => {
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keyup.enter="handleEnter"
/>
<p
v-if="message"
@@ -554,6 +554,30 @@
"CANCEL": "Cancel",
"CONFIRM": "Merge contact"
}
},
"ATTRIBUTES": {
"SEARCH_PLACEHOLDER": "Search for attributes",
"UNUSED_ATTRIBUTES": "{count} Used attribute | {count} Unused attributes",
"YES": "Yes",
"NO": "No",
"TRIGGER": {
"SELECT": "Select value",
"INPUT": "Enter value"
},
"VALIDATIONS": {
"INVALID_NUMBER": "Invalid number",
"REQUIRED": "Valid value is required",
"INVALID_INPUT": "Invalid input",
"INVALID_URL": "Invalid URL"
},
"NO_ATTRIBUTES": "No attributes found",
"EMPTY_STATE": "No unused attributes found",
"API": {
"SUCCESS_MESSAGE": "Attribute updated successfully",
"DELETE_SUCCESS_MESSAGE": "Attribute deleted successfully",
"UPDATE_ERROR": "Unable to update attribute. Please try again later",
"DELETE_ERROR": "Unable to delete attribute. Please try again later"
}
}
}
}
@@ -11,7 +11,7 @@ import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
import ContactHistory from 'dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue';
import ContactMerge from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue';
// import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
const store = useStore();
const route = useRoute();
@@ -137,10 +137,10 @@ onMounted(() => {
@tab-changed="handleTabChange"
/>
</div>
<!-- <ContactCustomAttributes
<ContactCustomAttributes
v-if="activeTab === 'attributes'"
:selected-contact="selectedContact"
/> -->
/>
<ContactNotes v-if="activeTab === 'notes'" />
<ContactHistory v-if="activeTab === 'history'" />
<ContactMerge