feat: Contact attributes
This commit is contained in:
+95
@@ -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>
|
||||
+130
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user