mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 19:48:08 +00:00
feat(v4): Update the design for the contacts list page (#10501)
--------- Co-authored-by: Pranav <pranavrajs@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
import countries from 'shared/constants/countries';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: { type: Number, required: true },
|
id: { type: Number, required: true },
|
||||||
@@ -14,46 +16,108 @@ const props = defineProps({
|
|||||||
phoneNumber: { type: String, default: '' },
|
phoneNumber: { type: String, default: '' },
|
||||||
thumbnail: { type: String, default: '' },
|
thumbnail: { type: String, default: '' },
|
||||||
isExpanded: { type: Boolean, default: false },
|
isExpanded: { type: Boolean, default: false },
|
||||||
|
isUpdating: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
|
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const contactsFormRef = ref(null);
|
||||||
|
|
||||||
|
const getInitialContactData = () => ({
|
||||||
|
id: props.id,
|
||||||
|
name: props.name,
|
||||||
|
email: props.email,
|
||||||
|
phoneNumber: props.phoneNumber,
|
||||||
|
additionalAttributes: props.additionalAttributes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contactData = ref(getInitialContactData());
|
||||||
|
|
||||||
|
const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
|
||||||
|
|
||||||
|
const countriesMap = computed(() => {
|
||||||
|
return countries.reduce((acc, country) => {
|
||||||
|
acc[country.code] = country;
|
||||||
|
acc[country.id] = country;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const countryDetails = computed(() => {
|
||||||
|
const attributes = props.additionalAttributes || {};
|
||||||
|
const { country, countryCode, city } = attributes;
|
||||||
|
|
||||||
|
if (!country && !countryCode) return null;
|
||||||
|
|
||||||
|
const activeCountry =
|
||||||
|
countriesMap.value[country] || countriesMap.value[countryCode];
|
||||||
|
|
||||||
|
if (!activeCountry) return null;
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
activeCountry.emoji,
|
||||||
|
city ? `${city},` : null,
|
||||||
|
activeCountry.name,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return parts.length ? parts.join(' ') : null;
|
||||||
|
});
|
||||||
|
|
||||||
const handleFormUpdate = updatedData => {
|
const handleFormUpdate = updatedData => {
|
||||||
emit('updateContact', { id: props.id, updatedData });
|
Object.assign(contactData.value, updatedData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickViewDetails = async () => {
|
const handleUpdateContact = () => {
|
||||||
emit('showContact', props.id);
|
emit('updateContact', contactData.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onClickExpand = () => {
|
||||||
|
emit('toggle');
|
||||||
|
contactData.value = getInitialContactData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickViewDetails = () => emit('showContact', props.id);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CardLayout :key="id" layout="row">
|
<CardLayout :key="id" layout="row">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-start flex-1 gap-4">
|
||||||
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
|
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-0.5 flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
<span class="text-base font-medium truncate text-n-slate-12">
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</span>
|
</span>
|
||||||
<template v-if="additionalAttributes?.companyName">
|
<span class="inline-flex items-center gap-1">
|
||||||
<span class="text-sm text-n-slate-11">
|
<span
|
||||||
{{ t('CONTACTS_LAYOUT.CARD.OF') }}
|
v-if="additionalAttributes?.companyName"
|
||||||
</span>
|
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
|
||||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
/>
|
||||||
|
<span
|
||||||
|
v-if="additionalAttributes?.companyName"
|
||||||
|
class="text-sm truncate text-n-slate-11"
|
||||||
|
>
|
||||||
{{ additionalAttributes.companyName }}
|
{{ additionalAttributes.companyName }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
|
||||||
<span v-if="email" class="text-sm text-n-slate-11">{{ email }}</span>
|
<div v-if="email" class="truncate max-w-72" :title="email">
|
||||||
<div v-if="email" class="w-px h-3 bg-n-slate-6" />
|
<span class="text-sm text-n-slate-11">
|
||||||
<span v-if="phoneNumber" class="text-sm text-n-slate-11">
|
{{ email }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
|
||||||
|
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
|
||||||
{{ phoneNumber }}
|
{{ phoneNumber }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="phoneNumber" class="w-px h-3 bg-n-slate-6" />
|
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
|
||||||
|
<span v-if="countryDetails" class="text-sm truncate text-n-slate-11">
|
||||||
|
{{ countryDetails }}
|
||||||
|
</span>
|
||||||
|
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
|
||||||
<Button
|
<Button
|
||||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||||
variant="link"
|
variant="link"
|
||||||
@@ -70,7 +134,7 @@ const onClickViewDetails = async () => {
|
|||||||
color="slate"
|
color="slate"
|
||||||
size="xs"
|
size="xs"
|
||||||
:class="{ 'rotate-180': isExpanded }"
|
:class="{ 'rotate-180': isExpanded }"
|
||||||
@click="emit('toggle')"
|
@click="onClickExpand"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template #after>
|
<template #after>
|
||||||
@@ -78,22 +142,28 @@ const onClickViewDetails = async () => {
|
|||||||
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
|
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
|
||||||
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
|
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
|
||||||
enter-from-class="overflow-hidden opacity-0 max-h-0"
|
enter-from-class="overflow-hidden opacity-0 max-h-0"
|
||||||
enter-to-class="opacity-100 max-h-[360px]"
|
enter-to-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
|
||||||
leave-from-class="opacity-100 max-h-[360px]"
|
leave-from-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
|
||||||
leave-to-class="overflow-hidden opacity-0 max-h-0"
|
leave-to-class="overflow-hidden opacity-0 max-h-0"
|
||||||
>
|
>
|
||||||
<div v-show="isExpanded" class="w-full">
|
<div v-show="isExpanded" class="w-full">
|
||||||
<div class="p-6 border-t border-n-strong">
|
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
||||||
<ContactsForm
|
<ContactsForm
|
||||||
:contact-data="{
|
ref="contactsFormRef"
|
||||||
id,
|
:contact-data="contactData"
|
||||||
name,
|
|
||||||
email,
|
|
||||||
phoneNumber,
|
|
||||||
additionalAttributes,
|
|
||||||
}"
|
|
||||||
@update="handleFormUpdate"
|
@update="handleFormUpdate"
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
:label="
|
||||||
|
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
|
||||||
|
"
|
||||||
|
size="sm"
|
||||||
|
:is-loading="isUpdating"
|
||||||
|
:disabled="isUpdating || isFormInvalid"
|
||||||
|
@click="handleUpdateContact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
|
||||||
|
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['export']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
|
const segments = useMapGetter('customViews/getContactCustomViews');
|
||||||
|
const appliedFilters = useMapGetter('contacts/getAppliedContactFilters');
|
||||||
|
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
|
const isExportingContact = computed(() => uiFlags.value.isExporting);
|
||||||
|
|
||||||
|
const activeSegmentId = computed(() => route.params.segmentId);
|
||||||
|
const activeSegment = computed(() =>
|
||||||
|
activeSegmentId.value
|
||||||
|
? segments.value.find(view => view.id === Number(activeSegmentId.value))
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportContacts = async () => {
|
||||||
|
let query = { payload: [] };
|
||||||
|
|
||||||
|
if (activeSegmentId.value && activeSegment.value) {
|
||||||
|
query = activeSegment.value.query;
|
||||||
|
} else if (Object.keys(appliedFilters.value).length > 0) {
|
||||||
|
query = filterQueryGenerator(appliedFilters.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('export', {
|
||||||
|
...query,
|
||||||
|
label: route.params.label || '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogConfirm = async () => {
|
||||||
|
await exportContacts();
|
||||||
|
dialogRef.value?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ dialogRef });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.TITLE')"
|
||||||
|
:description="
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.DESCRIPTION')
|
||||||
|
"
|
||||||
|
:confirm-button-label="
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.CONFIRM')
|
||||||
|
"
|
||||||
|
:is-loading="isExportingContact"
|
||||||
|
:disable-confirm-button="isExportingContact"
|
||||||
|
@confirm="handleDialogConfirm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['import']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
|
const isImportingContact = computed(() => uiFlags.value.isImporting);
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
const fileInput = ref(null);
|
||||||
|
|
||||||
|
const hasSelectedFile = ref(null);
|
||||||
|
const selectedFileName = ref('');
|
||||||
|
|
||||||
|
const csvUrl = '/downloads/import-contacts-sample.csv';
|
||||||
|
|
||||||
|
const handleFileClick = () => fileInput.value?.click();
|
||||||
|
|
||||||
|
const processFileName = fileName => {
|
||||||
|
const lastDotIndex = fileName.lastIndexOf('.');
|
||||||
|
const extension = fileName.slice(lastDotIndex);
|
||||||
|
const baseName = fileName.slice(0, lastDotIndex);
|
||||||
|
|
||||||
|
return baseName.length > 20
|
||||||
|
? `${baseName.slice(0, 20)}...${extension}`
|
||||||
|
: fileName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = () => {
|
||||||
|
const file = fileInput.value?.files[0];
|
||||||
|
hasSelectedFile.value = file;
|
||||||
|
selectedFileName.value = file ? processFileName(file.name) : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = () => {
|
||||||
|
hasSelectedFile.value = null;
|
||||||
|
if (fileInput.value) {
|
||||||
|
fileInput.value.value = null;
|
||||||
|
}
|
||||||
|
selectedFileName.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFile = async () => {
|
||||||
|
if (!hasSelectedFile.value) return;
|
||||||
|
emit('import', hasSelectedFile.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ dialogRef });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.TITLE')"
|
||||||
|
:confirm-button-label="
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.IMPORT')
|
||||||
|
"
|
||||||
|
:is-loading="isImportingContact"
|
||||||
|
:disable-confirm-button="isImportingContact"
|
||||||
|
@confirm="uploadFile"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<p class="mb-0 text-sm text-n-slate-11">
|
||||||
|
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DESCRIPTION') }}
|
||||||
|
<a
|
||||||
|
:href="csvUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
download="import-contacts-sample.csv"
|
||||||
|
class="text-n-blue-text"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
|
||||||
|
}}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm text-n-slate-12 whitespace-nowrap">
|
||||||
|
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center justify-between w-full gap-2">
|
||||||
|
<span v-if="hasSelectedFile" class="text-sm text-n-slate-12">
|
||||||
|
{{ selectedFileName }}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
v-if="!hasSelectedFile"
|
||||||
|
:label="
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHOOSE_FILE')
|
||||||
|
"
|
||||||
|
icon="i-lucide-upload"
|
||||||
|
color="slate"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="!w-fit"
|
||||||
|
@click="handleFileClick"
|
||||||
|
/>
|
||||||
|
<div v-else class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
:label="t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.CHANGE')"
|
||||||
|
color="slate"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="handleFileClick"
|
||||||
|
/>
|
||||||
|
<div class="w-px h-3 bg-n-strong" />
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
color="slate"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="handleRemoveFile"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="text/csv"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileChange"
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -3,7 +3,7 @@ import { computed, reactive, watch } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { required, email, minLength } from '@vuelidate/validators';
|
import { required, email, minLength } from '@vuelidate/validators';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { splitName } from '@chatwoot/utils';
|
||||||
import countries from 'shared/constants/countries.js';
|
import countries from 'shared/constants/countries.js';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
@@ -80,6 +80,8 @@ const validationRules = {
|
|||||||
|
|
||||||
const v$ = useVuelidate(validationRules, state);
|
const v$ = useVuelidate(validationRules, state);
|
||||||
|
|
||||||
|
const isFormInvalid = computed(() => v$.value.$invalid);
|
||||||
|
|
||||||
const prepareStateBasedOnProps = () => {
|
const prepareStateBasedOnProps = () => {
|
||||||
if (props.isNewContact) {
|
if (props.isNewContact) {
|
||||||
return; // Added to prevent state update for new contact form
|
return; // Added to prevent state update for new contact form
|
||||||
@@ -92,8 +94,7 @@ const prepareStateBasedOnProps = () => {
|
|||||||
phoneNumber,
|
phoneNumber,
|
||||||
additionalAttributes = {},
|
additionalAttributes = {},
|
||||||
} = props.contactData || {};
|
} = props.contactData || {};
|
||||||
|
const { firstName, lastName } = splitName(name);
|
||||||
const [firstName = '', lastName = ''] = name.split(' ');
|
|
||||||
const {
|
const {
|
||||||
description,
|
description,
|
||||||
companyName,
|
companyName,
|
||||||
@@ -203,6 +204,16 @@ const getMessageType = key => {
|
|||||||
: 'info';
|
: 'info';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCountrySelection = value => {
|
||||||
|
const selectedCountry = countries.find(option => option.name === value);
|
||||||
|
state.additionalAttributes.countryCode = selectedCountry?.id || '';
|
||||||
|
emit('update', state);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetValidation = () => {
|
||||||
|
v$.value.$reset();
|
||||||
|
};
|
||||||
|
|
||||||
watch(() => props.contactData, prepareStateBasedOnProps, {
|
watch(() => props.contactData, prepareStateBasedOnProps, {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
deep: true,
|
deep: true,
|
||||||
@@ -211,6 +222,8 @@ watch(() => props.contactData, prepareStateBasedOnProps, {
|
|||||||
// Expose state to parent component for avatar upload
|
// Expose state to parent component for avatar upload
|
||||||
defineExpose({
|
defineExpose({
|
||||||
state,
|
state,
|
||||||
|
resetValidation,
|
||||||
|
isFormInvalid,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -220,7 +233,7 @@ defineExpose({
|
|||||||
<span class="py-1 text-sm font-medium text-n-slate-12">
|
<span class="py-1 text-sm font-medium text-n-slate-12">
|
||||||
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
|
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
|
||||||
</span>
|
</span>
|
||||||
<div class="grid w-full grid-cols-2 gap-4">
|
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<template v-for="item in editDetailsForm" :key="item.key">
|
<template v-for="item in editDetailsForm" :key="item.key">
|
||||||
<ComboBox
|
<ComboBox
|
||||||
v-if="item.key === 'COUNTRY'"
|
v-if="item.key === 'COUNTRY'"
|
||||||
@@ -234,7 +247,7 @@ defineExpose({
|
|||||||
'[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2':
|
'[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2':
|
||||||
isDetailsView,
|
isDetailsView,
|
||||||
}"
|
}"
|
||||||
@update:model-value="emit('update', state)"
|
@update:model-value="handleCountrySelection"
|
||||||
/>
|
/>
|
||||||
<PhoneNumberInput
|
<PhoneNumberInput
|
||||||
v-else-if="item.key === 'PHONE_NUMBER'"
|
v-else-if="item.key === 'PHONE_NUMBER'"
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['create']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
const contactsFormRef = ref(null);
|
||||||
|
const contact = ref(null);
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
|
const isCreatingContact = computed(() => uiFlags.value.isCreating);
|
||||||
|
|
||||||
|
const createNewContact = contactItem => {
|
||||||
|
contact.value = contactItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogConfirm = async () => {
|
||||||
|
if (!contact.value) return;
|
||||||
|
emit('create', contact.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
dialogRef.value.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ dialogRef, contactsFormRef });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
|
||||||
|
<ContactsForm
|
||||||
|
ref="contactsFormRef"
|
||||||
|
is-new-contact
|
||||||
|
@update="createNewContact"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-between w-full gap-3">
|
||||||
|
<Button
|
||||||
|
:label="t('DIALOG.BUTTONS.CANCEL')"
|
||||||
|
variant="link"
|
||||||
|
class="h-10 hover:!no-underline hover:text-n-brand"
|
||||||
|
@click="closeDialog"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT')
|
||||||
|
"
|
||||||
|
color="blue"
|
||||||
|
:is-loading="isCreatingContact"
|
||||||
|
@click="handleDialogConfirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { required } from '@vuelidate/validators';
|
||||||
|
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['create']);
|
||||||
|
|
||||||
|
const FILTER_TYPE_CONTACT = 1;
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('customViews/getUIFlags');
|
||||||
|
const isCreating = computed(() => uiFlags.value.isCreating);
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
name: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const validationRules = {
|
||||||
|
name: { required },
|
||||||
|
};
|
||||||
|
|
||||||
|
const v$ = useVuelidate(validationRules, state);
|
||||||
|
|
||||||
|
const handleDialogConfirm = async () => {
|
||||||
|
const isNameValid = await v$.value.$validate();
|
||||||
|
if (!isNameValid) return;
|
||||||
|
emit('create', {
|
||||||
|
name: state.name,
|
||||||
|
filter_type: FILTER_TYPE_CONTACT,
|
||||||
|
});
|
||||||
|
state.name = '';
|
||||||
|
v$.value.$reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ dialogRef });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.TITLE')"
|
||||||
|
:confirm-button-label="
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.CONFIRM')
|
||||||
|
"
|
||||||
|
:is-loading="isCreating"
|
||||||
|
:disable-confirm-button="isCreating"
|
||||||
|
@confirm="handleDialogConfirm"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="state.name"
|
||||||
|
:label="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.LABEL')"
|
||||||
|
:placeholder="
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
:message="
|
||||||
|
v$.name.$error
|
||||||
|
? t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.ERROR')
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
:message-type="v$.name.$error ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['delete']);
|
||||||
|
|
||||||
|
const FILTER_TYPE_CONTACT = 'contact';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('customViews/getUIFlags');
|
||||||
|
const isDeleting = computed(() => uiFlags.value.isDeleting);
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
|
const handleDialogConfirm = async () => {
|
||||||
|
emit('delete', {
|
||||||
|
filterType: FILTER_TYPE_CONTACT,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ dialogRef });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
type="alert"
|
||||||
|
:title="t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.TITLE')"
|
||||||
|
:description="
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.DESCRIPTION')
|
||||||
|
"
|
||||||
|
:confirm-button-label="
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.CONFIRM')
|
||||||
|
"
|
||||||
|
:is-loading="isDeleting"
|
||||||
|
:disable-confirm-button="isDeleting"
|
||||||
|
@confirm="handleDialogConfirm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -18,10 +18,10 @@ defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
buttonLabel: {
|
// buttonLabel: {
|
||||||
type: String,
|
// type: String,
|
||||||
required: true,
|
// default: '',
|
||||||
},
|
// },
|
||||||
activeSort: {
|
activeSort: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'last_activity_at',
|
default: 'last_activity_at',
|
||||||
@@ -30,16 +30,26 @@ defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
isSegmentsView: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
hasActiveFilters: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'search',
|
'search',
|
||||||
'filter',
|
'filter',
|
||||||
'update:sort',
|
'update:sort',
|
||||||
'message',
|
// 'message',
|
||||||
'add',
|
'add',
|
||||||
'import',
|
'import',
|
||||||
'export',
|
'export',
|
||||||
|
'createSegment',
|
||||||
|
'deleteSegment',
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -72,11 +82,37 @@ const emit = defineEmits([
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-list-filter"
|
:icon="
|
||||||
|
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
||||||
|
"
|
||||||
color="slate"
|
color="slate"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
class="relative"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@click="emit('filter')"
|
@click="emit('filter')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="hasActiveFilters && !isSegmentsView"
|
||||||
|
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="hasActiveFilters && !isSegmentsView"
|
||||||
|
icon="i-lucide-save"
|
||||||
|
color="slate"
|
||||||
|
size="sm"
|
||||||
|
class="relative"
|
||||||
|
variant="ghost"
|
||||||
|
@click="emit('createSegment')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="isSegmentsView"
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
color="slate"
|
||||||
|
size="sm"
|
||||||
|
class="relative"
|
||||||
|
variant="ghost"
|
||||||
|
@click="emit('deleteSegment')"
|
||||||
/>
|
/>
|
||||||
<ContactSortMenu
|
<ContactSortMenu
|
||||||
:active-sort="activeSort"
|
:active-sort="activeSort"
|
||||||
@@ -89,8 +125,9 @@ const emit = defineEmits([
|
|||||||
@export="emit('export')"
|
@export="emit('export')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-px h-4 bg-n-strong" />
|
<!-- TODO: Add this when we enabling message feature -->
|
||||||
<Button :label="buttonLabel" size="sm" @click="emit('message')" />
|
<!-- <div class="w-px h-4 bg-n-strong" /> -->
|
||||||
|
<!-- <Button :label="buttonLabel" size="sm" @click="emit('message')" /> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useAlert, useTrack } from 'dashboard/composables';
|
||||||
|
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
|
||||||
|
import contactFilterItems from 'dashboard/routes/dashboard/contacts/contactFilterItems';
|
||||||
|
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
|
||||||
|
import countries from 'shared/constants/countries';
|
||||||
|
|
||||||
|
import ContactsHeader from 'dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue';
|
||||||
|
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
|
||||||
|
import ContactExportDialog from 'dashboard/components-next/Contacts/ContactsForm/ContactExportDialog.vue';
|
||||||
|
import ContactImportDialog from 'dashboard/components-next/Contacts/ContactsForm/ContactImportDialog.vue';
|
||||||
|
import CreateSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateSegmentDialog.vue';
|
||||||
|
import DeleteSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/DeleteSegmentDialog.vue';
|
||||||
|
import ContactsAdvancedFilters from 'dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
showSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
searchValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
activeSort: {
|
||||||
|
type: String,
|
||||||
|
default: 'last_activity_at',
|
||||||
|
},
|
||||||
|
activeOrdering: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
segmentsId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
activeSegment: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
hasAppliedFilters: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:sort',
|
||||||
|
'search',
|
||||||
|
'applyFilter',
|
||||||
|
'clearFilters',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const createNewContactDialogRef = ref(null);
|
||||||
|
const contactExportDialogRef = ref(null);
|
||||||
|
const contactImportDialogRef = ref(null);
|
||||||
|
const createSegmentDialogRef = ref(null);
|
||||||
|
const deleteSegmentDialogRef = ref(null);
|
||||||
|
|
||||||
|
const showFiltersModal = ref(false);
|
||||||
|
const appliedFilter = ref([]);
|
||||||
|
const segmentsQuery = ref({});
|
||||||
|
|
||||||
|
const hasActiveSegments = computed(
|
||||||
|
() => props.activeSegment && props.segmentsId !== 0
|
||||||
|
);
|
||||||
|
const activeSegmentName = computed(() => props.activeSegment?.name);
|
||||||
|
|
||||||
|
const contactFilterItemsList = computed(() =>
|
||||||
|
contactFilterItems.map(filter => ({
|
||||||
|
...filter,
|
||||||
|
attributeName: t(`CONTACTS_FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const openCreateNewContactDialog = async () => {
|
||||||
|
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
|
||||||
|
createNewContactDialogRef.value?.dialogRef.open();
|
||||||
|
};
|
||||||
|
const openContactImportDialog = () =>
|
||||||
|
contactImportDialogRef.value?.dialogRef.open();
|
||||||
|
const openContactExportDialog = () =>
|
||||||
|
contactExportDialogRef.value?.dialogRef.open();
|
||||||
|
const openCreateSegmentDialog = () =>
|
||||||
|
createSegmentDialogRef.value?.dialogRef.open();
|
||||||
|
const openDeleteSegmentDialog = () =>
|
||||||
|
deleteSegmentDialogRef.value?.dialogRef.open();
|
||||||
|
|
||||||
|
const onCreate = async contact => {
|
||||||
|
await store.dispatch('contacts/create', contact);
|
||||||
|
createNewContactDialogRef.value?.dialogRef.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onImport = async file => {
|
||||||
|
try {
|
||||||
|
await store.dispatch('contacts/import', file);
|
||||||
|
contactImportDialogRef.value?.dialogRef.close();
|
||||||
|
useAlert(t('IMPORT_CONTACTS.SUCCESS_MESSAGE'));
|
||||||
|
useTrack(CONTACTS_EVENTS.IMPORT_SUCCESS);
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(error.message ?? t('IMPORT_CONTACTS.ERROR_MESSAGE'));
|
||||||
|
useTrack(CONTACTS_EVENTS.IMPORT_FAILURE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onExport = async query => {
|
||||||
|
try {
|
||||||
|
await store.dispatch('contacts/export', query);
|
||||||
|
useAlert(
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.SUCCESS_MESSAGE')
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(
|
||||||
|
error.message ||
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.ERROR_MESSAGE')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCreateSegment = async payload => {
|
||||||
|
try {
|
||||||
|
const payloadData = {
|
||||||
|
...payload,
|
||||||
|
query: segmentsQuery.value,
|
||||||
|
};
|
||||||
|
await store.dispatch('customViews/create', payloadData);
|
||||||
|
createSegmentDialogRef.value?.dialogRef.close();
|
||||||
|
useAlert(
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.SUCCESS_MESSAGE')
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
useAlert(
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.ERROR_MESSAGE')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteSegment = async payload => {
|
||||||
|
try {
|
||||||
|
await store.dispatch('customViews/delete', {
|
||||||
|
id: Number(props.segmentsId),
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
router.push({
|
||||||
|
name: 'contacts_dashboard_index',
|
||||||
|
query: {
|
||||||
|
page: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
deleteSegmentDialogRef.value?.dialogRef.close();
|
||||||
|
useAlert(
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.SUCCESS_MESSAGE')
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(
|
||||||
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.ERROR_MESSAGE')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAdvanceFiltersModal = () => {
|
||||||
|
showFiltersModal.value = false;
|
||||||
|
appliedFilter.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = async () => {
|
||||||
|
await store.dispatch('contacts/clearContactFilters');
|
||||||
|
emit('clearFilters');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onApplyFilter = async payload => {
|
||||||
|
segmentsQuery.value = filterQueryGenerator(payload);
|
||||||
|
emit('applyFilter', filterQueryGenerator(payload));
|
||||||
|
showFiltersModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpdateSegment = async (payload, segmentName) => {
|
||||||
|
const payloadData = {
|
||||||
|
...props.activeSegment,
|
||||||
|
name: segmentName,
|
||||||
|
query: filterQueryGenerator(payload),
|
||||||
|
};
|
||||||
|
await store.dispatch('customViews/update', payloadData);
|
||||||
|
closeAdvanceFiltersModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setParamsForEditSegmentModal = () => {
|
||||||
|
return {
|
||||||
|
countries,
|
||||||
|
filterTypes: contactFilterItems,
|
||||||
|
allCustomAttributes:
|
||||||
|
store.getters['attributes/getAttributesByModel']('contact_attribute'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeSegmentToFilterModal = segment => {
|
||||||
|
const query = segment?.query?.payload;
|
||||||
|
if (!Array.isArray(query)) return;
|
||||||
|
|
||||||
|
appliedFilter.value = query.map(filter => ({
|
||||||
|
attribute_key: filter.attribute_key,
|
||||||
|
attribute_model: filter.attribute_model,
|
||||||
|
filter_operator: filter.filter_operator,
|
||||||
|
values: Array.isArray(filter.values)
|
||||||
|
? generateValuesForEditCustomViews(filter, setParamsForEditSegmentModal())
|
||||||
|
: [],
|
||||||
|
query_operator: filter.query_operator,
|
||||||
|
custom_attribute_type: filter.custom_attribute_type,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleFilters = () => {
|
||||||
|
appliedFilter.value = [];
|
||||||
|
if (hasActiveSegments.value) {
|
||||||
|
initializeSegmentToFilterModal(props.activeSegment);
|
||||||
|
}
|
||||||
|
showFiltersModal.value = true;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContactsHeader
|
||||||
|
:show-search="showSearch"
|
||||||
|
:search-value="searchValue"
|
||||||
|
:active-sort="activeSort"
|
||||||
|
:active-ordering="activeOrdering"
|
||||||
|
:header-title="headerTitle"
|
||||||
|
:is-segments-view="hasActiveSegments"
|
||||||
|
:has-active-filters="hasAppliedFilters"
|
||||||
|
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
|
||||||
|
@search="emit('search', $event)"
|
||||||
|
@update:sort="emit('update:sort', $event)"
|
||||||
|
@add="openCreateNewContactDialog"
|
||||||
|
@import="openContactImportDialog"
|
||||||
|
@export="openContactExportDialog"
|
||||||
|
@filter="onToggleFilters"
|
||||||
|
@create-segment="openCreateSegmentDialog"
|
||||||
|
@delete-segment="openDeleteSegmentDialog"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateNewContactDialog ref="createNewContactDialogRef" @create="onCreate" />
|
||||||
|
<ContactExportDialog ref="contactExportDialogRef" @export="onExport" />
|
||||||
|
<ContactImportDialog ref="contactImportDialogRef" @import="onImport" />
|
||||||
|
<CreateSegmentDialog ref="createSegmentDialogRef" @create="onCreateSegment" />
|
||||||
|
<DeleteSegmentDialog ref="deleteSegmentDialogRef" @delete="onDeleteSegment" />
|
||||||
|
<woot-modal
|
||||||
|
v-model:show="showFiltersModal"
|
||||||
|
:on-close="closeAdvanceFiltersModal"
|
||||||
|
size="medium"
|
||||||
|
>
|
||||||
|
<ContactsAdvancedFilters
|
||||||
|
v-if="showFiltersModal"
|
||||||
|
:on-close="closeAdvanceFiltersModal"
|
||||||
|
:initial-filter-types="contactFilterItemsList"
|
||||||
|
:initial-applied-filters="appliedFilter"
|
||||||
|
:active-segment-name="activeSegmentName"
|
||||||
|
:is-segments-view="hasActiveSegments"
|
||||||
|
@apply-filter="onApplyFilter"
|
||||||
|
@update-segment="onUpdateSegment"
|
||||||
|
@clear-filters="clearFilters"
|
||||||
|
/>
|
||||||
|
</woot-modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
|
||||||
|
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
searchValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
showPaginationFooter: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
currentPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
totalItems: {
|
||||||
|
type: Number,
|
||||||
|
default: 100,
|
||||||
|
},
|
||||||
|
itemsPerPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 15,
|
||||||
|
},
|
||||||
|
activeSort: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
activeOrdering: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
activeSegment: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
segmentsId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
hasAppliedFilters: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'update:currentPage',
|
||||||
|
'update:sort',
|
||||||
|
'search',
|
||||||
|
'applyFilter',
|
||||||
|
'clearFilters',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const isNotSegmentView = computed(() => {
|
||||||
|
return route.name !== 'contacts_dashboard_segments_index';
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCurrentPage = page => {
|
||||||
|
emit('update:currentPage', page);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col w-full h-full transition-all duration-300">
|
||||||
|
<ContactListHeaderWrapper
|
||||||
|
:show-search="isNotSegmentView"
|
||||||
|
:search-value="searchValue"
|
||||||
|
:active-sort="activeSort"
|
||||||
|
:active-ordering="activeOrdering"
|
||||||
|
:header-title="headerTitle"
|
||||||
|
:active-segment="activeSegment"
|
||||||
|
:segments-id="segmentsId"
|
||||||
|
:has-applied-filters="hasAppliedFilters"
|
||||||
|
@update:sort="emit('update:sort', $event)"
|
||||||
|
@search="emit('search', $event)"
|
||||||
|
@apply-filter="emit('applyFilter', $event)"
|
||||||
|
@clear-filters="emit('clearFilters')"
|
||||||
|
/>
|
||||||
|
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
|
||||||
|
<div class="w-full mx-auto max-w-[960px]">
|
||||||
|
<slot name="default" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer
|
||||||
|
v-if="showPaginationFooter"
|
||||||
|
class="sticky bottom-0 z-10 px-4 pb-4"
|
||||||
|
>
|
||||||
|
<PaginationFooter
|
||||||
|
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||||
|
:current-page="currentPage"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
@update:current-page="updateCurrentPage"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||||
|
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
|
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
|
||||||
import contactContent from 'dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent';
|
import contactContent from 'dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent';
|
||||||
@@ -22,6 +25,14 @@ defineProps({
|
|||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['create']);
|
||||||
|
|
||||||
|
const createNewContactDialogRef = ref(null);
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
createNewContactDialogRef.value?.dialogRef.open();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -45,6 +56,10 @@ defineProps({
|
|||||||
<template #actions>
|
<template #actions>
|
||||||
<div v-if="showButton">
|
<div v-if="showButton">
|
||||||
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
|
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
|
||||||
|
<CreateNewContactDialog
|
||||||
|
ref="createNewContactDialogRef"
|
||||||
|
@create="emit('create', $event)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</EmptyStateLayout>
|
</EmptyStateLayout>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import {
|
||||||
|
DuplicateContactException,
|
||||||
|
ExceptionWithMessage,
|
||||||
|
} from 'shared/helpers/CustomErrors';
|
||||||
|
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
|
||||||
|
|
||||||
|
defineProps({ contacts: { type: Array, required: true } });
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
|
const isUpdating = computed(() => uiFlags.value.isUpdating);
|
||||||
|
const expandedCardId = ref(null);
|
||||||
|
|
||||||
|
const updateContact = async updatedData => {
|
||||||
|
try {
|
||||||
|
await store.dispatch('contacts/update', updatedData);
|
||||||
|
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.SUCCESS_MESSAGE'));
|
||||||
|
} catch (error) {
|
||||||
|
const i18nPrefix = 'CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.FORM';
|
||||||
|
if (error instanceof DuplicateContactException) {
|
||||||
|
if (error.data.includes('email')) {
|
||||||
|
useAlert(t(`${i18nPrefix}.EMAIL_ADDRESS.DUPLICATE`));
|
||||||
|
} else if (error.data.includes('phone_number')) {
|
||||||
|
useAlert(t(`${i18nPrefix}.PHONE_NUMBER.DUPLICATE`));
|
||||||
|
}
|
||||||
|
} else if (error instanceof ExceptionWithMessage) {
|
||||||
|
useAlert(error.data);
|
||||||
|
} else {
|
||||||
|
useAlert(t(`${i18nPrefix}.ERROR_MESSAGE`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickViewDetails = async id => {
|
||||||
|
const params = { contactId: id };
|
||||||
|
if (route.name.includes('segments')) {
|
||||||
|
params.segmentId = route.params.segmentId;
|
||||||
|
} else if (route.name.includes('labels')) {
|
||||||
|
params.label = route.params.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push({ name: 'contacts_edit', params, query: route.query });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpanded = id => {
|
||||||
|
expandedCardId.value = expandedCardId.value === id ? null : id;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4 p-6">
|
||||||
|
<ContactsCard
|
||||||
|
v-for="contact in contacts"
|
||||||
|
:id="contact.id"
|
||||||
|
:key="contact.id"
|
||||||
|
:name="contact.name"
|
||||||
|
:email="contact.email"
|
||||||
|
:thumbnail="contact.thumbnail"
|
||||||
|
:phone-number="contact.phoneNumber"
|
||||||
|
:additional-attributes="contact.additionalAttributes"
|
||||||
|
:is-expanded="expandedCardId === contact.id"
|
||||||
|
:is-updating="isUpdating"
|
||||||
|
@toggle="toggleExpanded(contact.id)"
|
||||||
|
@update-contact="updateContact"
|
||||||
|
@show-contact="onClickViewDetails"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -132,7 +132,7 @@ const iconStyles = computed(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const initialsStyles = computed(() => ({
|
const initialsStyles = computed(() => ({
|
||||||
fontSize: `${props.size / 2}px`,
|
fontSize: `${props.size > 32 ? 16 : props.size / 2}px`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const invalidateCurrentImage = () => {
|
const invalidateCurrentImage = () => {
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 16,
|
default: 16,
|
||||||
},
|
},
|
||||||
|
currentPageInfo: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const emit = defineEmits(['update:currentPage']);
|
const emit = defineEmits(['update:currentPage']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -39,11 +43,14 @@ const changePage = newPage => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentPageInformation = computed(() => {
|
const currentPageInformation = computed(() => {
|
||||||
return t('PAGINATION_FOOTER.SHOWING', {
|
return t(
|
||||||
startItem: startItem.value,
|
props.currentPageInfo ? props.currentPageInfo : 'PAGINATION_FOOTER.SHOWING',
|
||||||
endItem: endItem.value,
|
{
|
||||||
totalItems: props.totalItems,
|
startItem: startItem.value,
|
||||||
});
|
endItem: endItem.value,
|
||||||
|
totalItems: props.totalItems,
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageInfo = computed(() => {
|
const pageInfo = computed(() => {
|
||||||
@@ -84,7 +91,7 @@ const pageInfo = computed(() => {
|
|||||||
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
|
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
|
||||||
{{ currentPage }}
|
{{ currentPage }}
|
||||||
</span>
|
</span>
|
||||||
<span>{{ pageInfo }}</span>
|
<span class="truncate">{{ pageInfo }}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-chevron-right"
|
icon="i-lucide-chevron-right"
|
||||||
|
|||||||
@@ -197,7 +197,18 @@ const menuItems = computed(() => {
|
|||||||
{
|
{
|
||||||
name: 'All Contacts',
|
name: 'All Contacts',
|
||||||
label: t('SIDEBAR.ALL_CONTACTS'),
|
label: t('SIDEBAR.ALL_CONTACTS'),
|
||||||
to: accountScopedRoute('contacts_dashboard'),
|
to: accountScopedRoute(
|
||||||
|
'contacts_dashboard_index',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
search: undefined,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
activeOn: [
|
||||||
|
'contacts_dashboard_index',
|
||||||
|
'contacts_dashboard_edit_index',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Segments',
|
name: 'Segments',
|
||||||
@@ -206,9 +217,16 @@ const menuItems = computed(() => {
|
|||||||
children: contactCustomViews.value.map(view => ({
|
children: contactCustomViews.value.map(view => ({
|
||||||
name: `${view.name}-${view.id}`,
|
name: `${view.name}-${view.id}`,
|
||||||
label: view.name,
|
label: view.name,
|
||||||
to: accountScopedRoute('contacts_segments_dashboard', {
|
to: accountScopedRoute(
|
||||||
id: view.id,
|
'contacts_dashboard_segments_index',
|
||||||
}),
|
{
|
||||||
|
segmentId: view.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
activeOn: ['contacts_dashboard_segments_index'],
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -222,9 +240,17 @@ const menuItems = computed(() => {
|
|||||||
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
|
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
|
||||||
style: { backgroundColor: label.color },
|
style: { backgroundColor: label.color },
|
||||||
}),
|
}),
|
||||||
to: accountScopedRoute('contacts_labels_dashboard', {
|
to: accountScopedRoute(
|
||||||
label: label.title,
|
'contacts_dashboard_labels_index',
|
||||||
}),
|
{
|
||||||
|
label: label.title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
search: undefined,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
activeOn: ['contacts_dashboard_labels_index'],
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -344,18 +370,6 @@ const menuItems = computed(() => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
activeOn: [
|
|
||||||
'portals_new',
|
|
||||||
'portals_index',
|
|
||||||
'portals_articles_index',
|
|
||||||
'portals_articles_new',
|
|
||||||
'portals_articles_edit',
|
|
||||||
'portals_categories_index',
|
|
||||||
'portals_categories_articles_index',
|
|
||||||
'portals_categories_articles_edit',
|
|
||||||
'portals_locales_index',
|
|
||||||
'portals_settings_index',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
|
|||||||
@@ -66,14 +66,35 @@ const activeChild = computed(() => {
|
|||||||
);
|
);
|
||||||
if (pathSame) return pathSame;
|
if (pathSame) return pathSame;
|
||||||
|
|
||||||
const pathSatrtsWith = navigableChildren.value.find(
|
// Rank the activeOn Prop higher than the path match
|
||||||
child => child.to && route.path.startsWith(resolvePath(child.to))
|
// There will be cases where the path name is the same but the params are different
|
||||||
);
|
// So we need to rank them based on the params
|
||||||
if (pathSatrtsWith) return pathSatrtsWith;
|
// For example, contacts segment list in the sidebar effectively has the same name
|
||||||
|
// But the params are different
|
||||||
return navigableChildren.value.find(child =>
|
const activeOnPages = navigableChildren.value.filter(child =>
|
||||||
child.activeOn?.includes(route.name)
|
child.activeOn?.includes(route.name)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (activeOnPages.length > 0) {
|
||||||
|
const rankedPage = activeOnPages.find(child => {
|
||||||
|
return Object.keys(child.to.params)
|
||||||
|
.map(key => {
|
||||||
|
return String(child.to.params[key]) === String(route.params[key]);
|
||||||
|
})
|
||||||
|
.every(match => match);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there is no ranked page, return the first activeOn page anyway
|
||||||
|
// Since this takes higher precedence over the path match
|
||||||
|
// This is not perfect, ideally we should rank each route based on all the techniques
|
||||||
|
// and then return the highest ranked one
|
||||||
|
// But this is good enough for now
|
||||||
|
return rankedPage ?? activeOnPages[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return navigableChildren.value.find(
|
||||||
|
child => child.to && route.path.startsWith(resolvePath(child.to))
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasActiveChild = computed(() => {
|
const hasActiveChild = computed(() => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const contacts = accountId => ({
|
|||||||
parentNav: 'contacts',
|
parentNav: 'contacts',
|
||||||
routes: [
|
routes: [
|
||||||
'contacts_dashboard',
|
'contacts_dashboard',
|
||||||
'contact_profile_dashboard',
|
'contacts_edit',
|
||||||
'contacts_segments_dashboard',
|
'contacts_segments_dashboard',
|
||||||
'contacts_labels_dashboard',
|
'contacts_labels_dashboard',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -91,10 +91,11 @@ describe('useAccount', () => {
|
|||||||
it('returns an account-scoped route', () => {
|
it('returns an account-scoped route', () => {
|
||||||
const wrapper = mount(createComponent(), mountParams);
|
const wrapper = mount(createComponent(), mountParams);
|
||||||
const { accountScopedRoute } = wrapper.vm;
|
const { accountScopedRoute } = wrapper.vm;
|
||||||
const result = accountScopedRoute('accountDetail', { userId: 456 });
|
const result = accountScopedRoute('accountDetail', { userId: 456 }, {});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
name: 'accountDetail',
|
name: 'accountDetail',
|
||||||
params: { accountId: 123, userId: 456 },
|
params: { accountId: 123, userId: 456 },
|
||||||
|
query: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,11 @@ export function useAccount() {
|
|||||||
return `/app/accounts/${accountId.value}/${url}`;
|
return `/app/accounts/${accountId.value}/${url}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const accountScopedRoute = (name, params) => {
|
const accountScopedRoute = (name, params, query) => {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
params: { accountId: accountId.value, ...params },
|
params: { accountId: accountId.value, ...params },
|
||||||
|
query: { ...query },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -441,9 +441,31 @@
|
|||||||
"ASCENDING": "Ascending",
|
"ASCENDING": "Ascending",
|
||||||
"DESCENDING": "Descending"
|
"DESCENDING": "Descending"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"FILTERS": {
|
||||||
|
"CREATE_SEGMENT": {
|
||||||
|
"TITLE": "Do you want to save this filter?",
|
||||||
|
"CONFIRM": "Save filter",
|
||||||
|
"LABEL": "Name",
|
||||||
|
"PLACEHOLDER": "Enter the name of the filter",
|
||||||
|
"ERROR": "Enter a valid name",
|
||||||
|
"SUCCESS_MESSAGE": "Filter saved successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to save filter. Please try again later."
|
||||||
|
},
|
||||||
|
"DELETE_SEGMENT": {
|
||||||
|
"TITLE": "Confirm Deletion",
|
||||||
|
"DESCRIPTION": "Are you sure you want to delete this filter?",
|
||||||
|
"CONFIRM": "Yes, Delete",
|
||||||
|
"CANCEL": "No, Cancel",
|
||||||
|
"SUCCESS_MESSAGE": "Filter deleted successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to delete filter. Please try again later."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"PAGINATION_FOOTER": {
|
||||||
|
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contacts"
|
||||||
|
},
|
||||||
"CARD": {
|
"CARD": {
|
||||||
"OF": "of",
|
"OF": "of",
|
||||||
"VIEW_DETAILS": "View details",
|
"VIEW_DETAILS": "View details",
|
||||||
@@ -457,10 +479,12 @@
|
|||||||
"PLACEHOLDER": "Enter the last name"
|
"PLACEHOLDER": "Enter the last name"
|
||||||
},
|
},
|
||||||
"EMAIL_ADDRESS": {
|
"EMAIL_ADDRESS": {
|
||||||
"PLACEHOLDER": "Enter the email address"
|
"PLACEHOLDER": "Enter the email address",
|
||||||
|
"DUPLICATE": "This email address is in use for another contact."
|
||||||
},
|
},
|
||||||
"PHONE_NUMBER": {
|
"PHONE_NUMBER": {
|
||||||
"PLACEHOLDER": "Enter the phone number"
|
"PLACEHOLDER": "Enter the phone number",
|
||||||
|
"DUPLICATE": "This phone number is in use for another contact."
|
||||||
},
|
},
|
||||||
"CITY": {
|
"CITY": {
|
||||||
"PLACEHOLDER": "Enter the city name"
|
"PLACEHOLDER": "Enter the city name"
|
||||||
@@ -474,7 +498,10 @@
|
|||||||
"COMPANY_NAME": {
|
"COMPANY_NAME": {
|
||||||
"PLACEHOLDER": "Enter the company name"
|
"PLACEHOLDER": "Enter the company name"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"UPDATE_BUTTON": "Update contact",
|
||||||
|
"SUCCESS_MESSAGE": "Contact updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Unable to update contact. Please try again later."
|
||||||
},
|
},
|
||||||
"SOCIAL_MEDIA": {
|
"SOCIAL_MEDIA": {
|
||||||
"TITLE": "Edit social links",
|
"TITLE": "Edit social links",
|
||||||
@@ -550,6 +577,13 @@
|
|||||||
"SAVE": "Save note",
|
"SAVE": "Save note",
|
||||||
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
|
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"EMPTY_STATE": {
|
||||||
|
"TITLE": "No contacts found in this account",
|
||||||
|
"SUBTITLE": "Start adding new contacts by clicking on the button below",
|
||||||
|
"BUTTON_LABEL": "Add contact",
|
||||||
|
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
|
||||||
|
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default {
|
|||||||
this.filterTypes = [...this.filterTypes, ...filterTypes];
|
this.filterTypes = [...this.filterTypes, ...filterTypes];
|
||||||
this.filterGroups = filterGroups;
|
this.filterGroups = filterGroups;
|
||||||
|
|
||||||
if (this.getAppliedContactFilters.length) {
|
if (this.getAppliedContactFilters.length && !this.isSegmentsView) {
|
||||||
this.appliedFilters = [...this.getAppliedContactFilters];
|
this.appliedFilters = [...this.getAppliedContactFilters];
|
||||||
} else if (!this.isSegmentsView) {
|
} else if (!this.isSegmentsView) {
|
||||||
this.appliedFilters.push({
|
this.appliedFilters.push({
|
||||||
@@ -318,7 +318,7 @@ export default {
|
|||||||
@reset-filter="resetFilter(i, appliedFilters[i])"
|
@reset-filter="resetFilter(i, appliedFilters[i])"
|
||||||
@remove-filter="removeFilter(i)"
|
@remove-filter="removeFilter(i)"
|
||||||
/>
|
/>
|
||||||
<div class="mt-4">
|
<div class="flex items-center gap-2 mt-4">
|
||||||
<woot-button
|
<woot-button
|
||||||
icon="add"
|
icon="add"
|
||||||
color-scheme="success"
|
color-scheme="success"
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export default {
|
|||||||
return this.$store.getters['contacts/getContact'](this.contactId);
|
return this.$store.getters['contacts/getContact'](this.contactId);
|
||||||
},
|
},
|
||||||
backUrl() {
|
backUrl() {
|
||||||
|
if (window.history.state?.back || window.history.length > 1) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
return `/app/accounts/${this.$route.params.accountId}/contacts`;
|
return `/app/accounts/${this.$route.params.accountId}/contacts`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, computed, ref, reactive, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { debounce } from '@chatwoot/utils';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
|
||||||
|
|
||||||
|
import ContactsListLayout from 'dashboard/components-next/Contacts/ContactsListLayout.vue';
|
||||||
|
import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue';
|
||||||
|
import ContactEmptyState from 'dashboard/components-next/Contacts/EmptyState/ContactEmptyState.vue';
|
||||||
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
|
||||||
|
const DEFAULT_SORT_FIELD = 'last_activity_at';
|
||||||
|
const DEBOUNCE_DELAY = 300;
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const { updateUISettings, uiSettings } = useUISettings();
|
||||||
|
|
||||||
|
const contacts = useMapGetter('contacts/getContactsList');
|
||||||
|
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
|
const customViewsUiFlags = useMapGetter('customViews/getUIFlags');
|
||||||
|
const segments = useMapGetter('customViews/getContactCustomViews');
|
||||||
|
const appliedFilters = useMapGetter('contacts/getAppliedContactFilters');
|
||||||
|
const meta = useMapGetter('contacts/getMeta');
|
||||||
|
|
||||||
|
const searchQuery = computed(() => route.query?.search);
|
||||||
|
const searchValue = ref(searchQuery.value || '');
|
||||||
|
const pageNumber = computed(() => Number(route.query?.page) || 1);
|
||||||
|
|
||||||
|
const parseSortSettings = (sortString = '') => {
|
||||||
|
const hasDescending = sortString.startsWith('-');
|
||||||
|
const sortField = hasDescending ? sortString.slice(1) : sortString;
|
||||||
|
return {
|
||||||
|
sort: sortField || DEFAULT_SORT_FIELD,
|
||||||
|
order: hasDescending ? '-' : '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { contacts_sort_by: contactSortBy = '' } = uiSettings.value ?? {};
|
||||||
|
const { sort: initialSort, order: initialOrder } =
|
||||||
|
parseSortSettings(contactSortBy);
|
||||||
|
|
||||||
|
const sortState = reactive({
|
||||||
|
activeSort: initialSort,
|
||||||
|
activeOrdering: initialOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeLabel = computed(() => route.params.label);
|
||||||
|
const activeSegmentId = computed(() => route.params.segmentId);
|
||||||
|
const isFetchingList = computed(
|
||||||
|
() => uiFlags.value.isFetching || customViewsUiFlags.value.isFetching
|
||||||
|
);
|
||||||
|
const currentPage = computed(() => Number(meta.value?.currentPage));
|
||||||
|
const totalItems = computed(() => meta.value?.count);
|
||||||
|
const activeSegment = computed(() => {
|
||||||
|
if (!activeSegmentId.value) return undefined;
|
||||||
|
return segments.value.find(view => view.id === Number(activeSegmentId.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasContacts = computed(() => contacts.value.length > 0);
|
||||||
|
const isContactIndexView = computed(
|
||||||
|
() => route.name === 'contacts_dashboard_index' && pageNumber.value === 1
|
||||||
|
);
|
||||||
|
const hasAppliedFilters = computed(() => {
|
||||||
|
return appliedFilters.value.length > 0;
|
||||||
|
});
|
||||||
|
const showEmptyStateLayout = computed(() => {
|
||||||
|
return (
|
||||||
|
!searchQuery.value &&
|
||||||
|
!hasContacts.value &&
|
||||||
|
isContactIndexView.value &&
|
||||||
|
!hasAppliedFilters.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const showEmptyText = computed(() => {
|
||||||
|
return (
|
||||||
|
(searchQuery.value ||
|
||||||
|
hasAppliedFilters.value ||
|
||||||
|
!isContactIndexView.value) &&
|
||||||
|
!hasContacts.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerTitle = computed(() => {
|
||||||
|
if (searchQuery.value) return t('CONTACTS_LAYOUT.HEADER.SEARCH_TITLE');
|
||||||
|
if (activeSegmentId.value) return activeSegment.value?.name;
|
||||||
|
if (activeLabel.value) return `#${activeLabel.value}`;
|
||||||
|
return t('CONTACTS_LAYOUT.HEADER.TITLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePageParam = (page, search = '') => {
|
||||||
|
const query = {
|
||||||
|
...route.query,
|
||||||
|
page: page.toString(),
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!search) {
|
||||||
|
delete query.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace({ query });
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSortAttr = () =>
|
||||||
|
`${sortState.activeOrdering}${sortState.activeSort}`;
|
||||||
|
|
||||||
|
const getCommonFetchParams = (page = 1) => ({
|
||||||
|
page,
|
||||||
|
sortAttr: buildSortAttr(),
|
||||||
|
label: activeLabel.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchContacts = async (page = 1) => {
|
||||||
|
await store.dispatch('contacts/get', getCommonFetchParams(page));
|
||||||
|
updatePageParam(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
|
||||||
|
if (!activeSegmentId.value && !hasAppliedFilters.value) return;
|
||||||
|
await store.dispatch('contacts/filter', {
|
||||||
|
...getCommonFetchParams(page),
|
||||||
|
queryPayload: payload,
|
||||||
|
});
|
||||||
|
updatePageParam(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchContacts = debounce(async (value, page = 1) => {
|
||||||
|
searchValue.value = value;
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
updatePageParam(page);
|
||||||
|
await fetchContacts(page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePageParam(page, value);
|
||||||
|
await store.dispatch('contacts/search', {
|
||||||
|
...getCommonFetchParams(page),
|
||||||
|
search: encodeURIComponent(value),
|
||||||
|
});
|
||||||
|
}, DEBOUNCE_DELAY);
|
||||||
|
|
||||||
|
const fetchContactsBasedOnContext = async page => {
|
||||||
|
updatePageParam(page, searchValue.value);
|
||||||
|
if (isFetchingList.value) return;
|
||||||
|
if (searchQuery.value) {
|
||||||
|
await searchContacts(searchQuery.value, page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reset the search value when we change the view
|
||||||
|
searchValue.value = '';
|
||||||
|
// If there are applied filters or active segment with query
|
||||||
|
if (
|
||||||
|
(hasAppliedFilters.value || activeSegment.value?.query) &&
|
||||||
|
!activeLabel.value
|
||||||
|
) {
|
||||||
|
const queryPayload =
|
||||||
|
activeSegment.value?.query || filterQueryGenerator(appliedFilters.value);
|
||||||
|
await fetchSavedOrAppliedFilteredContact(queryPayload, page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Default case: fetch regular contacts + label
|
||||||
|
await fetchContacts(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSort = async ({ sort, order }) => {
|
||||||
|
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
|
||||||
|
|
||||||
|
await updateUISettings({
|
||||||
|
contacts_sort_by: buildSortAttr(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (searchQuery.value) {
|
||||||
|
await searchContacts(searchValue.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await (activeSegmentId.value || hasAppliedFilters.value
|
||||||
|
? fetchSavedOrAppliedFilteredContact(
|
||||||
|
activeSegmentId.value
|
||||||
|
? activeSegment.value?.query
|
||||||
|
: filterQueryGenerator(appliedFilters.value)
|
||||||
|
)
|
||||||
|
: fetchContacts());
|
||||||
|
};
|
||||||
|
|
||||||
|
const createContact = async contact => {
|
||||||
|
await store.dispatch('contacts/create', contact);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => uiSettings.value?.contacts_sort_by,
|
||||||
|
newSortBy => {
|
||||||
|
if (newSortBy) {
|
||||||
|
const { sort, order } = parseSortSettings(newSortBy);
|
||||||
|
sortState.activeSort = sort;
|
||||||
|
sortState.activeOrdering = order;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[activeLabel, activeSegment],
|
||||||
|
() => {
|
||||||
|
fetchContactsBasedOnContext(pageNumber.value);
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(searchQuery, value => {
|
||||||
|
if (isFetchingList.value) return;
|
||||||
|
searchValue.value = value || '';
|
||||||
|
// Reset the view if there is search query when we click on the sidebar group
|
||||||
|
if (value === undefined) {
|
||||||
|
fetchContacts();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!activeSegmentId.value) {
|
||||||
|
if (searchQuery.value) {
|
||||||
|
await searchContacts(searchQuery.value, pageNumber.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchContacts(pageNumber.value);
|
||||||
|
} else if (activeSegment.value && activeSegmentId.value) {
|
||||||
|
await fetchSavedOrAppliedFilteredContact(
|
||||||
|
activeSegment.value.query,
|
||||||
|
pageNumber.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
|
||||||
|
>
|
||||||
|
<ContactsListLayout
|
||||||
|
:search-value="searchValue"
|
||||||
|
:header-title="headerTitle"
|
||||||
|
:current-page="currentPage"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:show-pagination-footer="!isFetchingList && hasContacts"
|
||||||
|
:active-sort="sortState.activeSort"
|
||||||
|
:active-ordering="sortState.activeOrdering"
|
||||||
|
:active-segment="activeSegment"
|
||||||
|
:segments-id="activeSegmentId"
|
||||||
|
:has-applied-filters="hasAppliedFilters"
|
||||||
|
@update:current-page="fetchContactsBasedOnContext"
|
||||||
|
@search="searchContacts"
|
||||||
|
@update:sort="handleSort"
|
||||||
|
@apply-filter="fetchSavedOrAppliedFilteredContact"
|
||||||
|
@clear-filters="fetchContacts"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isFetchingList"
|
||||||
|
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||||
|
>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<ContactEmptyState
|
||||||
|
v-if="showEmptyStateLayout"
|
||||||
|
class="pt-14"
|
||||||
|
:title="t('CONTACTS_LAYOUT.EMPTY_STATE.TITLE')"
|
||||||
|
:subtitle="t('CONTACTS_LAYOUT.EMPTY_STATE.SUBTITLE')"
|
||||||
|
:button-label="t('CONTACTS_LAYOUT.EMPTY_STATE.BUTTON_LABEL')"
|
||||||
|
@create="createContact"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="showEmptyText"
|
||||||
|
class="flex items-center justify-center py-10"
|
||||||
|
>
|
||||||
|
<span class="text-base text-n-slate-11">
|
||||||
|
{{
|
||||||
|
searchQuery || !hasAppliedFilters
|
||||||
|
? t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE')
|
||||||
|
: t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContactsList v-else :contacts="contacts" />
|
||||||
|
</template>
|
||||||
|
</ContactsListLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,42 +1,38 @@
|
|||||||
/* eslint arrow-body-style: 0 */
|
/* eslint arrow-body-style: 0 */
|
||||||
import { frontendURL } from '../../../helper/URLHelper';
|
import { frontendURL } from '../../../helper/URLHelper';
|
||||||
import ContactsView from './components/ContactsView.vue';
|
import ContactsIndex from './pages/ContactsIndex.vue';
|
||||||
import ContactManageView from './pages/ContactManageView.vue';
|
import ContactManageView from './pages/ContactManageView.vue';
|
||||||
|
|
||||||
export const routes = [
|
export const routes = [
|
||||||
{
|
{
|
||||||
path: frontendURL('accounts/:accountId/contacts'),
|
path: frontendURL('accounts/:accountId/contacts'),
|
||||||
name: 'contacts_dashboard',
|
component: ContactsIndex,
|
||||||
|
name: 'contacts_dashboard_index',
|
||||||
meta: {
|
meta: {
|
||||||
permissions: ['administrator', 'agent', 'contact_manage'],
|
permissions: ['administrator', 'agent', 'contact_manage'],
|
||||||
},
|
},
|
||||||
component: ContactsView,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: frontendURL('accounts/:accountId/contacts/custom_view/:id'),
|
|
||||||
name: 'contacts_segments_dashboard',
|
|
||||||
meta: {
|
|
||||||
permissions: ['administrator', 'agent', 'contact_manage'],
|
|
||||||
},
|
|
||||||
component: ContactsView,
|
|
||||||
props: route => {
|
|
||||||
return { segmentsId: route.params.id };
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: frontendURL('accounts/:accountId/labels/:label/contacts'),
|
path: frontendURL('accounts/:accountId/contacts/segments/:segmentId'),
|
||||||
name: 'contacts_labels_dashboard',
|
component: ContactsIndex,
|
||||||
|
name: 'contacts_dashboard_segments_index',
|
||||||
meta: {
|
meta: {
|
||||||
permissions: ['administrator', 'agent', 'contact_manage'],
|
permissions: ['administrator', 'agent', 'contact_manage'],
|
||||||
},
|
},
|
||||||
component: ContactsView,
|
},
|
||||||
props: route => {
|
|
||||||
return { label: route.params.label };
|
{
|
||||||
|
path: frontendURL('accounts/:accountId/contacts/labels/:label'),
|
||||||
|
component: ContactsIndex,
|
||||||
|
name: 'contacts_dashboard_labels_index',
|
||||||
|
meta: {
|
||||||
|
permissions: ['administrator', 'agent', 'contact_manage'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: frontendURL('accounts/:accountId/contacts/:contactId'),
|
path: frontendURL('accounts/:accountId/contacts/:contactId'),
|
||||||
name: 'contact_profile_dashboard',
|
name: 'contacts_edit',
|
||||||
meta: {
|
meta: {
|
||||||
permissions: ['administrator', 'agent', 'contact_manage'],
|
permissions: ['administrator', 'agent', 'contact_manage'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
} from 'shared/helpers/CustomErrors';
|
} from 'shared/helpers/CustomErrors';
|
||||||
import types from '../../mutation-types';
|
import types from '../../mutation-types';
|
||||||
import ContactAPI from '../../../api/contacts';
|
import ContactAPI from '../../../api/contacts';
|
||||||
|
import decamelizeKeys from 'decamelize-keys';
|
||||||
import AccountActionsAPI from '../../../api/accountActions';
|
import AccountActionsAPI from '../../../api/accountActions';
|
||||||
import AnalyticsHelper from '../../../helper/AnalyticsHelper';
|
import AnalyticsHelper from '../../../helper/AnalyticsHelper';
|
||||||
import { CONTACTS_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
import { CONTACTS_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||||
@@ -90,11 +91,16 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
update: async ({ commit }, { id, isFormData = false, ...contactParams }) => {
|
update: async ({ commit }, { id, isFormData = false, ...contactParams }) => {
|
||||||
|
const decamelizedContactParams = decamelizeKeys(contactParams, {
|
||||||
|
deep: true,
|
||||||
|
});
|
||||||
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: true });
|
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: true });
|
||||||
try {
|
try {
|
||||||
const response = await ContactAPI.update(
|
const response = await ContactAPI.update(
|
||||||
id,
|
id,
|
||||||
isFormData ? buildContactFormData(contactParams) : contactParams
|
isFormData
|
||||||
|
? buildContactFormData(decamelizedContactParams)
|
||||||
|
: decamelizedContactParams
|
||||||
);
|
);
|
||||||
commit(types.EDIT_CONTACT, response.data.payload);
|
commit(types.EDIT_CONTACT, response.data.payload);
|
||||||
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false });
|
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false });
|
||||||
@@ -109,10 +115,15 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
create: async ({ commit }, { isFormData = false, ...contactParams }) => {
|
create: async ({ commit }, { isFormData = false, ...contactParams }) => {
|
||||||
|
const decamelizedContactParams = decamelizeKeys(contactParams, {
|
||||||
|
deep: true,
|
||||||
|
});
|
||||||
commit(types.SET_CONTACT_UI_FLAG, { isCreating: true });
|
commit(types.SET_CONTACT_UI_FLAG, { isCreating: true });
|
||||||
try {
|
try {
|
||||||
const response = await ContactAPI.create(
|
const response = await ContactAPI.create(
|
||||||
isFormData ? buildContactFormData(contactParams) : contactParams
|
isFormData
|
||||||
|
? buildContactFormData(decamelizedContactParams)
|
||||||
|
: decamelizedContactParams
|
||||||
);
|
);
|
||||||
|
|
||||||
AnalyticsHelper.track(CONTACTS_EVENTS.CREATE_CONTACT);
|
AnalyticsHelper.track(CONTACTS_EVENTS.CREATE_CONTACT);
|
||||||
@@ -126,12 +137,12 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
import: async ({ commit }, file) => {
|
import: async ({ commit }, file) => {
|
||||||
commit(types.SET_CONTACT_UI_FLAG, { isCreating: true });
|
commit(types.SET_CONTACT_UI_FLAG, { isImporting: true });
|
||||||
try {
|
try {
|
||||||
await ContactAPI.importContacts(file);
|
await ContactAPI.importContacts(file);
|
||||||
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
|
commit(types.SET_CONTACT_UI_FLAG, { isImporting: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
|
commit(types.SET_CONTACT_UI_FLAG, { isImporting: false });
|
||||||
if (error.response?.data?.message) {
|
if (error.response?.data?.message) {
|
||||||
throw new ExceptionWithMessage(error.response.data.message);
|
throw new ExceptionWithMessage(error.response.data.message);
|
||||||
}
|
}
|
||||||
@@ -139,12 +150,13 @@ export const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
export: async ({ commit }, { payload, label }) => {
|
export: async ({ commit }, { payload, label }) => {
|
||||||
|
commit(types.SET_CONTACT_UI_FLAG, { isExporting: true });
|
||||||
try {
|
try {
|
||||||
await ContactAPI.exportContacts({ payload, label });
|
await ContactAPI.exportContacts({ payload, label });
|
||||||
|
|
||||||
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
|
commit(types.SET_CONTACT_UI_FLAG, { isExporting: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
|
commit(types.SET_CONTACT_UI_FLAG, { isExporting: false });
|
||||||
if (error.response?.data?.message) {
|
if (error.response?.data?.message) {
|
||||||
throw new Error(error.response.data.message);
|
throw new Error(error.response.data.message);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
getContacts($state) {
|
getContacts($state) {
|
||||||
return $state.sortOrder.map(contactId => $state.records[contactId]);
|
return $state.sortOrder.map(contactId => $state.records[contactId]);
|
||||||
},
|
},
|
||||||
|
getContactsList($state) {
|
||||||
|
const contacts = $state.sortOrder.map(
|
||||||
|
contactId => $state.records[contactId]
|
||||||
|
);
|
||||||
|
return camelcaseKeys(contacts, { deep: true });
|
||||||
|
},
|
||||||
getUIFlags($state) {
|
getUIFlags($state) {
|
||||||
return $state.uiFlags;
|
return $state.uiFlags;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const state = {
|
|||||||
isUpdating: false,
|
isUpdating: false,
|
||||||
isMerging: false,
|
isMerging: false,
|
||||||
isDeleting: false,
|
isDeleting: false,
|
||||||
|
isExporting: false,
|
||||||
|
isImporting: false,
|
||||||
},
|
},
|
||||||
sortOrder: [],
|
sortOrder: [],
|
||||||
appliedFilters: [],
|
appliedFilters: [],
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
"@chatwoot/captain": "0.0.3-alpha.4",
|
"@chatwoot/captain": "0.0.3-alpha.4",
|
||||||
"@chatwoot/ninja-keys": "1.2.3",
|
"@chatwoot/ninja-keys": "1.2.3",
|
||||||
"@chatwoot/prosemirror-schema": "1.1.1-next",
|
"@chatwoot/prosemirror-schema": "1.1.1-next",
|
||||||
"@chatwoot/utils": "^0.0.25",
|
"@chatwoot/utils": "^0.0.30",
|
||||||
"@formkit/core": "^1.6.7",
|
"@formkit/core": "^1.6.7",
|
||||||
"@formkit/vue": "^1.6.7",
|
"@formkit/vue": "^1.6.7",
|
||||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
"countries-and-timezones": "^3.6.0",
|
"countries-and-timezones": "^3.6.0",
|
||||||
"date-fns": "2.21.1",
|
"date-fns": "2.21.1",
|
||||||
"date-fns-tz": "^1.3.3",
|
"date-fns-tz": "^1.3.3",
|
||||||
|
"decamelize-keys": "^2.0.1",
|
||||||
"dompurify": "3.1.6",
|
"dompurify": "3.1.6",
|
||||||
"floating-vue": "^5.2.2",
|
"floating-vue": "^5.2.2",
|
||||||
"highlight.js": "^11.10.0",
|
"highlight.js": "^11.10.0",
|
||||||
|
|||||||
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
@@ -26,8 +26,8 @@ importers:
|
|||||||
specifier: 1.1.1-next
|
specifier: 1.1.1-next
|
||||||
version: 1.1.1-next
|
version: 1.1.1-next
|
||||||
'@chatwoot/utils':
|
'@chatwoot/utils':
|
||||||
specifier: ^0.0.25
|
specifier: ^0.0.30
|
||||||
version: 0.0.25
|
version: 0.0.30
|
||||||
'@formkit/core':
|
'@formkit/core':
|
||||||
specifier: ^1.6.7
|
specifier: ^1.6.7
|
||||||
version: 1.6.7
|
version: 1.6.7
|
||||||
@@ -118,6 +118,9 @@ importers:
|
|||||||
date-fns-tz:
|
date-fns-tz:
|
||||||
specifier: ^1.3.3
|
specifier: ^1.3.3
|
||||||
version: 1.3.8(date-fns@2.21.1)
|
version: 1.3.8(date-fns@2.21.1)
|
||||||
|
decamelize-keys:
|
||||||
|
specifier: ^2.0.1
|
||||||
|
version: 2.0.1
|
||||||
dompurify:
|
dompurify:
|
||||||
specifier: 3.1.6
|
specifier: 3.1.6
|
||||||
version: 3.1.6
|
version: 3.1.6
|
||||||
@@ -401,8 +404,8 @@ packages:
|
|||||||
'@chatwoot/prosemirror-schema@1.1.1-next':
|
'@chatwoot/prosemirror-schema@1.1.1-next':
|
||||||
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
|
resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==}
|
||||||
|
|
||||||
'@chatwoot/utils@0.0.25':
|
'@chatwoot/utils@0.0.30':
|
||||||
resolution: {integrity: sha512-2bGfRewHu0Bra47vHwJa3SdZ0VqXK/2q2ampcc6TT3z8ojSfZqpfMdb+1RKJ/Q1tQEYn/0rJTdWtLDRjePF92A==}
|
resolution: {integrity: sha512-UfKn2GUV/9PF7zoj17dAoyx5RZkJihjjclhWTtG0SHRfRizKS/pg0SpXSt9ToAEDeNeRtqmD7RrVMUaso8TZxw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
'@codemirror/commands@6.7.0':
|
'@codemirror/commands@6.7.0':
|
||||||
@@ -2437,6 +2440,14 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decamelize-keys@2.0.1:
|
||||||
|
resolution: {integrity: sha512-nrNeSCtU2gV3Apcmn/EZ+aR20zKDuNDStV67jPiupokD3sOAFeMzslLMCFdKv1sPqzwoe5ZUhsSW9IAVgKSL/Q==}
|
||||||
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
|
decamelize@6.0.0:
|
||||||
|
resolution: {integrity: sha512-Fv96DCsdOgB6mdGl67MT5JaTNKRzrzill5OH5s8bjYJXVlcXyPYGyPsUkWyGV5p1TXI5esYIYMMeDJL0hEIwaA==}
|
||||||
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
decimal.js@10.4.3:
|
decimal.js@10.4.3:
|
||||||
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
|
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
|
||||||
|
|
||||||
@@ -4676,6 +4687,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==}
|
resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
type-fest@3.13.1:
|
||||||
|
resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==}
|
||||||
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
type-fest@4.26.1:
|
type-fest@4.26.1:
|
||||||
resolution: {integrity: sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==}
|
resolution: {integrity: sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -5235,7 +5250,7 @@ snapshots:
|
|||||||
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
|
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
|
||||||
prosemirror-view: 1.34.1
|
prosemirror-view: 1.34.1
|
||||||
|
|
||||||
'@chatwoot/utils@0.0.25':
|
'@chatwoot/utils@0.0.30':
|
||||||
dependencies:
|
dependencies:
|
||||||
date-fns: 2.29.3
|
date-fns: 2.29.3
|
||||||
|
|
||||||
@@ -7513,6 +7528,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decamelize-keys@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
decamelize: 6.0.0
|
||||||
|
map-obj: 4.3.0
|
||||||
|
quick-lru: 6.1.2
|
||||||
|
type-fest: 3.13.1
|
||||||
|
|
||||||
|
decamelize@6.0.0: {}
|
||||||
|
|
||||||
decimal.js@10.4.3: {}
|
decimal.js@10.4.3: {}
|
||||||
|
|
||||||
deep-eql@5.0.2: {}
|
deep-eql@5.0.2: {}
|
||||||
@@ -10110,6 +10134,8 @@ snapshots:
|
|||||||
|
|
||||||
type-fest@1.4.0: {}
|
type-fest@1.4.0: {}
|
||||||
|
|
||||||
|
type-fest@3.13.1: {}
|
||||||
|
|
||||||
type-fest@4.26.1: {}
|
type-fest@4.26.1: {}
|
||||||
|
|
||||||
typed-array-buffer@1.0.0:
|
typed-array-buffer@1.0.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user