feat: Add contact header components (#10498)
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import ContactSortMenu from './components/ContactSortMenu.vue';
|
||||||
|
import ContactMoreActions from './components/ContactMoreActions.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
showSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
searchValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
buttonLabel: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
activeSort: {
|
||||||
|
type: String,
|
||||||
|
default: 'last_activity_at',
|
||||||
|
},
|
||||||
|
activeOrdering: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'search',
|
||||||
|
'filter',
|
||||||
|
'update:sort',
|
||||||
|
'message',
|
||||||
|
'add',
|
||||||
|
'import',
|
||||||
|
'export',
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="sticky top-0 z-10 px-6 xl:px-0">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between w-full h-20 gap-2 mx-auto max-w-[900px]"
|
||||||
|
>
|
||||||
|
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||||
|
{{ headerTitle }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center flex-shrink-0 gap-4">
|
||||||
|
<div v-if="showSearch" class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
:model-value="searchValue"
|
||||||
|
type="search"
|
||||||
|
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
|
||||||
|
:custom-input-class="[
|
||||||
|
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
|
||||||
|
]"
|
||||||
|
@input="emit('search', $event.target.value)"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-search"
|
||||||
|
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Input>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-list-filter"
|
||||||
|
color="slate"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
@click="emit('filter')"
|
||||||
|
/>
|
||||||
|
<ContactSortMenu
|
||||||
|
:active-sort="activeSort"
|
||||||
|
:active-ordering="activeOrdering"
|
||||||
|
@update:sort="emit('update:sort', $event)"
|
||||||
|
/>
|
||||||
|
<ContactMoreActions
|
||||||
|
@add="emit('add')"
|
||||||
|
@import="emit('import')"
|
||||||
|
@export="emit('export')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-px h-4 bg-n-strong" />
|
||||||
|
<Button :label="buttonLabel" size="sm" @click="emit('message')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['add', 'import', 'export']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const contactMenuItems = [
|
||||||
|
{
|
||||||
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
|
||||||
|
action: 'add',
|
||||||
|
value: 'add',
|
||||||
|
icon: 'i-lucide-plus',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
|
||||||
|
action: 'export',
|
||||||
|
value: 'export',
|
||||||
|
icon: 'i-lucide-upload',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
|
||||||
|
action: 'import',
|
||||||
|
value: 'import',
|
||||||
|
icon: 'i-lucide-download',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const showActionsDropdown = ref(false);
|
||||||
|
|
||||||
|
const handleContactAction = ({ action }) => {
|
||||||
|
if (action === 'add') {
|
||||||
|
emit('add');
|
||||||
|
} else if (action === 'import') {
|
||||||
|
emit('import');
|
||||||
|
} else if (action === 'export') {
|
||||||
|
emit('export');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-on-clickaway="() => (showActionsDropdown = false)" class="relative">
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-ellipsis-vertical"
|
||||||
|
color="slate"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:class="showActionsDropdown ? 'bg-n-alpha-2' : ''"
|
||||||
|
@click="showActionsDropdown = !showActionsDropdown"
|
||||||
|
/>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showActionsDropdown"
|
||||||
|
:menu-items="contactMenuItems"
|
||||||
|
class="right-0 mt-1 w-52 top-full"
|
||||||
|
@action="handleContactAction($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, toRef } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
activeSort: {
|
||||||
|
type: String,
|
||||||
|
default: 'last_activity_at',
|
||||||
|
},
|
||||||
|
activeOrdering: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:sort']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const isMenuOpen = ref(false);
|
||||||
|
|
||||||
|
const sortMenus = [
|
||||||
|
{
|
||||||
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.NAME'),
|
||||||
|
value: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
|
||||||
|
value: 'email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
|
||||||
|
value: 'phone_number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
|
||||||
|
value: 'company_name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COUNTRY'),
|
||||||
|
value: 'country',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CITY'),
|
||||||
|
value: 'city',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.LAST_ACTIVITY'),
|
||||||
|
value: 'last_activity_at',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CREATED_AT'),
|
||||||
|
value: 'created_at',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const orderingMenus = [
|
||||||
|
{
|
||||||
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.ASCENDING'),
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.DESCENDING'),
|
||||||
|
value: '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Converted the props to refs for better reactivity
|
||||||
|
const activeSort = toRef(props, 'activeSort');
|
||||||
|
|
||||||
|
const activeOrdering = toRef(props, 'activeOrdering');
|
||||||
|
|
||||||
|
const activeSortLabel = computed(() => {
|
||||||
|
const selectedMenu = sortMenus.find(menu => menu.value === activeSort.value);
|
||||||
|
return (
|
||||||
|
selectedMenu?.label || t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeOrderingLabel = computed(() => {
|
||||||
|
const selectedMenu = orderingMenus.find(
|
||||||
|
menu => menu.value === activeOrdering.value
|
||||||
|
);
|
||||||
|
return selectedMenu?.label || t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL');
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSortChange = value => {
|
||||||
|
emit('update:sort', { sort: value, order: props.activeOrdering });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOrderChange = value => {
|
||||||
|
emit('update:sort', { sort: props.activeSort, order: value });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-arrow-down-up"
|
||||||
|
color="slate"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
:class="isMenuOpen ? 'bg-n-alpha-2' : ''"
|
||||||
|
@click="isMenuOpen = !isMenuOpen"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="isMenuOpen"
|
||||||
|
v-on-clickaway="() => (isMenuOpen = false)"
|
||||||
|
class="absolute top-full mt-1 right-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm text-n-slate-12">
|
||||||
|
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL') }}
|
||||||
|
</span>
|
||||||
|
<SelectMenu
|
||||||
|
:model-value="activeSort"
|
||||||
|
:options="sortMenus"
|
||||||
|
:label="activeSortLabel"
|
||||||
|
@update:model-value="handleSortChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm text-n-slate-12">
|
||||||
|
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL') }}
|
||||||
|
</span>
|
||||||
|
<SelectMenu
|
||||||
|
:model-value="activeOrdering"
|
||||||
|
:options="orderingMenus"
|
||||||
|
:label="activeOrderingLabel"
|
||||||
|
@update:model-value="handleOrderChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import ContactHeader from '../ContactHeader.vue';
|
||||||
|
|
||||||
|
// Base state controls
|
||||||
|
const searchValue = ref('');
|
||||||
|
const activeSort = ref('last_activity_at');
|
||||||
|
const activeOrdering = ref('');
|
||||||
|
|
||||||
|
const onSearch = value => {
|
||||||
|
searchValue.value = value;
|
||||||
|
console.log('🔍 Search:', value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSort = ({ sort, order }) => {
|
||||||
|
activeSort.value = sort;
|
||||||
|
activeOrdering.value = order;
|
||||||
|
console.log('🔄 Sort changed:', { sort, order });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFilter = () => {
|
||||||
|
console.log('🏷️ Filter clicked');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMessage = () => {
|
||||||
|
console.log('💬 Message clicked');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAdd = () => {
|
||||||
|
console.log('➕ Add contact clicked');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onImport = () => {
|
||||||
|
console.log('📥 Import contacts clicked');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onExport = () => {
|
||||||
|
console.log('📤 Export contacts clicked');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story
|
||||||
|
title="Components/Contacts/ContactHeader"
|
||||||
|
:layout="{ type: 'grid', width: '900px' }"
|
||||||
|
>
|
||||||
|
<Variant title="Default">
|
||||||
|
<div class="w-full h-[400px]">
|
||||||
|
<ContactHeader
|
||||||
|
header-title="Contacts"
|
||||||
|
button-label="Message"
|
||||||
|
:search-value="searchValue"
|
||||||
|
:active-sort="activeSort"
|
||||||
|
:active-ordering="activeOrdering"
|
||||||
|
@search="onSearch"
|
||||||
|
@filter="onFilter"
|
||||||
|
@update:sort="onSort"
|
||||||
|
@message="onMessage"
|
||||||
|
@add="onAdd"
|
||||||
|
@import="onImport"
|
||||||
|
@export="onExport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="Empty State">
|
||||||
|
<div class="w-full">
|
||||||
|
<ContactHeader
|
||||||
|
:show-search="false"
|
||||||
|
header-title="Contacts"
|
||||||
|
button-label="Message"
|
||||||
|
:search-value="searchValue"
|
||||||
|
:active-sort="activeSort"
|
||||||
|
:active-ordering="activeOrdering"
|
||||||
|
@search="onSearch"
|
||||||
|
@filter="onFilter"
|
||||||
|
@update:sort="onSort"
|
||||||
|
@message="onMessage"
|
||||||
|
@add="onAdd"
|
||||||
|
@import="onImport"
|
||||||
|
@export="onExport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
|
||||||
|
<Variant title="Segment View">
|
||||||
|
<div class="w-full">
|
||||||
|
<ContactHeader
|
||||||
|
:show-search="false"
|
||||||
|
header-title="Segment: VIP Customers"
|
||||||
|
button-label="Message"
|
||||||
|
:search-value="searchValue"
|
||||||
|
:active-sort="activeSort"
|
||||||
|
:active-ordering="activeOrdering"
|
||||||
|
@search="onSearch"
|
||||||
|
@filter="onFilter"
|
||||||
|
@update:sort="onSort"
|
||||||
|
@message="onMessage"
|
||||||
|
@add="onAdd"
|
||||||
|
@import="onImport"
|
||||||
|
@export="onExport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -388,6 +388,62 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"CONTACTS_LAYOUT": {
|
"CONTACTS_LAYOUT": {
|
||||||
|
"HEADER": {
|
||||||
|
"TITLE": "Contacts",
|
||||||
|
"SEARCH_TITLE": "Search contacts",
|
||||||
|
"SEARCH_PLACEHOLDER": "Search...",
|
||||||
|
"MESSAGE_BUTTON": "Message",
|
||||||
|
"BREADCRUMB": {
|
||||||
|
"CONTACTS": "Contacts"
|
||||||
|
},
|
||||||
|
"ACTIONS": {
|
||||||
|
"CONTACT_CREATION": {
|
||||||
|
"ADD_CONTACT": "Add contact",
|
||||||
|
"EXPORT_CONTACT": "Export contacts",
|
||||||
|
"IMPORT_CONTACT": "Import contacts",
|
||||||
|
"SAVE_CONTACT": "Save contact"
|
||||||
|
},
|
||||||
|
"IMPORT_CONTACT": {
|
||||||
|
"TITLE": "Import contacts",
|
||||||
|
"DESCRIPTION": "Import contacts through a CSV file.",
|
||||||
|
"DOWNLOAD_LABEL": "Download a sample csv.",
|
||||||
|
"LABEL": "CSV File:",
|
||||||
|
"CHOOSE_FILE": "Choose file",
|
||||||
|
"CHANGE": "Change",
|
||||||
|
"CANCEL": "Cancel",
|
||||||
|
"IMPORT": "Import",
|
||||||
|
"SUCCESS_MESSAGE": "You will be notified via email when the import is complete.",
|
||||||
|
"ERROR_MESSAGE": "There was an error, please try again"
|
||||||
|
},
|
||||||
|
"EXPORT_CONTACT": {
|
||||||
|
"TITLE": "Export contacts",
|
||||||
|
"DESCRIPTION": "Quickly export a csv file with comprehensive details of your contacts",
|
||||||
|
"CONFIRM": "Export",
|
||||||
|
"SUCCESS_MESSAGE": "Export is in progress. You will be notified on email when the export file is ready to download.",
|
||||||
|
"ERROR_MESSAGE": "There was an error, please try again"
|
||||||
|
},
|
||||||
|
"SORT_BY": {
|
||||||
|
"LABEL": "Sort by",
|
||||||
|
"OPTIONS": {
|
||||||
|
"NAME": "Name",
|
||||||
|
"EMAIL": "Email",
|
||||||
|
"PHONE_NUMBER": "Phone number",
|
||||||
|
"COMPANY": "Company",
|
||||||
|
"COUNTRY": "Country",
|
||||||
|
"CITY": "City",
|
||||||
|
"LAST_ACTIVITY": "Last activity",
|
||||||
|
"CREATED_AT": "Created at"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ORDER": {
|
||||||
|
"LABEL": "Ordering",
|
||||||
|
"OPTIONS": {
|
||||||
|
"ASCENDING": "Ascending",
|
||||||
|
"DESCENDING": "Descending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"CARD": {
|
"CARD": {
|
||||||
"OF": "of",
|
"OF": "of",
|
||||||
"VIEW_DETAILS": "View details",
|
"VIEW_DETAILS": "View details",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { vResizeObserver } from '@vueuse/components';
|
|||||||
import store from 'dashboard/store';
|
import store from 'dashboard/store';
|
||||||
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||||
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
|
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
|
||||||
|
import { directive as onClickaway } from 'vue3-click-away';
|
||||||
|
|
||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
legacy: false, // https://github.com/intlify/vue-i18n/issues/1902
|
legacy: false, // https://github.com/intlify/vue-i18n/issues/1902
|
||||||
@@ -18,4 +19,5 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
|
|||||||
app.use(i18n);
|
app.use(i18n);
|
||||||
app.directive('resize', vResizeObserver);
|
app.directive('resize', vResizeObserver);
|
||||||
app.use(VueDOMPurifyHTML, domPurifyConfig);
|
app.use(VueDOMPurifyHTML, domPurifyConfig);
|
||||||
|
app.directive('on-clickaway', onClickaway);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user