Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,125 @@
<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';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({
showSearch: { type: Boolean, default: true },
searchValue: { type: String, default: '' },
headerTitle: { type: String, required: true },
buttonLabel: { type: String, default: '' },
activeSort: { type: String, default: 'last_activity_at' },
activeOrdering: { type: String, default: '' },
isSegmentsView: { type: Boolean, default: false },
hasActiveFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
});
const emit = defineEmits([
'search',
'filter',
'update:sort',
'add',
'import',
'export',
'createSegment',
'deleteSegment',
]);
</script>
<template>
<header class="sticky top-0 z-10 px-6">
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2 w-full">
<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',
]"
class="w-full"
@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 flex-shrink-0 gap-4">
<div class="flex items-center gap-2">
<div v-if="!isLabelView && !isActiveView" class="relative">
<Button
id="toggleContactsFilterButton"
:icon="
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
"
color="slate"
size="sm"
class="relative w-8"
variant="ghost"
@click="emit('filter')"
>
<div
v-if="hasActiveFilters && !isSegmentsView"
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
/>
</Button>
<slot name="filter" />
</div>
<Button
v-if="
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
icon="i-lucide-save"
color="slate"
size="sm"
variant="ghost"
@click="emit('createSegment')"
/>
<Button
v-if="isSegmentsView && !isLabelView && !isActiveView"
icon="i-lucide-trash"
color="slate"
size="sm"
variant="ghost"
@click="emit('deleteSegment')"
/>
<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" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</div>
</header>
</template>

View File

@@ -0,0 +1,317 @@
<script setup>
import { ref, computed, unref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } 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 {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import countries from 'shared/constants/countries';
import {
useCamelCase,
useSnakeCase,
} from 'dashboard/composables/useTransformKeys';
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 ContactsFilter from 'dashboard/components-next/filter/ContactsFilter.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 },
isLabelView: { type: Boolean, default: false },
isActiveView: { 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 appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
const contactAttributes = useMapGetter('attributes/getContactAttributes');
const labels = useMapGetter('labels/getLabels');
const hasActiveSegments = computed(
() => props.activeSegment && props.segmentsId !== 0
);
const activeSegmentName = computed(() => props.activeSegment?.name);
const openCreateNewContactDialog = () => {
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 => {
try {
await store.dispatch('contacts/create', contact);
createNewContactDialogRef.value?.onSuccess();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SUCCESS_MESSAGE')
);
} catch (error) {
const i18nPrefix = 'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION';
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 onImport = async file => {
try {
await store.dispatch('contacts/import', file);
contactImportDialogRef.value?.dialogRef.close();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.SUCCESS_MESSAGE')
);
useTrack(CONTACTS_EVENTS.IMPORT_SUCCESS);
} catch (error) {
useAlert(
error.message ??
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.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,
};
const response = await store.dispatch('customViews/create', payloadData);
createSegmentDialogRef.value?.dialogRef.close();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.SUCCESS_MESSAGE')
);
const segmentId = response?.data?.id;
if (!segmentId) return;
// Navigate to the created segment
router.push({
name: 'contacts_dashboard_segments_index',
params: { segmentId },
query: { page: 1 },
});
} 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 () => {
emit('clearFilters');
};
const onApplyFilter = async payload => {
payload = useSnakeCase(payload);
segmentsQuery.value = filterQueryGenerator(payload);
emit('applyFilter', filterQueryGenerator(payload));
showFiltersModal.value = false;
};
const onUpdateSegment = async (payload, segmentName) => {
payload = useSnakeCase(payload);
const payloadData = {
...props.activeSegment,
name: segmentName,
query: filterQueryGenerator(payload),
};
await store.dispatch('customViews/update', payloadData);
closeAdvanceFiltersModal();
};
const setParamsForEditSegmentModal = () => {
return {
countries,
filterTypes: contactFilterItems,
allCustomAttributes: useSnakeCase(contactAttributes.value),
labels: labels.value || [],
};
};
const initializeSegmentToFilterModal = segment => {
const query = unref(segment)?.query?.payload;
if (!Array.isArray(query)) return;
const newFilters = query.map(filter => {
const transformed = useCamelCase(filter);
const values = Array.isArray(transformed.values)
? generateValuesForEditCustomViews(
useSnakeCase(filter),
setParamsForEditSegmentModal()
)
: [];
return {
attributeKey: transformed.attributeKey,
attributeModel: transformed.attributeModel,
customAttributeType: transformed.customAttributeType,
filterOperator: transformed.filterOperator,
queryOperator: transformed.queryOperator ?? 'and',
values,
};
});
appliedFilter.value = [...appliedFilter.value, ...newFilters];
};
const onToggleFilters = () => {
appliedFilter.value = [];
if (hasActiveSegments.value) {
initializeSegmentToFilterModal(props.activeSegment);
} else {
appliedFilter.value = props.hasAppliedFilters
? [...appliedFilters.value]
: [
{
attributeKey: 'name',
filterOperator: 'equal_to',
values: '',
queryOperator: 'and',
attributeModel: 'standard',
},
];
}
showFiltersModal.value = true;
};
defineExpose({
onToggleFilters,
});
</script>
<template>
<ContactsHeader
:show-search="showSearch"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
:header-title="headerTitle"
:is-segments-view="hasActiveSegments"
:is-label-view="isLabelView"
:is-active-view="isActiveView"
: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"
>
<template #filter>
<div
class="absolute mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
>
<ContactsFilter
v-if="showFiltersModal"
v-model="appliedFilter"
:segment-name="activeSegmentName"
:is-segment-view="hasActiveSegments"
@apply-filter="onApplyFilter"
@update-segment="onUpdateSegment"
@close="closeAdvanceFiltersModal"
@clear-filters="clearFilters"
/>
</div>
</template>
</ContactsHeader>
<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" />
</template>

View File

@@ -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="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
@action="handleContactAction($event)"
/>
</div>
</template>

View File

@@ -0,0 +1,134 @@
<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.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 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-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>

View File

@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import ActiveFilterPreview from 'dashboard/components-next/filter/ActiveFilterPreview.vue';
const props = defineProps({
activeSegment: { type: Object, default: null },
});
const emit = defineEmits(['clearFilters', 'openFilter']);
const { t } = useI18n();
const route = useRoute();
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
const activeSegmentId = computed(() => route.params.segmentId);
const activeSegmentQuery = computed(() => {
const query = props.activeSegment?.query?.payload;
if (!Array.isArray(query)) return [];
const newFilters = query.map(filter => {
const transformed = useCamelCase(filter);
return {
attributeKey: transformed.attributeKey,
attributeModel: transformed.attributeModel,
customAttributeType: transformed.customAttributeType,
filterOperator: transformed.filterOperator,
queryOperator: transformed.queryOperator ?? 'and',
values: transformed.values,
};
});
return newFilters;
});
const hasActiveSegments = computed(
() => props.activeSegment && activeSegmentId.value !== 0
);
const activeFilterQueryData = computed(() => {
return hasActiveSegments.value
? activeSegmentQuery.value
: appliedFilters.value;
});
</script>
<template>
<ActiveFilterPreview
:applied-filters="activeFilterQueryData"
:max-visible-filters="2"
:more-filters-label="
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.MORE_FILTERS', {
count: activeFilterQueryData.length - 2,
})
"
:clear-button-label="
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
"
:show-clear-button="!hasActiveSegments"
class="max-w-5xl"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
/>
</template>

View File

@@ -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>