Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/message-bubble

This commit is contained in:
Shivam Mishra
2024-11-27 19:05:23 +05:30
100 changed files with 4002 additions and 453 deletions
@@ -22,7 +22,7 @@ const handleButtonClick = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[900px] mx-auto">
<div class="w-full max-w-[960px] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
@@ -44,7 +44,7 @@ const handleButtonClick = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[900px] mx-auto py-4">
<div class="w-full max-w-[960px] mx-auto py-4">
<slot name="default" />
</div>
</main>
@@ -0,0 +1,112 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { useI18n } from 'vue-i18n';
defineProps({
selectedContact: {
type: Object,
required: true,
},
primaryContactId: {
type: [Number, null],
default: null,
},
primaryContactList: {
type: Array,
default: () => [],
},
isSearching: {
type: Boolean,
default: false,
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:primaryContactId', 'search']);
const { t } = useI18n();
</script>
<template>
<div class="flex flex-col">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY') }}
</label>
<span
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-teal-11 bg-n-alpha-2"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_HELP_LABEL') }}
</span>
</div>
<ComboBox
id="inbox"
:model-value="primaryContactId"
:options="primaryContactList"
:empty-state="
isSearching
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.IS_SEARCHING')
: t('CONTACTS_LAYOUT.SIDEBAR.MERGE.EMPTY_STATE')
"
:search-placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.MERGE.SEARCH_PLACEHOLDER')
"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PLACEHOLDER')"
:has-error="hasError"
:message="errorMessage"
class="[&>div>button]:bg-n-alpha-black2"
@update:model-value="value => emit('update:primaryContactId', value)"
@search="query => emit('search', query)"
/>
</div>
<div class="relative flex justify-center gap-2 top-4">
<div v-for="i in 3" :key="i" class="relative w-4 h-8">
<div
class="absolute w-0 h-0 border-l-[4px] border-r-[4px] border-b-[6px] border-l-transparent border-r-transparent border-n-strong ltr:translate-x-[4px] rtl:-translate-x-[4px] -translate-y-[4px]"
/>
<div
class="absolute w-[1px] h-full bg-n-strong left-1/2 transform -translate-x-1/2"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT') }}
</label>
<span
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-ruby-11 bg-n-alpha-2"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT_HELP_LABEL') }}
</span>
</div>
<div
class="border border-n-strong h-[60px] gap-2 flex items-center rounded-xl p-3"
>
<Avatar
:name="selectedContact.name || ''"
:src="selectedContact.thumbnail || ''"
:size="32"
rounded-full
/>
<div class="flex flex-col gap-1">
<span class="text-sm leading-4 truncate text-n-slate-11">
{{ selectedContact.name }}
</span>
<span class="text-sm leading-4 truncate text-n-slate-11">
{{ selectedContact.email }}
</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup>
import ContactMergeForm from '../ContactMergeForm.vue';
import { contactData, primaryContactList } from './fixtures';
const handleSearch = query => {
console.log('Searching for:', query);
};
const handleUpdate = value => {
console.log('Primary contact updated:', value);
};
</script>
<template>
<Story
title="Components/Contacts/ContactMergeForm"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="null"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="With Selected Primary Contact">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="1"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="Error State">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="primaryContactList"
:primary-contact-id="null"
:is-searching="false"
has-error
error-message="Please select a primary contact"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
<Variant title="Empty Primary Contact List">
<div class="p-6 border rounded-lg border-n-strong">
<ContactMergeForm
:selected-contact="contactData"
:primary-contact-list="[]"
:primary-contact-id="null"
:is-searching="false"
@update:primary-contact-id="handleUpdate"
@search="handleSearch"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,6 +1,6 @@
<script setup>
import ContactsForm from '../ContactsForm.vue';
import contactData from './fixtures';
import { contactData } from './fixtures';
const handleUpdate = updatedData => {
console.log('Form updated:', updatedData);
@@ -1,4 +1,4 @@
export default {
export const contactData = {
id: 370,
name: 'John Doe',
email: 'johndoe@chatwoot.com',
@@ -18,3 +18,30 @@ export default {
},
},
};
export const primaryContactList = [
{
id: 1,
name: 'Jane Smith',
email: 'jane@chatwoot.com',
thumbnail: '',
label: '(ID: 1) Jane Smith',
value: 1,
},
{
id: 2,
name: 'Mike Johnson',
email: 'mike@chatwoot.com',
thumbnail: '',
label: '(ID: 2) Mike Johnson',
value: 2,
},
{
id: 3,
name: 'Sarah Wilson',
email: 'sarah@chatwoot.com',
thumbnail: '',
label: '(ID: 3) Sarah Wilson',
value: 3,
},
];
@@ -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-[960px]"
>
<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>
@@ -0,0 +1,63 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
note: {
type: Object,
required: true,
},
writtenBy: {
type: String,
required: true,
},
});
const emit = defineEmits(['delete']);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const handleDelete = () => {
emit('delete', props.note.id);
};
</script>
<template>
<div
class="flex flex-col gap-2 px-6 py-2 border-b border-n-strong group/note"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
<Avatar
:name="note.user.name"
:src="note.user.thumbnail"
:size="16"
rounded-full
/>
<div class="min-w-0 truncate">
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span class="font-medium">{{ writtenBy }}</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium">{{ dynamicTime(note.createdAt) }}</span>
</span>
</div>
</div>
<Button
variant="faded"
color="ruby"
size="xs"
icon="i-lucide-trash"
class="opacity-0 group-hover/note:opacity-100"
@click="handleDelete"
/>
</div>
<p
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
/>
</div>
</template>
@@ -0,0 +1,39 @@
<script setup>
import ContactNoteItem from '../ContactNoteItem.vue';
import notes from './fixtures';
const controls = {
writtenBy: {
type: 'text',
default: 'You',
},
};
// Example delete handler
const onDelete = noteId => {
console.log('Note deleted:', noteId);
};
</script>
<template>
<Story
title="Components/Contacts/ContactNoteItem"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Multiple Notes">
<div class="flex flex-col border rounded-lg border-n-strong">
<ContactNoteItem
v-for="note in notes"
:key="note.id"
:note="note"
:written-by="
note.id === notes[1].id
? controls.writtenBy.default
: note.user.name
"
@delete="onDelete"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,69 @@
export default [
{
id: 12,
content:
'This tutorial will show you how to use Chatwoot and, hence, ensure you practice effective customer communication. We will explain in detail the following:\n\n* Step-by-step setup of your account, with illustrative screenshots.\n\n* An in-depth explanation of all the core features of Chatwoot.\n\n* Get your account up and running by the end of this tutorial.\n\n* Basic concepts of customer communication.',
accountId: null,
contactId: null,
user: {
id: 30,
account_id: 2,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'bruce@paperlayer.test',
available_name: 'Bruce',
name: 'Bruce',
role: 'administrator',
thumbnail:
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBJZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--515dbb35e9ba3c36d14f4c4b77220a675513c1fb/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--df796c2af3c0153e55236c2f3cf3a199ac2cb6f7/2.jpg',
custom_role_id: null,
},
createdAt: 1730786556,
updatedAt: 1730786556,
},
{
id: 10,
content:
'We discussed a couple of things:\n\n* Product offering and how it can be useful to talk with people.\n\n* Theyll reach out to us after an internal review.',
accountId: null,
contactId: null,
user: {
id: 1,
account_id: 2,
availability_status: 'online',
auto_offline: false,
confirmed: true,
email: 'hillary@chatwoot.com',
available_name: 'Hillary',
name: 'Hillary',
role: 'administrator',
thumbnail: '',
custom_role_id: null,
},
createdAt: 1730782566,
updatedAt: 1730782566,
},
{
id: 9,
content:
'We discussed a couple of things:\n\n* Product offering and how it can be useful to talk with people.\n\n* Theyll reach out to us after an internal review.',
accountId: null,
contactId: null,
user: {
id: 1,
account_id: 2,
availability_status: 'online',
auto_offline: false,
confirmed: true,
email: 'john@chatwoot.com',
available_name: 'John',
name: 'John',
role: 'administrator',
thumbnail: '',
custom_role_id: null,
},
createdAt: 1730782564,
updatedAt: 1730782564,
},
];
@@ -0,0 +1,28 @@
<script setup>
import ContactEmptyState from './ContactEmptyState.vue';
</script>
<template>
<Story
title="Components/Contacts/EmptyState"
:layout="{ type: 'grid', width: '900px' }"
>
<!-- Default Story -->
<Variant title="Default">
<ContactEmptyState
title="No contacts found"
subtitle="Create your first contact to get started"
button-label="Add Contact"
/>
</Variant>
<!-- Without Button -->
<Variant title="Without Button">
<ContactEmptyState
title="No contacts"
subtitle="These are your current contacts"
:show-button="false"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,51 @@
<script setup>
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
import contactContent from 'dashboard/components-next/Contacts/EmptyState/contactEmptyStateContent';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
showButton: {
type: Boolean,
default: true,
},
buttonLabel: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ContactsCard
v-for="contact in contactContent.slice(0, 5)"
: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="0 === contact.id"
@toggle="toggleExpanded(contact.id)"
/>
</div>
</template>
<template #actions>
<div v-if="showButton">
<Button :label="buttonLabel" icon="i-lucide-plus" @click="onClick" />
</div>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,228 @@
export default [
{
additionalAttributes: {
city: 'Los Angeles',
country: 'United States',
description:
"I'm Candice, a developer focusing on building web solutions. Currently, Im working as a Product Developer at Chatwoot.",
companyName: 'Chatwoot',
countryCode: 'US',
socialProfiles: {
github: 'candice-dev',
twitter: 'candice_w_dev',
facebook: 'candice.dev',
linkedin: 'candice-matherson',
instagram: 'candice.codes',
},
},
availabilityStatus: 'offline',
email: 'candice.matherson@chatwoot.com',
id: 22,
name: 'Candice Matherson',
phoneNumber: '+14155552671',
identifier: null,
thumbnail: '',
customAttributes: {
dateContact: '2024-11-11T11:53:09.299Z',
linkContact: 'https://example.com',
listContact: 'Follow-Up',
textContact: 'Hi there!',
numberContact: '42',
checkboxContact: false,
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'San Francisco',
country: 'United States',
description: 'Passionate about design and user experience.',
companyName: 'Designify',
countryCode: 'US',
socialProfiles: {
github: 'ophelia-folkard',
twitter: 'oph_designs',
facebook: 'ophelia.folkard',
linkedin: 'ophelia-folkard',
instagram: 'ophelia.design',
},
},
availabilityStatus: 'offline',
email: 'ophelia.folkard@designify.com',
id: 21,
name: 'Ophelia Folkard',
phoneNumber: '+14155552672',
identifier: null,
thumbnail: '',
customAttributes: {
dateContact: '2024-10-05T10:12:34.567Z',
linkContact: 'https://designify.com',
listContact: 'Prospects',
textContact: 'Looking forward to connecting!',
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'Austin',
country: 'United States',
description: 'Avid coder and tech enthusiast.',
companyName: 'CodeHub',
countryCode: 'US',
socialProfiles: {
github: 'willy_castelot',
twitter: 'willy_code',
facebook: 'willy.castelot',
linkedin: 'willy-castelot',
instagram: 'willy.coder',
},
},
availabilityStatus: 'offline',
email: 'willy.castelot@codehub.io',
id: 20,
name: 'Willy Castelot',
phoneNumber: '+14155552673',
identifier: null,
thumbnail: '',
customAttributes: {
textContact: 'Lets collaborate!',
checkboxContact: true,
},
lastActivityAt: 1712123233,
createdAt: 1712123233,
},
{
additionalAttributes: {
city: 'Seattle',
country: 'United States',
description: 'Product manager with a love for innovation.',
companyName: 'InnovaTech',
countryCode: 'US',
socialProfiles: {
github: 'elisabeth-d',
twitter: 'elisabeth_innova',
facebook: 'elisabeth.derington',
linkedin: 'elisabeth-derington',
instagram: 'elisabeth.innovates',
},
},
availabilityStatus: 'offline',
email: 'elisabeth.derington@innova.com',
id: 19,
name: 'Elisabeth Derington',
phoneNumber: '+14155552674',
identifier: null,
thumbnail: '',
customAttributes: {
textContact: 'Lets schedule a call.',
},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Chicago',
country: 'United States',
description: 'Marketing specialist and content creator.',
companyName: 'Contently',
countryCode: 'US',
socialProfiles: {
github: 'olia-olenchenko',
twitter: 'olia_content',
facebook: 'olia.olenchenko',
linkedin: 'olia-olenchenko',
instagram: 'olia.creates',
},
},
availabilityStatus: 'offline',
email: 'olia.olenchenko@contently.com',
id: 18,
name: 'Olia Olenchenko',
phoneNumber: '+14155552675',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Boston',
country: 'United States',
description: 'SEO expert and analytics enthusiast.',
companyName: 'OptiSearch',
countryCode: 'US',
socialProfiles: {
github: 'nate-vannuchi',
twitter: 'nate_seo',
facebook: 'nathaniel.vannuchi',
linkedin: 'nathaniel-vannuchi',
instagram: 'nate.optimizes',
},
},
availabilityStatus: 'offline',
email: 'nathaniel.vannuchi@optisearch.com',
id: 17,
name: 'Nathaniel Vannuchi',
phoneNumber: '+14155552676',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Denver',
country: 'United States',
description: 'UI/UX designer with a flair for minimalist designs.',
companyName: 'Minimal Designs',
countryCode: 'US',
socialProfiles: {
github: 'merrile-petruk',
twitter: 'merrile_ux',
facebook: 'merrile.petruk',
linkedin: 'merrile-petruk',
instagram: 'merrile.designs',
},
},
availabilityStatus: 'offline',
email: 'merrile.petruk@minimal.com',
id: 16,
name: 'Merrile Petruk',
phoneNumber: '+14155552677',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
{
additionalAttributes: {
city: 'Miami',
country: 'United States',
description: 'Entrepreneur with a background in e-commerce.',
companyName: 'Ecom Solutions',
countryCode: 'US',
socialProfiles: {
github: 'cordell-d',
twitter: 'cordell_entrepreneur',
facebook: 'cordell.dalinder',
linkedin: 'cordell-dalinder',
instagram: 'cordell.ecom',
},
},
availabilityStatus: 'offline',
email: 'cordell.dalinder@ecomsolutions.com',
id: 15,
name: 'Cordell Dalinder',
phoneNumber: '+14155552678',
identifier: null,
thumbnail: '',
customAttributes: {},
lastActivityAt: 1712123232,
createdAt: 1712123232,
},
];
@@ -0,0 +1,46 @@
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const attributeValue = ref(Boolean(props.attribute.value));
const handleChange = value => {
emit('update', value);
};
</script>
<template>
<div
class="flex items-center w-full gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<Switch v-model="attributeValue" @change="handleChange" />
<Button
v-if="isEditingView"
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
</template>
@@ -0,0 +1,148 @@
<script setup>
import { ref, computed } from 'vue';
import { parseISO } from 'date-fns';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const isEditingValue = ref(false);
const editedValue = ref(props.attribute.value || '');
const rules = {
editedValue: {
required,
isDate: value => new Date(value).toISOString(),
},
};
const v$ = useVuelidate(rules, { editedValue });
const formattedDate = computed(() => {
return props.attribute.value
? new Date(props.attribute.value).toLocaleDateString()
: t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT');
});
const hasError = computed(() => v$.value.$errors.length > 0);
const defaultDateValue = computed({
get() {
const existingDate = editedValue.value ?? props.attribute.value;
if (existingDate) return new Date(existingDate).toISOString().slice(0, 10);
return isEditingValue.value && !hasError.value
? new Date().toISOString().slice(0, 10)
: '';
},
set(value) {
editedValue.value = value ? new Date(value).toISOString() : value;
},
});
const toggleEditValue = value => {
isEditingValue.value =
typeof value === 'boolean' ? value : !isEditingValue.value;
if (isEditingValue.value && !editedValue.value) {
v$.value.$reset();
editedValue.value = new Date().toISOString();
}
};
const handleInputUpdate = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
emit('update', parseISO(editedValue.value));
isEditingValue.value = false;
};
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<span
v-if="!isEditingValue"
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1': isEditingView,
}"
@click="toggleEditValue(!isEditingView)"
>
{{ formattedDate }}
</span>
<div
v-if="isEditingView && !isEditingValue"
class="flex items-center gap-1"
>
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleEditValue(true)"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
<div
v-if="isEditingValue"
v-on-clickaway="() => toggleEditValue(false)"
class="flex items-center w-full"
>
<Input
v-model="defaultDateValue"
type="date"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
:message="
hasError
? t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_DATE')
: ''
"
:message-type="hasError ? 'error' : 'info'"
autofocus
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
:color="hasError ? 'ruby' : 'blue'"
size="sm"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
@click="handleInputUpdate"
/>
</div>
</div>
</template>
@@ -0,0 +1,100 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const [showAttributeListDropdown, toggleAttributeListDropdown] = useToggle();
const attributeListMenuItems = computed(() => {
return (
props.attribute.attributeValues?.map(value => ({
label: value,
value,
action: 'select',
isSelected: value === props.attribute.value,
})) || []
);
});
const handleAttributeAction = async action => {
emit('update', action.value);
toggleAttributeListDropdown(false);
};
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<div
v-on-clickaway="() => toggleAttributeListDropdown(false)"
class="relative flex items-center"
>
<span
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1': isEditingView,
}"
@click="toggleAttributeListDropdown(!props.isEditingView)"
>
{{
attribute.value ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.SELECT')
}}
</span>
<DropdownMenu
v-if="showAttributeListDropdown"
:menu-items="attributeListMenuItems"
show-search
class="w-48 mt-2 top-full"
:class="{
'ltr:right-0 rtl:left-0': !isEditingView,
'ltr:left-0 rtl:right-0': isEditingView,
}"
@action="handleAttributeAction($event)"
/>
</div>
<div v-if="isEditingView" class="flex items-center gap-1">
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleAttributeListDropdown()"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
</div>
</template>
@@ -0,0 +1,202 @@
<!-- Attribute type "Text, URL, Number" -->
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { isValidURL } from 'dashboard/helper/URLHelper.js';
import { getRegexp } from 'shared/helpers/Validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update', 'delete']);
const { t } = useI18n();
const isEditingValue = ref(false);
const editedValue = ref(props.attribute.value || '');
const isAttributeTypeLink = computed(
() => props.attribute.attributeDisplayType === 'link'
);
const isAttributeTypeText = computed(
() => props.attribute.attributeDisplayType === 'text'
);
const isAttributeTypeNumber = computed(
() => props.attribute.attributeDisplayType === 'number'
);
const rules = computed(() => ({
editedValue: {
required,
...(isAttributeTypeLink.value && {
url: value => !value || isValidURL(value),
}),
...(isAttributeTypeText.value &&
props.attribute.regexPattern && {
regexValidation: value => {
if (!value) return true;
return getRegexp(props.attribute.regexPattern).test(value);
},
}),
},
}));
const v$ = useVuelidate(rules, { editedValue });
const hasError = computed(() => v$.value.$error);
const attributeErrorMessage = computed(() => {
if (!hasError.value) return '';
if (isAttributeTypeLink.value && v$.value.editedValue.url?.$invalid) {
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
if (
isAttributeTypeText.value &&
props.attribute.regexPattern &&
v$.value.editedValue.regexValidation?.$invalid
) {
return (
props.attribute.regexCue ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
);
}
if (isAttributeTypeNumber.value && v$.value.editedValue.required?.$invalid) {
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_NUMBER');
}
return t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.REQUIRED');
});
const getInputType = computed(() => {
switch (props.attribute.attributeDisplayType) {
case 'link':
return 'url';
case 'number':
return 'number';
default:
return 'text';
}
});
const toggleEditValue = value => {
isEditingValue.value =
typeof value === 'boolean' ? value : !isEditingValue.value;
if (isEditingValue.value) {
v$.value.$reset();
editedValue.value = props.attribute.value || '';
}
};
const handleInputUpdate = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
emit('update', editedValue.value);
toggleEditValue(false);
};
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<span
v-if="!isEditingValue"
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1':
isEditingView && !isAttributeTypeLink,
'truncate flex-1 hover:text-n-brand text-n-blue-text':
isEditingView && isAttributeTypeLink,
}"
@click="toggleEditValue(!isEditingView)"
>
<a
v-if="isAttributeTypeLink && attribute.value && isEditingView"
:href="attribute.value"
target="_blank"
rel="noopener noreferrer"
class="hover:underline"
@click.stop
>
{{ attribute.value }}
</a>
<template v-else>
{{
attribute.value ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')
}}
</template>
</span>
<div
v-if="isEditingView && !isEditingValue"
class="flex items-center gap-1"
>
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleEditValue(true)"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
<div
v-if="isEditingValue"
v-on-clickaway="() => toggleEditValue(false)"
class="flex items-center w-full"
>
<Input
v-model="editedValue"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')"
:type="getInputType"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
autofocus
:message="attributeErrorMessage"
:message-type="hasError ? 'error' : 'info'"
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
:color="hasError ? 'ruby' : 'blue'"
size="sm"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
@click="handleInputUpdate"
/>
</div>
</div>
</template>
@@ -0,0 +1,83 @@
<script setup>
import Attributes from './fixtures';
import OtherAttribute from '../OtherAttribute.vue';
import ListAttribute from '../ListAttribute.vue';
import DateAttribute from '../DateAttribute.vue';
import CheckboxAttribute from '../CheckboxAttribute.vue';
const componentMap = {
list: ListAttribute,
checkbox: CheckboxAttribute,
date: DateAttribute,
default: OtherAttribute,
};
const getCurrentComponent = type => {
return componentMap[type] || componentMap.default;
};
const handleUpdate = (type, value) => {
console.log(`${type} updated:`, value);
};
const handleDelete = type => {
console.log(`${type} deleted`);
};
</script>
<template>
<Story
title="Components/CustomAttributes"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Create View">
<div class="flex flex-col gap-4 p-4 border rounded-lg border-n-strong">
<div
v-for="attribute in Attributes"
:key="attribute.attributeKey"
class="grid grid-cols-[140px,1fr] group-hover/attribute items-center gap-1 min-h-10"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="getCurrentComponent(attribute.attributeDisplayType)"
:attribute="attribute"
@update="
value => handleUpdate(attribute.attributeDisplayType, value)
"
@delete="() => handleDelete(attribute.attributeDisplayType)"
/>
</div>
</div>
</Variant>
<Variant title="Saved View">
<div class="flex flex-col gap-4 p-4 border rounded-lg border-n-strong">
<div
v-for="attribute in Attributes"
:key="attribute.attributeKey"
class="grid grid-cols-[140px,1fr] group-hover/attribute items-center gap-1 min-h-10"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="getCurrentComponent(attribute.attributeDisplayType)"
:attribute="attribute"
is-editing-view
@update="
value => handleUpdate(attribute.attributeDisplayType, value)
"
@delete="() => handleDelete(attribute.attributeDisplayType)"
/>
</div>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,39 @@
export default [
{
attributeKey: 'textContact',
attributeDisplayName: 'Text Input',
attributeDisplayType: 'text',
value: 'Sample text value',
},
{
attributeKey: 'linkContact',
attributeDisplayName: 'URL Input',
attributeDisplayType: 'link',
value: 'https://www.chatwoot.com',
},
{
attributeKey: 'numberContact',
attributeDisplayName: 'Number Input',
attributeDisplayType: 'number',
value: '42',
},
{
attributeKey: 'listContact',
attributeDisplayName: 'List Input',
attributeDisplayType: 'list',
value: 'Option 2',
attributeValues: ['Option 1', 'Option 2', 'Option 3'],
},
{
attributeKey: 'dateContact',
attributeDisplayName: 'Date Input',
attributeDisplayType: 'date',
value: '2024-03-25T00:00:00.000Z',
},
{
attributeKey: 'checkboxContact',
attributeDisplayName: 'Checkbox Input',
attributeDisplayType: 'checkbox',
value: true,
},
];
@@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[900px] mx-auto">
<div class="w-full max-w-[960px] mx-auto">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
@@ -95,7 +95,7 @@ const togglePortalSwitcher = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[900px] mx-auto py-3">
<div class="w-full max-w-[960px] mx-auto py-3">
<slot name="content" />
</div>
</main>
@@ -27,6 +27,7 @@ const props = defineProps({
const emit = defineEmits([
'saveArticle',
'saveArticleAsync',
'goBack',
'setAuthor',
'setCategory',
@@ -35,19 +36,37 @@ const emit = defineEmits([
const { t } = useI18n();
const saveArticle = debounce(value => emit('saveArticle', value), 600, false);
const saveAndSync = value => {
emit('saveArticle', value);
};
// this will only send the data to the backend
// but will not update the local state preventing unnecessary re-renders
// since the data is already saved and we keep the editor text as the source of truth
const quickSave = debounce(
value => emit('saveArticleAsync', value),
400,
false
);
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
// so we can save the data to the backend and retrieve the updated data
// this will update the local state with response data
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
const articleTitle = computed({
get: () => props.article.title,
set: value => {
saveArticle({ title: value });
quickSave({ title: value });
saveAndSyncDebounced({ title: value });
},
});
const articleContent = computed({
get: () => props.article.content,
set: content => {
saveArticle({ content });
quickSave({ content });
saveAndSyncDebounced({ content });
},
});
@@ -93,7 +112,7 @@ const previewArticle = () => {
/>
<ArticleEditorControls
:article="article"
@save-article="saveArticle"
@save-article="saveAndSync"
@set-author="setAuthorId"
@set-category="setCategoryId"
/>
@@ -0,0 +1,49 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
defineProps({
labelMenuItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['updateLabel']);
const { t } = useI18n();
const showDropdown = ref(false);
</script>
<template>
<div class="relative">
<button
class="flex items-center gap-1 px-2 py-1 rounded-md outline-dashed h-[26px] outline-1 outline-n-slate-6 hover:bg-n-alpha-2"
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="showDropdown = !showDropdown"
>
<span class="i-lucide-plus" />
<span class="text-sm text-n-slate-11">
{{ t('LABEL.TAG_BUTTON') }}
</span>
</button>
<DropdownMenu
v-if="showDropdown"
v-on-clickaway="() => (showDropdown = false)"
:menu-items="labelMenuItems"
show-search
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
@action="emit('updateLabel', $event)"
>
<template #thumbnail="{ item }">
<div
class="rounded-sm size-2"
:style="{ backgroundColor: item.thumbnail.color }"
/>
</template>
</DropdownMenu>
</div>
</template>
@@ -0,0 +1,22 @@
<script setup>
defineProps({
label: {
type: Object,
default: null,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 rounded-md flex items-center h-7 w-fit py-1 ltr:pl-1 rtl:pr-1 ltr:pr-1.5 rtl:pl-1.5"
>
<div
class="w-2 h-2 m-1 rounded-sm"
:style="{ backgroundColor: label.color }"
/>
<span class="text-sm text-n-slate-12">
{{ label.title }}
</span>
</div>
</template>
@@ -0,0 +1,27 @@
<script setup>
import AddLabel from '../AddLabel.vue';
import { labelMenuItems } from './fixtures';
function onUpdateLabel(label) {
console.log('Label updated:', label);
}
</script>
<template>
<Story title="Components/Label/Add Label">
<Variant title="Default (button with label menu items with active state)">
<div class="h-[300px] p-4">
<AddLabel
:label-menu-items="labelMenuItems"
@update-label="onUpdateLabel"
/>
</div>
</Variant>
<Variant title="Empty List (button with empty label menu)">
<div class="h-[300px] p-4">
<AddLabel :label-menu-items="[]" @update-label="onUpdateLabel" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,21 @@
<script setup>
import Label from '../LabelItem.vue';
import { label } from './fixtures';
</script>
<template>
<Story title="Components/Label/Label item">
<Variant title="Default">
<Label :label="label" />
</Variant>
<Variant title="Custom Label">
<Label
:label="{
title: 'Custom Label',
color: '#FF5733',
}"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,62 @@
export const label = {
id: 1,
title: 'delivery',
color: '#A2FDD5',
};
export const labelMenuItems = [
{
label: 'delivery',
value: 3,
thumbnail: {
color: '#A2FDD5',
},
isSelected: true,
action: 'addLabel',
},
{
label: 'lead',
value: 6,
thumbnail: {
color: '#F161C8',
},
isSelected: false,
action: 'addLabel',
},
{
label: 'ops-handover',
value: 4,
thumbnail: {
color: '#A53326',
},
isSelected: false,
action: 'addLabel',
},
{
label: 'billing',
value: 1,
thumbnail: {
color: '#28AD21',
},
isSelected: false,
action: 'addLabel',
},
{
label: 'premium-customer',
value: 5,
thumbnail: {
color: '#6FD4EF',
},
isSelected: false,
action: 'addLabel',
},
{
label: 'software',
value: 2,
thumbnail: {
color: '#8F6EF2',
},
isSelected: false,
action: 'addLabel',
},
];
@@ -1,46 +1,85 @@
<script setup>
import { computed, useSlots } from 'vue';
import { computed, useSlots, useAttrs } from 'vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import {
VARIANT_OPTIONS,
COLOR_OPTIONS,
SIZE_OPTIONS,
EXCLUDED_ATTRS,
} from './constants.js';
const props = defineProps({
label: {
type: [String, Number],
default: '',
},
label: { type: [String, Number], default: '' },
variant: {
type: String,
default: 'solid',
validator: value =>
['solid', 'outline', 'faded', 'link', 'ghost'].includes(value),
default: null,
validator: value => VARIANT_OPTIONS.includes(value) || value === null,
},
color: {
type: String,
default: 'blue',
validator: value =>
['blue', 'ruby', 'amber', 'slate', 'teal'].includes(value),
default: null,
validator: value => COLOR_OPTIONS.includes(value) || value === null,
},
size: {
type: String,
default: 'md',
validator: value => ['xs', 'sm', 'md', 'lg'].includes(value),
},
icon: {
type: String,
default: '',
},
trailingIcon: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
default: null,
validator: value => SIZE_OPTIONS.includes(value) || value === null,
},
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
});
const slots = useSlots();
const attrs = useAttrs();
defineOptions({
inheritAttrs: false,
});
const filteredAttrs = computed(() => {
const standardAttrs = {};
Object.entries(attrs)
.filter(([key]) => !EXCLUDED_ATTRS.includes(key))
.forEach(([key, value]) => {
standardAttrs[key] = value;
});
return standardAttrs;
});
const computedVariant = computed(() => {
if (props.variant) return props.variant;
// The useAttrs method returns attributes values an empty string (not boolean value as in props).
if (attrs.solid || attrs.solid === '') return 'solid';
if (attrs.outline || attrs.outline === '') return 'outline';
if (attrs.faded || attrs.faded === '') return 'faded';
if (attrs.link || attrs.link === '') return 'link';
if (attrs.ghost || attrs.ghost === '') return 'ghost';
return 'solid'; // Default variant
});
const computedColor = computed(() => {
if (props.color) return props.color;
if (attrs.blue || attrs.blue === '') return 'blue';
if (attrs.ruby || attrs.ruby === '') return 'ruby';
if (attrs.amber || attrs.amber === '') return 'amber';
if (attrs.slate || attrs.slate === '') return 'slate';
if (attrs.teal || attrs.teal === '') return 'teal';
return 'blue'; // Default color
});
const computedSize = computed(() => {
if (props.size) return props.size;
if (attrs.xs || attrs.xs === '') return 'xs';
if (attrs.sm || attrs.sm === '') return 'sm';
if (attrs.md || attrs.md === '') return 'md';
if (attrs.lg || attrs.lg === '') return 'lg';
return 'md';
});
const STYLE_CONFIG = {
colors: {
@@ -113,23 +152,24 @@ const STYLE_CONFIG = {
const variantClasses = computed(() => {
const variantMap = {
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
link: `${STYLE_CONFIG.colors[props.color].link} p-0 font-medium underline-offset-4`,
outline: STYLE_CONFIG.colors[props.color].outline,
faded: STYLE_CONFIG.colors[props.color].faded,
solid: STYLE_CONFIG.colors[props.color].solid,
link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-4`,
outline: STYLE_CONFIG.colors[computedColor.value].outline,
faded: STYLE_CONFIG.colors[computedColor.value].faded,
solid: STYLE_CONFIG.colors[computedColor.value].solid,
};
return variantMap[props.variant];
return variantMap[computedVariant.value];
});
const isIconOnly = computed(() => !props.label && !slots.default);
const isLink = computed(() => props.variant === 'link');
const isLink = computed(() => computedVariant.value === 'link');
const buttonClasses = computed(() => {
const sizeConfig = isIconOnly.value ? 'iconOnly' : 'regular';
const classes = [
variantClasses.value,
props.variant !== 'link' && STYLE_CONFIG.sizes[sizeConfig][props.size],
computedVariant.value !== 'link' &&
STYLE_CONFIG.sizes[sizeConfig][computedSize.value],
].filter(Boolean);
return classes.join(' ');
@@ -138,7 +178,7 @@ const buttonClasses = computed(() => {
const linkButtonClasses = computed(() => {
const classes = [
variantClasses.value,
STYLE_CONFIG.sizes.link[props.size],
STYLE_CONFIG.sizes.link[computedSize.value],
].filter(Boolean);
return classes.join(' ');
@@ -147,10 +187,11 @@ const linkButtonClasses = computed(() => {
<template>
<button
v-bind="filteredAttrs"
:class="{
[STYLE_CONFIG.base]: true,
[isLink ? linkButtonClasses : buttonClasses]: true,
[STYLE_CONFIG.fontSize[size]]: true,
[STYLE_CONFIG.fontSize[computedSize]]: true,
'flex-row-reverse': trailingIcon && !isIconOnly,
}"
>
@@ -0,0 +1,15 @@
export const VARIANT_OPTIONS = ['solid', 'outline', 'faded', 'link', 'ghost'];
export const COLOR_OPTIONS = ['blue', 'ruby', 'amber', 'slate', 'teal'];
export const SIZE_OPTIONS = ['xs', 'sm', 'md', 'lg'];
export const EXCLUDED_ATTRS = [
'variant',
'color',
'size',
'icon',
'trailingIcon',
'isLoading',
...VARIANT_OPTIONS,
...COLOR_OPTIONS,
...SIZE_OPTIONS,
];
@@ -14,22 +14,22 @@ const menuItems = ref([
{
label: 'Contact Support',
icon: 'i-lucide-life-buoy',
click: () => window.alert('Contact Support'),
click: () => console.log('Contact Support'),
},
{
label: 'Keyboard Shortcuts',
icon: 'i-lucide-keyboard',
click: () => window.alert('Keyboard Shortcuts'),
click: () => console.log('Keyboard Shortcuts'),
},
{
label: 'Profile Settings',
icon: 'i-lucide-user-pen',
click: () => window.alert('Profile Settings'),
click: () => console.log('Profile Settings'),
},
{
label: 'Change Appearance',
icon: 'i-lucide-swatch-book',
click: () => window.alert('Change Appearance'),
click: () => console.log('Change Appearance'),
},
{
label: 'Open SuperAdmin',
@@ -40,7 +40,7 @@ const menuItems = ref([
{
label: 'Log Out',
icon: 'i-lucide-log-out',
click: () => window.alert('Log Out'),
click: () => console.log('Log Out'),
},
]);
</script>
@@ -1,5 +1,6 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { provideDropdownContext } from './provider.js';
const emit = defineEmits(['close']);
@@ -20,9 +21,9 @@ provideDropdownContext({
</script>
<template>
<div class="relative z-20 space-y-2">
<div class="relative space-y-2">
<slot name="trigger" :is-open :toggle="() => toggle()" />
<div v-if="isOpen" v-on-clickaway="closeMenu" class="absolute">
<div v-if="isOpen" v-on-click-outside="closeMenu" class="absolute">
<slot />
</div>
</div>
@@ -0,0 +1,58 @@
<script setup>
import { ref } from 'vue';
import ConditionRow from './ConditionRow.vue';
import Button from 'next/button/Button.vue';
import { filterTypes } from './fixtures/filterTypes.js';
const DEFAULT_FILTER = {
attributeKey: 'status',
filterOperator: 'equal_to',
values: [],
queryOperator: 'and',
};
const filters = ref([{ ...DEFAULT_FILTER }]);
const removeFilter = index => {
filters.value.splice(index, 1);
};
const showQueryOperator = true;
const addFilter = () => {
filters.value.push({ ...DEFAULT_FILTER });
};
</script>
<template>
<Story
title="Components/Filters/ConditionRow"
:layout="{ type: 'grid', width: '600px' }"
>
<div class="min-h-[400px] p-2 space-y-2">
<template v-for="(filter, index) in filters" :key="`filter-${index}`">
<ConditionRow
v-if="index === 0"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
:show-query-operator="false"
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
<ConditionRow
v-else
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
v-model:query-operator="filters[index - 1].queryOperator"
:show-query-operator
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
</template>
<Button sm label="Add Filter" @click="addFilter" />
</div>
</Story>
</template>
@@ -0,0 +1,197 @@
<script setup>
import { computed, defineModel, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'next/button/Button.vue';
import FilterSelect from './inputs/FilterSelect.vue';
import MultiSelect from './inputs/MultiSelect.vue';
import SingleSelect from './inputs/SingleSelect.vue';
import { validateSingleFilter } from 'dashboard/helper/validations.js';
// filterTypes: import('vue').ComputedRef<FilterType[]>
const { filterTypes } = defineProps({
showQueryOperator: { type: Boolean, default: false },
filterTypes: { type: Array, required: true },
});
const emit = defineEmits(['remove']);
const { t } = useI18n();
const showErrors = ref(false);
const attributeKey = defineModel('attributeKey', {
type: String,
required: true,
});
const values = defineModel('values', {
type: [String, Number, Array, Object],
required: true,
});
const filterOperator = defineModel('filterOperator', {
type: String,
required: true,
});
const queryOperator = defineModel('queryOperator', {
type: String,
required: false,
default: undefined,
validator: value => ['and', 'or'].includes(value),
});
const getFilterFromFilterTypes = key =>
filterTypes.find(filterObj => filterObj.attributeKey === key);
const currentFilter = computed(() =>
getFilterFromFilterTypes(attributeKey.value)
);
const getOperator = (filter, selectedOperator) => {
const operatorFromOptions = filter.filterOperators.find(
operator => operator.value === selectedOperator
);
if (!operatorFromOptions) {
return filter.filterOperators[0];
}
return operatorFromOptions;
};
const currentOperator = computed(() =>
getOperator(currentFilter.value, filterOperator.value)
);
const getInputType = (operator, filter) =>
operator.inputOverride ?? filter.inputType;
const inputType = computed(() =>
getInputType(currentOperator.value, currentFilter.value)
);
const queryOperatorOptions = computed(() => {
return [
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.AND`),
value: 'and',
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-text' }),
},
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.OR`),
value: 'or',
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-text' }),
},
];
});
const booleanOptions = computed(() => [
{ id: true, name: t('FILTER.ATTRIBUTE_LABELS.TRUE') },
{ id: false, name: t('FILTER.ATTRIBUTE_LABELS.FALSE') },
]);
const validationError = computed(() => {
return validateSingleFilter({
attributeKey: attributeKey.value,
filter_operator: filterOperator.value,
values: values.value,
});
});
const resetModelOnAttributeKeyChange = newAttributeKey => {
/**
* Resets the filter values and operator when the attribute key changes. This ensures that
* the values and operator remain compatible with the new attribute type. For example,
* switching from a text field to a multi-select should reset the value from '' (empty string)
* to an empty array.
*/
const filter = getFilterFromFilterTypes(newAttributeKey);
const newOperator = getOperator(filter, filterOperator.value);
const newInputType = getInputType(newOperator, filter);
if (newInputType === 'multiSelect') {
values.value = [];
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
values.value = {};
} else {
values.value = '';
}
filterOperator.value = newOperator.value;
};
watch([attributeKey, values, filterOperator], () => {
showErrors.value = false;
});
const validate = () => {
showErrors.value = true;
return !validationError.value;
};
defineExpose({ validate });
</script>
<template>
<li class="list-none">
<div
class="flex items-center gap-2 rounded-md"
:class="{
'animate-wiggle': showErrors && validationError,
}"
>
<FilterSelect
v-if="showQueryOperator"
v-model="queryOperator"
variant="faded"
hide-icon
class="text-sm"
:options="queryOperatorOptions"
/>
<FilterSelect
v-model="attributeKey"
variant="faded"
:options="filterTypes"
@update:model-value="resetModelOnAttributeKeyChange"
/>
<FilterSelect
v-model="filterOperator"
variant="ghost"
:options="currentFilter.filterOperators"
/>
<template v-if="currentOperator.hasInput">
<MultiSelect
v-if="inputType === 'multiSelect'"
v-model="values"
:options="currentFilter.options"
/>
<SingleSelect
v-else-if="inputType === 'searchSelect'"
v-model="values"
:options="currentFilter.options"
/>
<SingleSelect
v-else-if="inputType === 'booleanSelect'"
v-model="values"
disable-search
:options="booleanOptions"
/>
<input
v-else
v-model="values"
:type="inputType === 'date' ? 'date' : 'text'"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</template>
<Button
sm
solid
slate
icon="i-lucide-trash"
@click.stop="emit('remove')"
/>
</div>
<span v-if="showErrors && validationError" class="text-sm text-n-ruby-11">
{{ t(`FILTER.ERRORS.${validationError}`) }}
</span>
</li>
</template>
@@ -0,0 +1,558 @@
export const filterTypes = [
{
attributeKey: 'status',
value: 'status',
attributeName: 'Status',
label: 'Status',
inputType: 'multiSelect',
options: [
{ id: 'open', name: 'Open' },
{ id: 'resolved', name: 'Resolved' },
{ id: 'pending', name: 'Pending' },
{ id: 'snoozed', name: 'Snoozed' },
{ id: 'all', name: 'All' },
],
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'assignee_id',
value: 'assignee_id',
attributeName: 'Assignee name',
label: 'Assignee name',
inputType: 'searchSelect',
options: [
{ id: 14, name: 'Ben Nugent' },
{ id: 30, name: 'Bruce' },
{ id: 16, name: 'Cathy Simms' },
{ id: 7, name: 'Charles Miner' },
{ id: 10, name: 'Craig D' },
{ id: 9, name: 'Dan Gore' },
{ id: 13, name: 'Danny Cordray' },
{ id: 3, name: 'David Wallace' },
{ id: 4, name: 'Deangelo Vickers' },
{ id: 33, name: 'Devon White' },
{ id: 8, name: 'Ed Truck' },
{ id: 31, name: 'Frank' },
{ id: 29, name: 'Gideon' },
{ id: 24, name: 'Glenn Max' },
],
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'team_id',
value: 'team_id',
attributeName: 'Team name',
label: 'Team name',
inputType: 'searchSelect',
options: [
{ id: 223, name: '💰 sales' },
{ id: 224, name: '💼 management' },
{ id: 225, name: '👩‍💼 administration' },
{ id: 226, name: '🚛 warehouse' },
],
dataType: 'number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'display_id',
value: 'display_id',
attributeName: 'Conversation identifier',
label: 'Conversation identifier',
inputType: 'plainText',
datatype: 'number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'contains',
label: 'Contains',
hasInput: true,
inputOverride: null,
icon: 'i-ph-superset-of-bold',
},
{
value: 'does_not_contain',
label: 'Does not contain',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-superset-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'campaign_id',
value: 'campaign_id',
attributeName: 'Campaign name',
label: 'Campaign name',
inputType: 'searchSelect',
options: [],
datatype: 'number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'labels',
value: 'labels',
attributeName: 'Labels',
label: 'Labels',
inputType: 'multiSelect',
options: [
{ id: 'billing', name: 'billing' },
{ id: 'delivery', name: 'delivery' },
{ id: 'lead', name: 'lead' },
{ id: 'ops-handover', name: 'ops-handover' },
{ id: 'premium-customer', name: 'premium-customer' },
{ id: 'software', name: 'software' },
],
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'referer',
value: 'referer',
attributeName: 'Referer link',
label: 'Referer link',
inputType: 'plainText',
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'contains',
label: 'Contains',
hasInput: true,
inputOverride: null,
icon: 'i-ph-superset-of-bold',
},
{
value: 'does_not_contain',
label: 'Does not contain',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-superset-of',
},
],
attributeModel: 'additional',
},
{
attributeKey: 'created_at',
value: 'created_at',
attributeName: 'Created at',
label: 'Created at',
inputType: 'date',
dataType: 'text',
filterOperators: [
{
value: 'is_greater_than',
label: 'Is greater than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-greater-than-bold',
},
{
value: 'is_less_than',
label: 'Is lesser than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-less-than-bold',
},
{
value: 'days_before',
label: 'Is x days before',
hasInput: true,
inputOverride: 'plainText',
icon: 'i-ph-calendar-minus-bold',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'last_activity_at',
value: 'last_activity_at',
attributeName: 'Last activity',
label: 'Last activity',
inputType: 'date',
dataType: 'text',
filterOperators: [
{
value: 'is_greater_than',
label: 'Is greater than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-greater-than-bold',
},
{
value: 'is_less_than',
label: 'Is lesser than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-less-than-bold',
},
{
value: 'days_before',
label: 'Is x days before',
hasInput: true,
inputOverride: 'plainText',
icon: 'i-ph-calendar-minus-bold',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'are_you_a_paid_customer',
value: 'are_you_a_paid_customer',
attributeName: 'Are you a paid customer?',
label: 'Are you a paid customer?',
inputType: 'booleanSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'date_of_purchase',
value: 'date_of_purchase',
attributeName: 'Date of Purchase',
label: 'Date of Purchase',
inputType: 'date',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
{
value: 'is_greater_than',
label: 'Is greater than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-greater-than-bold',
},
{
value: 'is_less_than',
label: 'Is lesser than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-less-than-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'your_website',
value: 'your_website',
attributeName: 'Your website',
label: 'Your website',
inputType: 'plainText',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'are_you_residing_in_india',
value: 'are_you_residing_in_india',
attributeName: 'Are you residing in India?',
label: 'Are you residing in India?',
inputType: 'booleanSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'cloud',
value: 'cloud',
attributeName: 'Cloud',
label: 'Cloud',
inputType: 'booleanSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'license_type',
value: 'license_type',
attributeName: 'License Type',
label: 'License Type',
inputType: 'searchSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [
{
id: 'Personal',
name: 'Personal',
},
{
id: 'Enterprise',
name: 'Enterprise',
},
{
id: 'Teams',
name: 'Teams',
},
{
id: 'Professional',
name: 'Professional',
},
],
attributeModel: 'customAttributes',
},
];
@@ -0,0 +1,66 @@
<script setup>
import { ref } from 'vue';
import FilterSelect from './FilterSelect.vue';
const options = [
{ value: 'EQUAL_TO', label: 'Equal To', icon: 'i-ph-equals-bold' },
{
value: 'NOT_EQUAL_TO',
label: 'Not Equal To',
icon: 'i-ph-not-equals-bold',
},
{ value: 'IS_PRESENT', label: 'Is Present', icon: 'i-ph-member-of-bold' },
{
value: 'IS_NOT_PRESENT',
label: 'Is Not Present',
icon: 'i-ph-not-member-of-bold',
},
{ value: 'CONTAINS', label: 'Contains', icon: 'i-ph-superset-of-bold' },
{
value: 'DOES_NOT_CONTAIN',
label: 'Does Not Contain',
icon: 'i-ph-not-superset-of-bold',
},
{
value: 'IS_GREATER_THAN',
label: 'Is Greater Than',
icon: 'i-ph-greater-than-bold',
},
{ value: 'IS_LESS_THAN', label: 'Is Less Than', icon: 'i-ph-less-than-bold' },
{
value: 'DAYS_BEFORE',
label: 'Days Before',
icon: 'i-ph-calendar-minus-bold',
},
{
value: 'STARTS_WITH',
label: 'Starts With',
icon: 'i-ph-caret-line-right-bold',
},
];
const selected = ref(options[0].value);
</script>
<template>
<Story
title="Components/Filters/Filter Select"
:layout="{ type: 'grid', width: '250px' }"
>
<Variant title="With Icon & Label">
<div class="min-h-[400px]">
<FilterSelect v-model="selected" :options="options" />
</div>
</Variant>
<Variant title="Without Icon">
<div class="min-h-[400px]">
<FilterSelect v-model="selected" hide-icon :options="options" />
</div>
</Variant>
<Variant title="Without Label">
<div class="min-h-[400px]">
<FilterSelect v-model="selected" hide-label :options="options" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,76 @@
<script setup>
import { computed } from 'vue';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
import Button from 'next/button/Button.vue';
// [{label, icon, value}]
const props = defineProps({
options: {
type: Array,
required: true,
},
hideLabel: {
type: Boolean,
default: false,
},
hideIcon: {
type: Boolean,
default: false,
},
variant: {
type: String,
default: 'faded',
},
});
const selected = defineModel({
type: [String, Number],
required: true,
});
const selectedOption = computed(() => {
return props.options.find(o => o.value === selected.value) || {};
});
const iconToRender = computed(() => {
if (props.hideIcon) return null;
return selectedOption.value.icon || 'i-lucide-chevron-down';
});
const updateSelected = newValue => {
selected.value = newValue;
};
</script>
<template>
<DropdownContainer>
<template #trigger="{ toggle }">
<slot name="trigger" :toggle="toggle">
<Button
sm
slate
:variant
:icon="iconToRender"
:trailing-icon="selectedOption.icon ? false : true"
:label="hideLabel ? null : selectedOption.label"
@click="toggle"
/>
</slot>
</template>
<DropdownBody class="top-0 min-w-48 z-[999]">
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="option in options"
:key="option.value"
:label="option.label"
:icon="option.icon"
@click="updateSelected(option.value)"
/>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
</template>
@@ -0,0 +1,26 @@
<script setup>
import { ref } from 'vue';
import MultiSelect from './MultiSelect.vue';
const options = [
{ name: 'Open', id: 'open' },
{ name: 'Closed', id: 'closed' },
{ name: 'Pending', id: 'pending' },
{ name: 'Resolved', id: 'resolved' },
{ name: 'Spam', id: 'spam' },
{ name: 'All', id: 'all' },
];
const selected = ref([]);
</script>
<template>
<Story
title="Components/Filters/Multiselect Input"
:layout="{ type: 'grid', width: '600px' }"
>
<div class="min-h-[400px]">
<MultiSelect v-model="selected" :options="options" />
</div>
</Story>
</template>
@@ -0,0 +1,146 @@
<script setup>
import { defineModel, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options, maxChips } = defineProps({
options: {
type: Array,
required: true,
},
maxChips: {
type: Number,
default: 3,
},
});
const { t } = useI18n();
const selected = defineModel({
type: [Array, String],
required: true,
});
const hasItems = computed(() => {
if (!selected.value) return false;
if (!Array.isArray(selected.value)) return false;
if (selected.value.length === 0) return false;
return true;
});
const selectedIds = computed(() => {
if (!hasItems.value) return [];
return selected.value.map(value => value.id);
});
const selectedItems = computed(() => {
// Options has additional properties, so we need to use them directly
if (!hasItems.value) return [];
return options.filter(option => selectedIds.value.includes(option.id));
});
const selectedVisibleItems = computed(() => {
if (!hasItems.value) return [];
// avoid showing "+1 more" coz it takes up space anway, might as well show it
if (selectedItems.value.length === maxChips + 1) return selectedItems.value;
// if we have more than maxChips then show only maxChips
return selectedItems.value.slice(0, maxChips);
});
const remainingItems = computed(() => {
if (!hasItems.value) return [];
if (selectedItems.value.length === maxChips + 1) return [];
return selectedItems.value.slice(maxChips);
});
const remainingTooltip = computed(() => {
if (!hasItems.value) return '';
return remainingItems.value.map(item => item.name).join(', ');
});
const toggleOption = option => {
// Ensure that the `icon` prop is not included, icon is a VNode which has circular references
// This causes an error when creating a clone using JSON.parse(JSON.stringify())
const optionToToggle = {
id: option.id,
name: option.name,
};
const idToToggle = optionToToggle.id;
if (!hasItems.value) {
selected.value = [optionToToggle];
return;
}
if (selectedIds.value.includes(idToToggle)) {
selected.value = selected.value.filter(value => value.id !== idToToggle);
} else {
selected.value = [...selected.value, optionToToggle];
}
};
</script>
<template>
<DropdownContainer>
<template #trigger="{ toggle }">
<button
v-if="hasItems"
class="bg-n-alpha-2 py-2 rounded-lg h-8 flex items-center px-0"
@click="toggle"
>
<div
v-for="item in selectedVisibleItems"
:key="item.name"
class="px-3 border-r rtl:border-l rtl:border-r-0 border-n-weak text-n-slate-12 text-sm flex gap-2 items-center max-w-[100px]"
>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
<span class="truncate">{{ item.name }}</span>
</div>
<div
v-if="remainingItems.length > 0"
v-tooltip.top="remainingTooltip"
class="px-3 border-r rtl:border-l rtl:border-r-0 border-n-weak text-n-slate-12 text-sm flex gap-2 items-center max-w-[100px]"
>
<span class="truncate">{{
t('COMBOBOX.MORE', { count: remainingItems.length })
}}</span>
</div>
<div class="flex items-center border-none px-3 gap-2">
<Icon icon="i-lucide-plus" />
</div>
</button>
<Button v-else sm slate faded @click="toggle">
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
</template>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-48 z-[999]">
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="option in options"
:key="option.id"
:icon="option.icon"
preserve-open
@click="toggleOption(option)"
>
<template #label>
{{ option.name }}
<Icon
v-if="selectedIds.includes(option.id)"
icon="i-lucide-check"
class="bg-n-blue-text pointer-events-none"
/>
</template>
</DropdownItem>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
</template>
@@ -0,0 +1,33 @@
<script setup>
import { ref } from 'vue';
import SingleSelect from './SingleSelect.vue';
const options = [
{ name: 'Open', id: 'open' },
{ name: 'Closed', id: 'closed' },
{ name: 'Pending', id: 'pending' },
{ name: 'Resolved', id: 'resolved' },
{ name: 'Spam', id: 'spam' },
{ name: 'All', id: 'all' },
];
const selected = ref(options[0]);
</script>
<template>
<Story
title="Components/Filters/Single Select Input"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="With Search">
<div class="min-h-[400px]">
<SingleSelect v-model="selected" :options="options" />
</div>
</Variant>
<Variant title="Without Search">
<div class="min-h-[400px]">
<SingleSelect v-model="selected" disable-search :options="options" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,124 @@
<script setup>
import { defineModel, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { picoSearch } from '@scmmishra/pico-search';
import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options } = defineProps({
options: {
type: Array,
required: true,
},
disableSearch: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const selected = defineModel({
type: Object,
required: true,
});
const searchTerm = ref('');
const searchResults = computed(() => {
if (!options) return [];
return picoSearch(options, searchTerm.value, ['name']);
});
const selectedItem = computed(() => {
if (!options) return null;
if (!selected.value) return null;
// there are cases where the selected value is an array
const optionToSearch = Array.isArray(selected.value)
? selected.value[0]
: selected.value;
// extract the selected item from the options array
// this ensures that options like icon is also included
return options.find(option => option.id === optionToSearch.id);
});
const toggleSelected = option => {
// Ensure that the `icon` prop is not included, icon is a VNode which has circular references
// This causes an error when creating a clone using JSON.parse(JSON.stringify())
const optionToToggle = {
id: option.id,
name: option.name,
};
if (selected.value && selected.value.id === optionToToggle.id) {
selected.value = null;
} else {
selected.value = optionToToggle;
}
};
</script>
<template>
<DropdownContainer>
<template #trigger="{ toggle }">
<Button
v-if="selectedItem"
sm
slate
faded
:icon="selectedItem.icon"
:label="selectedItem.name"
@click="toggle"
/>
<Button v-else sm slate faded @click="toggle">
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
</template>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-56 z-[999]">
<div v-if="!disableSearch" class="relative">
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
<input
v-model="searchTerm"
autofocus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection class="max-h-80 overflow-scroll">
<template v-if="searchResults.length">
<DropdownItem
v-for="option in searchResults"
:key="option.id"
:icon="option.icon"
@click="toggleSelected(option)"
>
<template #label>
{{ option.name }}
<Icon
v-if="selectedItem && selectedItem.id === option.id"
icon="i-lucide-check"
class="bg-n-blue-text pointer-events-none"
/>
</template>
</DropdownItem>
</template>
<template v-else-if="searchTerm">
<DropdownItem disabled>
{{ t('COMBOBOX.EMPTY_SEARCH_RESULTS', { searchTerm: searchTerm }) }}
</DropdownItem>
</template>
<template v-else>
<DropdownItem disabled>
{{ t('COMBOBOX.EMPTY_STATE') }}
</DropdownItem>
</template>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
</template>
@@ -249,11 +249,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CSAT'),
to: accountScopedRoute('csat_reports'),
},
{
name: 'Reports Bot',
label: t('SIDEBAR.REPORTS_BOT'),
to: accountScopedRoute('bot_reports'),
},
{
name: 'Reports Agent',
label: t('SIDEBAR.REPORTS_AGENT'),
@@ -279,6 +274,11 @@ const menuItems = computed(() => {
label: t('SIDEBAR.REPORTS_SLA'),
to: accountScopedRoute('sla_reports'),
},
{
name: 'Reports Bot',
label: t('SIDEBAR.REPORTS_BOT'),
to: accountScopedRoute('bot_reports'),
},
],
},
{
@@ -54,7 +54,7 @@ const emitNewAccount = () => {
/>
</button>
</template>
<DropdownBody class="min-w-80">
<DropdownBody class="min-w-80 z-50">
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_WORKSPACE')">
<DropdownItem
v-for="account in currentUser.accounts"
@@ -0,0 +1,56 @@
<script setup>
import Switch from './Switch.vue';
import { ref } from 'vue';
// Default varian
const isEnabled = ref(false);
// States variant
const defaultValue = ref(false);
const checkedValue = ref(true);
// Events variant
const eventValue = ref(false);
const lastChange = ref('No changes yet');
const onChange = value => {
lastChange.value = `Changed to: ${value} at ${new Date().toLocaleTimeString()}`;
};
</script>
<template>
<Story title="Components/Switch" :layout="{ type: 'grid', width: '200px' }">
<Variant title="Default">
<div class="p-2">
<Switch v-model="isEnabled" />
</div>
</Variant>
<Variant title="States">
<div class="p-2 space-y-4">
<div class="flex items-center gap-4">
<span class="w-20">Default:</span>
<Switch v-model="defaultValue" />
</div>
<div class="flex items-center gap-4">
<span class="w-20">Checked:</span>
<Switch v-model="checkedValue" />
</div>
</div>
</Variant>
<Variant title="Events">
<div class="p-2 space-y-4">
<Switch v-model="eventValue" @change="onChange" />
<div class="text-sm text-gray-600">Last change: {{ lastChange }}</div>
</div>
</Variant>
<Variant title="Disabled">
<div class="p-2">
<Switch v-model="isEnabled" disabled />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,38 @@
<script setup>
import { useI18n } from 'vue-i18n';
const emit = defineEmits(['change']);
const { t } = useI18n();
const modelValue = defineModel({
type: Boolean,
default: false,
});
const updateValue = () => {
modelValue.value = !modelValue.value;
emit('change', !modelValue.value);
};
</script>
<template>
<button
type="button"
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-primary-500 focus:ring-offset-n-slate-2 focus:ring-offset-2"
:class="modelValue ? 'bg-n-brand' : 'bg-n-alpha-1 dark:bg-n-alpha-2'"
role="switch"
:aria-checked="modelValue"
@click="updateValue"
>
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
<span
class="absolute top-px left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out"
:class="
modelValue
? 'translate-x-2.5 bg-white'
: 'translate-x-0 bg-white dark:bg-n-black'
"
/>
</button>
</template>