Merge branch 'develop' into feature/service-worker-caching
This commit is contained in:
@@ -164,10 +164,4 @@ export default {
|
||||
.v-popper--theme-tooltip .v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
|
||||
|
||||
@@ -57,14 +57,14 @@ class ContactAPI extends ApiClient {
|
||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||
}
|
||||
|
||||
search(search = '', page = 1, sortAttr = 'name', label = '') {
|
||||
search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) {
|
||||
let requestURL = `${this.url}/search?${buildContactParams(
|
||||
page,
|
||||
sortAttr,
|
||||
label,
|
||||
search
|
||||
)}`;
|
||||
return axios.get(requestURL);
|
||||
return axios.get(requestURL, { signal: options.signal });
|
||||
}
|
||||
|
||||
active(page = 1, sortAttr = 'name') {
|
||||
|
||||
@@ -25,6 +25,12 @@ class CategoriesAPI extends PortalsAPI {
|
||||
delete({ portalSlug, categoryId }) {
|
||||
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
|
||||
}
|
||||
|
||||
reorder({ portalSlug, reorderedGroup }) {
|
||||
return axios.post(`${this.url}/${portalSlug}/categories/reorder`, {
|
||||
positions_hash: reorderedGroup,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CategoriesAPI();
|
||||
|
||||
@@ -42,6 +42,12 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
getCSATTemplateStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
||||
}
|
||||
|
||||
analyzeCSATTemplateUtility(inboxId, template) {
|
||||
return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
|
||||
template,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -68,7 +68,19 @@ describe('#ContactsAPI', () => {
|
||||
it('#search', () => {
|
||||
contactAPI.search('leads', 1, 'date', 'customer-support');
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
|
||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
|
||||
{ signal: undefined }
|
||||
);
|
||||
});
|
||||
|
||||
it('#search with signal', () => {
|
||||
const controller = new AbortController();
|
||||
contactAPI.search('leads', 1, 'date', 'customer-support', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,5 +8,6 @@ describe('#BulkActionsAPI', () => {
|
||||
expect(categoriesAPI).toHaveProperty('create');
|
||||
expect(categoriesAPI).toHaveProperty('update');
|
||||
expect(categoriesAPI).toHaveProperty('delete');
|
||||
expect(categoriesAPI).toHaveProperty('reorder');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ textarea {
|
||||
// Field base styles (Input, TextArea, Select)
|
||||
@layer components {
|
||||
.field-base {
|
||||
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
|
||||
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
|
||||
}
|
||||
|
||||
.field-disabled {
|
||||
@@ -78,7 +78,7 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)";
|
||||
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.no-margin)";
|
||||
|
||||
#{$form-input-selector} {
|
||||
@apply field-base h-10;
|
||||
@@ -92,7 +92,7 @@ $form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not
|
||||
}
|
||||
}
|
||||
|
||||
input[type='file']:not(.multiselect__input) {
|
||||
input[type='file'] {
|
||||
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
|
||||
}
|
||||
|
||||
@@ -126,13 +126,6 @@ label:has(.help-text) {
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling
|
||||
.has-multi-select-error {
|
||||
div.multiselect {
|
||||
@apply mb-1;
|
||||
}
|
||||
}
|
||||
|
||||
// FormKit support
|
||||
.formkit-outer[data-invalid='true'] {
|
||||
#{$form-input-selector},
|
||||
@@ -150,9 +143,7 @@ label:has(.help-text) {
|
||||
#{$form-input-selector},
|
||||
input:not([type]),
|
||||
textarea,
|
||||
select,
|
||||
.multiselect > .multiselect__tags,
|
||||
.multiselect:not(.no-margin) {
|
||||
select {
|
||||
@apply field-error;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
@import 'base';
|
||||
|
||||
// Plugins
|
||||
@import 'plugins/multiselect';
|
||||
@import 'plugins/date-picker';
|
||||
|
||||
html,
|
||||
@@ -66,4 +65,84 @@ body {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* TYPOGRAPHY UTILITIES
|
||||
* ============================================================================
|
||||
*
|
||||
* | Class | Use Case |
|
||||
* |--------------------|----------------------------------------------------|
|
||||
* | .text-body-main | <p>, <span>, general body text |
|
||||
* | .text-body-para | <p> for paragraphs, larger text blocks |
|
||||
* | .text-heading-1 | <h1>, page titles, panel headers |
|
||||
* | .text-heading-2 | <h2>, section headings, card titles |
|
||||
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
|
||||
* | .text-label | <label>, form labels, field names |
|
||||
* | .text-label-small | <small>, footnotes, tags, badges, captions |
|
||||
* | .text-button | <button>, standard button text |
|
||||
* | .text-button-small | <button>, small/compact button text |
|
||||
*/
|
||||
|
||||
/* body-text-main: Main text style for general body text */
|
||||
.text-body-main {
|
||||
@apply font-inter text-sm font-420;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
/* body-text-paragraph: For paragraphs or larger blocks of text */
|
||||
.text-body-para {
|
||||
@apply font-inter text-sm font-420;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.21px;
|
||||
}
|
||||
|
||||
/* heading-1: Large heading for pages and panels */
|
||||
.text-heading-1 {
|
||||
@apply font-inter text-lg font-520;
|
||||
line-height: 24px; /* 133.333% */
|
||||
letter-spacing: -0.27px;
|
||||
}
|
||||
|
||||
/* heading-2: Secondary heading for sections */
|
||||
.text-heading-2 {
|
||||
@apply font-inter text-base font-medium;
|
||||
line-height: 24px; /* 133.333% */
|
||||
letter-spacing: -0.27px;
|
||||
}
|
||||
|
||||
/* heading-3: For card headings, breadcrumbs, subsections */
|
||||
.text-heading-3 {
|
||||
@apply font-inter text-sm font-medium;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.27px;
|
||||
}
|
||||
|
||||
/* label: Standard label text for form fields */
|
||||
.text-label {
|
||||
@apply font-inter text-sm font-medium;
|
||||
line-height: 21px; /* 150% */
|
||||
}
|
||||
|
||||
/* label-small: Smallest font for labels, footnotes, tags */
|
||||
.text-label-small {
|
||||
@apply font-inter text-xs font-440;
|
||||
line-height: 16px; /* 133.333% */
|
||||
letter-spacing: -0.24px;
|
||||
}
|
||||
|
||||
/* button-text: Text for standard size buttons */
|
||||
.text-button {
|
||||
@apply font-inter text-sm font-460;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
/* button-text-small: Text for smaller buttons */
|
||||
.text-button-small {
|
||||
@apply font-inter text-xs font-440;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: -0.24px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
@mixin label-multiselect-hover {
|
||||
&::after {
|
||||
@apply text-n-brand;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-n-slate-3;
|
||||
|
||||
&::after {
|
||||
@apply text-n-blue-11;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect {
|
||||
&:not(.no-margin) {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
&.invalid .multiselect__tags {
|
||||
@apply border-0 outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
|
||||
}
|
||||
|
||||
&.multiselect--disabled {
|
||||
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
|
||||
|
||||
.multiselect__select {
|
||||
@apply cursor-not-allowed bg-transparent rounded-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect--active {
|
||||
> .multiselect__tags {
|
||||
@apply outline-n-blue-border;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__select {
|
||||
@apply min-h-[2.875rem] p-0 right-0 top-0;
|
||||
|
||||
&::before {
|
||||
@apply right-0;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__content-wrapper {
|
||||
@apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg;
|
||||
}
|
||||
|
||||
.multiselect__content {
|
||||
@apply max-w-full;
|
||||
|
||||
.multiselect__option {
|
||||
@apply text-sm font-normal flex justify-between items-center;
|
||||
|
||||
span {
|
||||
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight {
|
||||
@apply bg-n-alpha-black2 text-n-slate-12;
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight:hover {
|
||||
@apply bg-n-brand/10 text-n-slate-12;
|
||||
|
||||
&::after {
|
||||
@apply bg-transparent text-center text-n-slate-12;
|
||||
}
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight::after {
|
||||
@apply bg-transparent text-n-slate-12;
|
||||
}
|
||||
|
||||
&.multiselect__option--selected {
|
||||
@apply bg-n-brand/20 text-n-slate-12;
|
||||
|
||||
&::after {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
&.multiselect__option--highlight:hover {
|
||||
@apply bg-transparent;
|
||||
|
||||
&::after:hover {
|
||||
@apply text-n-slate-12 bg-transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__tags {
|
||||
@apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0;
|
||||
|
||||
input {
|
||||
@apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__spinner {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.multiselect__tags-wrap {
|
||||
@apply inline-block leading-none mt-1;
|
||||
}
|
||||
|
||||
.multiselect__placeholder {
|
||||
@apply text-n-slate-10 font-normal pt-3;
|
||||
}
|
||||
|
||||
.multiselect__tag {
|
||||
@apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5;
|
||||
}
|
||||
|
||||
.multiselect__tag-icon {
|
||||
@include label-multiselect-hover;
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
@apply text-sm h-[2.875rem] mb-0 p-0 shadow-none border-transparent hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none active:border-transparent active:shadow-none;
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-labels-wrap {
|
||||
&.has-edited,
|
||||
&:hover {
|
||||
.multiselect {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect {
|
||||
> .multiselect__select {
|
||||
@apply invisible;
|
||||
}
|
||||
|
||||
> .multiselect__tags {
|
||||
@apply outline-transparent;
|
||||
}
|
||||
|
||||
&.multiselect--active > .multiselect__tags {
|
||||
@apply outline-n-blue-border;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect-wrap--small {
|
||||
// To be removed one SLA reports date picker is created
|
||||
&.tiny {
|
||||
.multiselect.no-margin {
|
||||
@apply min-h-[32px];
|
||||
}
|
||||
|
||||
.multiselect__select {
|
||||
@apply min-h-[32px] h-8;
|
||||
|
||||
&::before {
|
||||
@apply top-[60%];
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__tags {
|
||||
@apply min-h-[32px] max-h-[32px];
|
||||
|
||||
.multiselect__single {
|
||||
@apply pt-1 pb-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__tags,
|
||||
.multiselect__input,
|
||||
.multiselect {
|
||||
@apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
@apply h-[2.375rem] min-h-[2.375rem];
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5;
|
||||
}
|
||||
|
||||
.multiselect__placeholder {
|
||||
@apply m-0 py-2 px-0.5;
|
||||
}
|
||||
|
||||
.multiselect__tag {
|
||||
@apply py-[6px] my-[1px];
|
||||
}
|
||||
|
||||
.multiselect__select {
|
||||
@apply min-h-[2.5rem];
|
||||
}
|
||||
|
||||
.multiselect--disabled .multiselect__current,
|
||||
.multiselect--disabled .multiselect__select {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect--disabled {
|
||||
background-color: rgba(var(--black-alpha-2)) !important;
|
||||
|
||||
.multiselect__tags {
|
||||
@apply hover:outline-n-weak;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect--active {
|
||||
.multiselect__select::before {
|
||||
@apply top-[62%];
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__select::before {
|
||||
top: 60% !important;
|
||||
}
|
||||
|
||||
.multiselect-wrap--medium {
|
||||
.multiselect__tags,
|
||||
.multiselect__input {
|
||||
@apply items-center flex;
|
||||
}
|
||||
|
||||
.multiselect__tags,
|
||||
.multiselect__input,
|
||||
.multiselect {
|
||||
@apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem];
|
||||
}
|
||||
|
||||
.multiselect__input {
|
||||
@apply h-[2.875rem] min-h-[2.875rem];
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
@apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12;
|
||||
}
|
||||
|
||||
.multiselect__placeholder {
|
||||
@apply m-0 py-1 px-0.5;
|
||||
}
|
||||
|
||||
.multiselect__select {
|
||||
@apply min-h-[3rem];
|
||||
}
|
||||
|
||||
.multiselect--disabled .multiselect__current,
|
||||
.multiselect--disabled .multiselect__select {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
.multiselect__tags-wrap {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -49,7 +49,7 @@ const handleFetchUsers = () => {
|
||||
<div class="flex flex-col gap-2 relative justify-between w-full">
|
||||
<div class="flex items-center gap-3 justify-between w-full">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
|
||||
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
|
||||
{{ name }}
|
||||
</h3>
|
||||
<CardPopover
|
||||
@@ -78,7 +78,7 @@ const handleFetchUsers = () => {
|
||||
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
|
||||
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -20,7 +20,7 @@ const handleClick = () => {
|
||||
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<div class="flex justify-between w-full items-center">
|
||||
<h3 class="text-n-slate-12 text-base font-medium">{{ title }}</h3>
|
||||
<h3 class="text-n-slate-12 text-heading-2">{{ title }}</h3>
|
||||
<Button
|
||||
xs
|
||||
slate
|
||||
@@ -29,14 +29,14 @@ const handleClick = () => {
|
||||
@click.stop="handleClick"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-sm mb-0">{{ description }}</p>
|
||||
<p class="text-n-slate-11 text-body-para mb-0">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-col items-start gap-3 mt-3">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
:key="feature.id"
|
||||
class="flex items-center gap-3 text-sm"
|
||||
class="flex items-center gap-3 text-body-para"
|
||||
>
|
||||
<Icon
|
||||
:icon="feature.icon"
|
||||
|
||||
-3
@@ -39,7 +39,6 @@ const policyA = withCount({
|
||||
description: 'Distributes conversations evenly among available agents',
|
||||
assignmentOrder: 'round_robin',
|
||||
conversationPriority: 'high',
|
||||
enabled: true,
|
||||
inboxes: [mockInboxes[0], mockInboxes[1]],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
@@ -50,7 +49,6 @@ const policyB = withCount({
|
||||
description: 'Assigns based on capacity and workload',
|
||||
assignmentOrder: 'capacity_based',
|
||||
conversationPriority: 'medium',
|
||||
enabled: true,
|
||||
inboxes: [mockInboxes[2], mockInboxes[3]],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
@@ -61,7 +59,6 @@ const emptyPolicy = withCount({
|
||||
description: 'Policy with no assigned inboxes',
|
||||
assignmentOrder: 'manual',
|
||||
conversationPriority: 'low',
|
||||
enabled: false,
|
||||
inboxes: [],
|
||||
isFetchingInboxes: false,
|
||||
});
|
||||
|
||||
+14
-35
@@ -15,7 +15,6 @@ const props = defineProps({
|
||||
assignmentOrder: { type: String, default: '' },
|
||||
conversationPriority: { type: String, default: '' },
|
||||
assignedInboxCount: { type: Number, default: 0 },
|
||||
enabled: { type: Boolean, default: false },
|
||||
inboxes: { type: Array, default: () => [] },
|
||||
isFetchingInboxes: { type: Boolean, default: false },
|
||||
});
|
||||
@@ -61,39 +60,19 @@ const handleFetchInboxes = () => {
|
||||
<div class="flex flex-col gap-2 relative justify-between w-full">
|
||||
<div class="flex items-center gap-3 justify-between w-full">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
|
||||
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
|
||||
{{ name }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
|
||||
<span
|
||||
class="text-xs"
|
||||
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
|
||||
>
|
||||
{{
|
||||
enabled
|
||||
? t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
|
||||
)
|
||||
: t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<CardPopover
|
||||
:title="
|
||||
t(
|
||||
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER'
|
||||
)
|
||||
"
|
||||
icon="i-lucide-inbox"
|
||||
:count="assignedInboxCount"
|
||||
:items="inboxes"
|
||||
:is-fetching="isFetchingInboxes"
|
||||
@fetch="handleFetchInboxes"
|
||||
/>
|
||||
</div>
|
||||
<CardPopover
|
||||
:title="
|
||||
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER')
|
||||
"
|
||||
icon="i-lucide-inbox"
|
||||
:count="assignedInboxCount"
|
||||
:items="inboxes"
|
||||
:is-fetching="isFetchingInboxes"
|
||||
@fetch="handleFetchInboxes"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -110,18 +89,18 @@ const handleFetchInboxes = () => {
|
||||
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
|
||||
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
|
||||
{{ description }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3 py-1.5">
|
||||
<span v-if="order" class="text-n-slate-11 text-sm">
|
||||
<span v-if="order" class="text-n-slate-11 text-body-para">
|
||||
{{
|
||||
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
|
||||
}}
|
||||
<span class="text-n-slate-12">{{ order }}</span>
|
||||
</span>
|
||||
<div v-if="order" class="w-px h-3 bg-n-strong" />
|
||||
<span v-if="priority" class="text-n-slate-11 text-sm">
|
||||
<span v-if="priority" class="text-n-slate-11 text-body-para">
|
||||
{{
|
||||
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
|
||||
}}
|
||||
|
||||
@@ -19,11 +19,15 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
const emit = defineEmits(['delete', 'navigate']);
|
||||
|
||||
const handleDelete = itemId => {
|
||||
emit('delete', itemId);
|
||||
};
|
||||
|
||||
const handleNavigate = item => {
|
||||
emit('navigate', item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,7 +51,11 @@ const handleDelete = itemId => {
|
||||
:key="item.id"
|
||||
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 col-span-2 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 rounded-lg py-1 px-1.5 -ml-1.5 transition-colors cursor-pointer group"
|
||||
@click="handleNavigate(item)"
|
||||
>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
@@ -61,10 +69,16 @@ const handleDelete = itemId => {
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 truncate min-w-0">
|
||||
<span
|
||||
class="text-sm text-n-slate-12 truncate min-w-0 group-hover:text-n-blue-11 dark:group-hover:text-n-blue-10 transition-colors"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
<Icon
|
||||
icon="i-lucide-external-link"
|
||||
class="size-3.5 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-2 col-span-1">
|
||||
<span
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
|
||||
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
|
||||
|
||||
+18
-4
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
@@ -15,6 +15,9 @@ const fairDistributionLimit = defineModel('fairDistributionLimit', {
|
||||
},
|
||||
});
|
||||
|
||||
// The model value is in seconds (for the backend/DB)
|
||||
// DurationInput works in minutes internally
|
||||
// We need to convert between seconds and minutes
|
||||
const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
||||
type: Number,
|
||||
default: 3600,
|
||||
@@ -25,6 +28,17 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
||||
|
||||
const windowUnit = ref(DURATION_UNITS.MINUTES);
|
||||
|
||||
// Convert seconds to minutes for DurationInput
|
||||
const windowInMinutes = computed({
|
||||
get() {
|
||||
return Math.floor((fairDistributionWindow.value || 0) / 60);
|
||||
},
|
||||
set(minutes) {
|
||||
fairDistributionWindow.value = minutes * 60;
|
||||
},
|
||||
});
|
||||
|
||||
// Detect unit based on minutes (converted from seconds)
|
||||
const detectUnit = minutes => {
|
||||
const m = Number(minutes) || 0;
|
||||
if (m === 0) return DURATION_UNITS.MINUTES;
|
||||
@@ -34,7 +48,7 @@ const detectUnit = minutes => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
windowUnit.value = detectUnit(fairDistributionWindow.value);
|
||||
windowUnit.value = detectUnit(windowInMinutes.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -73,9 +87,9 @@ onMounted(() => {
|
||||
<div
|
||||
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
||||
>
|
||||
<!-- allow 10 mins to 999 days -->
|
||||
<!-- allow 10 mins to 999 days (in minutes) -->
|
||||
<DurationInput
|
||||
v-model:model-value="fairDistributionWindow"
|
||||
v-model:model-value="windowInMinutes"
|
||||
v-model:unit="windowUnit"
|
||||
:min="10"
|
||||
:max="1438560"
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const handleChange = () => {
|
||||
if (!props.isActive) {
|
||||
emit('select', props.id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
|
||||
:class="[
|
||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
|
||||
]"
|
||||
@click="handleChange"
|
||||
>
|
||||
<div class="absolute top-4 right-4">
|
||||
<input
|
||||
:id="`${id}`"
|
||||
:checked="isActive"
|
||||
:value="id"
|
||||
:name="id"
|
||||
type="radio"
|
||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-col gap-3 items-start">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ label }}
|
||||
</h3>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-4
@@ -6,7 +6,6 @@ const policyName = ref('Round Robin Policy');
|
||||
const description = ref(
|
||||
'Distributes conversations evenly among available agents'
|
||||
);
|
||||
const enabled = ref(true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,13 +18,10 @@ const enabled = ref(true);
|
||||
<BaseInfo
|
||||
v-model:policy-name="policyName"
|
||||
v-model:description="description"
|
||||
v-model:enabled="enabled"
|
||||
name-label="Policy Name"
|
||||
name-placeholder="Enter policy name"
|
||||
description-label="Description"
|
||||
description-placeholder="Enter policy description"
|
||||
status-label="Status"
|
||||
status-placeholder="Active"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
@@ -21,14 +22,18 @@ const handleButtonClick = () => {
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
||||
<header class="sticky top-0 z-10 px-6 lg:px-0">
|
||||
<div class="w-full max-w-[60rem] mx-auto">
|
||||
<header class="sticky top-0 z-10 px-6">
|
||||
<div class="w-full max-w-5xl 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">
|
||||
<span class="text-heading-1 text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div
|
||||
v-on-clickaway="() => emit('close')"
|
||||
v-on-click-outside="[
|
||||
() => emit('close'),
|
||||
// This will prevent closing the modal when the editor Create link popup is open
|
||||
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
|
||||
]"
|
||||
class="relative group/campaign-button"
|
||||
>
|
||||
<Button
|
||||
@@ -43,8 +48,8 @@ const handleButtonClick = () => {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||
<div class="w-full max-w-[60rem] mx-auto py-4">
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
<div class="w-full max-w-5xl mx-auto py-4">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -19,7 +19,7 @@ const handleClick = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
|
||||
class="flex flex-col w-full outline-1 outline outline-n-container -outline-offset-1 group/cardLayout rounded-xl bg-n-solid-2"
|
||||
>
|
||||
<div
|
||||
class="flex w-full gap-3 py-5"
|
||||
|
||||
+3
-3
@@ -15,11 +15,11 @@ const emit = defineEmits(['search', 'update:sort']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-10">
|
||||
<header class="sticky top-0 z-10 px-6">
|
||||
<div
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||
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">
|
||||
<span class="text-heading-1 truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div class="flex items-center flex-row flex-shrink-0 gap-2">
|
||||
|
||||
@@ -32,17 +32,18 @@ const updateCurrentPage = page => {
|
||||
@search="emit('search', $event)"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="w-full mx-auto max-w-[60rem]">
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
<div class="w-full mx-auto max-w-5xl py-4">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
|
||||
<PaginationFooter
|
||||
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
:total-items="totalItems"
|
||||
:items-per-page="25"
|
||||
class="max-w-[67rem]"
|
||||
@update:current-page="updateCurrentPage"
|
||||
/>
|
||||
</footer>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { computed, watch, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
|
||||
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
|
||||
import AddLabel from 'dashboard/components-next/Label/AddLabel.vue';
|
||||
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
|
||||
import AddLabel from 'dashboard/components-next/label/AddLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contactId: {
|
||||
|
||||
@@ -44,6 +44,7 @@ const SOCIAL_CONFIG = {
|
||||
LINKEDIN: 'i-ri-linkedin-box-fill',
|
||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||
INSTAGRAM: 'i-ri-instagram-line',
|
||||
TELEGRAM: 'i-ri-telegram-fill',
|
||||
TIKTOK: 'i-ri-tiktok-fill',
|
||||
TWITTER: 'i-ri-twitter-x-fill',
|
||||
GITHUB: 'i-ri-github-fill',
|
||||
@@ -66,6 +67,7 @@ const defaultState = {
|
||||
facebook: '',
|
||||
github: '',
|
||||
instagram: '',
|
||||
telegram: '',
|
||||
tiktok: '',
|
||||
linkedin: '',
|
||||
twitter: '',
|
||||
@@ -103,9 +105,13 @@ const prepareStateBasedOnProps = () => {
|
||||
countryCode = '',
|
||||
country = '',
|
||||
city = '',
|
||||
socialTelegramUserName = '',
|
||||
socialProfiles = {},
|
||||
} = additionalAttributes || {};
|
||||
|
||||
const telegramUsername =
|
||||
socialProfiles?.telegram || socialTelegramUserName || '';
|
||||
|
||||
Object.assign(state, {
|
||||
id,
|
||||
name,
|
||||
@@ -119,7 +125,10 @@ const prepareStateBasedOnProps = () => {
|
||||
countryCode,
|
||||
country,
|
||||
city,
|
||||
socialProfiles,
|
||||
socialProfiles: {
|
||||
...socialProfiles,
|
||||
telegram: telegramUsername,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -32,9 +32,9 @@ const emit = defineEmits([
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-10">
|
||||
<header class="sticky top-0 z-10 px-6">
|
||||
<div
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||
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 }}
|
||||
|
||||
+1
-2
@@ -68,8 +68,7 @@ const hasActiveSegments = computed(
|
||||
);
|
||||
const activeSegmentName = computed(() => props.activeSegment?.name);
|
||||
|
||||
const openCreateNewContactDialog = async () => {
|
||||
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
|
||||
const openCreateNewContactDialog = () => {
|
||||
createNewContactDialogRef.value?.dialogRef.open();
|
||||
};
|
||||
const openContactImportDialog = () =>
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
|
||||
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
|
||||
"
|
||||
:show-clear-button="!hasActiveSegments"
|
||||
class="max-w-[60rem] px-6"
|
||||
class="max-w-5xl"
|
||||
@open-filter="emit('openFilter')"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
/>
|
||||
|
||||
@@ -98,8 +98,8 @@ const showPagination = computed(() => {
|
||||
@apply-filter="emit('applyFilter', $event)"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
/>
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="w-full mx-auto max-w-[60rem]">
|
||||
<main class="flex-1 overflow-y-auto px-6">
|
||||
<div class="w-full mx-auto max-w-5xl">
|
||||
<ContactsActiveFiltersPreview
|
||||
v-if="showActiveFiltersPreview"
|
||||
:active-segment="activeSegment"
|
||||
@@ -114,11 +114,12 @@ const showPagination = computed(() => {
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<footer v-if="showPagination" class="sticky bottom-0 z-0">
|
||||
<PaginationFooter
|
||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
:total-items="totalItems"
|
||||
class="max-w-[67rem]"
|
||||
:items-per-page="itemsPerPage"
|
||||
@update:current-page="updateCurrentPage"
|
||||
/>
|
||||
|
||||
+49
-42
@@ -1,8 +1,10 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
@@ -18,7 +20,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['edit', 'delete']);
|
||||
|
||||
const iconByType = {
|
||||
text: 'i-lucide-align-justify',
|
||||
text: 'i-lucide-menu',
|
||||
checkbox: 'i-lucide-circle-check-big',
|
||||
list: 'i-lucide-list',
|
||||
date: 'i-lucide-calendar',
|
||||
@@ -28,61 +30,66 @@ const iconByType = {
|
||||
|
||||
const attributeIcon = computed(() => {
|
||||
const typeKey = props.attribute.type?.toLowerCase();
|
||||
return iconByType[typeKey] || 'i-lucide-align-justify';
|
||||
return iconByType[typeKey] || 'i-lucide-menu';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2 justify-between items-center">
|
||||
<div class="flex flex-wrap gap-2 items-center min-w-0">
|
||||
<h4 class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ attribute.label }}
|
||||
</h4>
|
||||
<div class="w-px h-3 bg-n-strong" />
|
||||
<div class="flex gap-2 items-center text-sm text-n-slate-11">
|
||||
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||
<Icon :icon="attributeIcon" class="size-4" />
|
||||
<span class="text-sm">{{ attribute.type }}</span>
|
||||
<div class="flex flex-col py-4 min-w-0">
|
||||
<div class="flex justify-between flex-row items-center gap-4 min-w-0">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<div
|
||||
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
|
||||
>
|
||||
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 items-start min-w-0 overflow-hidden">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<h4 class="text-heading-3 truncate text-n-slate-12 min-w-0">
|
||||
{{ attribute.label }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Label :label="attribute.type" compact />
|
||||
<AttributeBadge
|
||||
v-for="badge in badges"
|
||||
:key="badge.type"
|
||||
:type="badge.type"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-px h-3 bg-n-weak" />
|
||||
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||
<Icon icon="i-lucide-key-round" class="size-4" />
|
||||
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-1.5">
|
||||
<Icon icon="i-lucide-key-round" class="size-3.5 text-n-slate-11" />
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-body-main text-n-slate-11 truncate">
|
||||
{{ attribute.value }}
|
||||
</span>
|
||||
<template
|
||||
v-if="attribute.attribute_description || attribute.description"
|
||||
>
|
||||
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
|
||||
<span class="text-body-main text-n-slate-11 truncate">
|
||||
{{ attribute.attribute_description || attribute.description }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<AttributeBadge
|
||||
v-for="badge in badges"
|
||||
:key="badge.type"
|
||||
:type="badge.type"
|
||||
/>
|
||||
<div
|
||||
v-if="badges.length > 0"
|
||||
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
|
||||
/>
|
||||
<div class="flex gap-3 justify-end flex-shrink-0">
|
||||
<Button
|
||||
icon="i-lucide-pencil-line"
|
||||
size="sm"
|
||||
color="slate"
|
||||
ghost
|
||||
icon="i-woot-edit-pen"
|
||||
slate
|
||||
sm
|
||||
@click="emit('edit', attribute)"
|
||||
/>
|
||||
<div class="w-px h-3 bg-n-strong" />
|
||||
<Button
|
||||
icon="i-lucide-trash"
|
||||
size="sm"
|
||||
color="slate"
|
||||
ghost
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="emit('delete', attribute)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ attribute.attribute_description || attribute.description || '' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+5
-3
@@ -35,18 +35,20 @@ const handleDelete = () => {
|
||||
<template>
|
||||
<div class="flex justify-between items-center px-4 py-3 w-full">
|
||||
<div class="flex gap-3 items-center">
|
||||
<h5 class="text-sm font-medium text-n-slate-12 line-clamp-1">
|
||||
<h5 class="text-heading-3 text-n-slate-12 line-clamp-1">
|
||||
{{ attribute.label }}
|
||||
</h5>
|
||||
<div class="w-px h-2.5 bg-n-slate-5" />
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
|
||||
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
|
||||
<span class="text-body-para text-n-slate-11">{{ attribute.type }}</span>
|
||||
</div>
|
||||
<div class="w-px h-2.5 bg-n-slate-5" />
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
|
||||
<span class="text-sm text-n-slate-11">{{ attribute.value }}</span>
|
||||
<span class="text-body-para text-n-slate-11">{{
|
||||
attribute.value
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
|
||||
+2
-2
@@ -129,10 +129,10 @@ const handleDelete = attribute => {
|
||||
<div class="flex flex-col gap-2 items-start px-5 py-4">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
<h3 class="text-heading-2 text-n-slate-12">
|
||||
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
|
||||
</h3>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
<p class="mb-0 text-body-para text-n-slate-11">
|
||||
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
@@ -18,11 +19,13 @@ const attributeConfig = {
|
||||
colorClass: 'text-n-blue-11',
|
||||
icon: 'i-lucide-message-circle',
|
||||
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
|
||||
color: 'slate',
|
||||
},
|
||||
resolution: {
|
||||
colorClass: 'text-n-teal-11',
|
||||
icon: 'i-lucide-circle-check-big',
|
||||
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
|
||||
color: 'slate',
|
||||
},
|
||||
};
|
||||
const config = computed(
|
||||
@@ -31,12 +34,9 @@ const config = computed(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
|
||||
>
|
||||
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
|
||||
<span class="text-xs" :class="config.colorClass">{{
|
||||
t(config.labelKey)
|
||||
}}</span>
|
||||
</div>
|
||||
<Label :label="t(config.labelKey)" :color="config.color" compact>
|
||||
<template #icon>
|
||||
<Icon :icon="config.icon" class="size-3.5 text-n-slate-12" />
|
||||
</template>
|
||||
</Label>
|
||||
</template>
|
||||
|
||||
@@ -28,7 +28,7 @@ const props = defineProps({
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const emit = defineEmits(['update:modelValue', 'executeCopilotAction']);
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
@@ -113,6 +113,9 @@ watch(
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@execute-copilot-action="
|
||||
(...args) => emit('executeCopilotAction', ...args)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="showCharacterCount || slots.actions"
|
||||
|
||||
@@ -26,7 +26,7 @@ defineProps({
|
||||
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
|
||||
class="relative w-full max-w-5xl mx-auto overflow-hidden h-full max-h-[28rem]"
|
||||
>
|
||||
<div
|
||||
v-if="showBackdrop"
|
||||
|
||||
@@ -59,8 +59,8 @@ const togglePortalSwitcher = () => {
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
||||
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
|
||||
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
|
||||
<header class="sticky top-0 z-10 px-6 pb-3">
|
||||
<div class="w-full max-w-5xl mx-auto">
|
||||
<div
|
||||
v-if="showHeaderTitle"
|
||||
class="flex items-center justify-start h-20 gap-2"
|
||||
@@ -95,16 +95,17 @@ const togglePortalSwitcher = () => {
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||
<div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6">
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
<div class="w-full max-w-5xl mx-auto py-3">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
|
||||
<PaginationFooter
|
||||
:current-page="currentPage"
|
||||
:total-items="totalItems"
|
||||
:items-per-page="itemsPerPage"
|
||||
class="max-w-[67rem]"
|
||||
@update:current-page="updateCurrentPage"
|
||||
/>
|
||||
</footer>
|
||||
|
||||
+12
-8
@@ -58,18 +58,22 @@ const openArticle = id => {
|
||||
}
|
||||
};
|
||||
|
||||
const onReorder = reorderedGroup => {
|
||||
store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
const onReorder = async reorderedGroup => {
|
||||
try {
|
||||
await store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Reuse existing positions to maintain order within the current group
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
const sortedArticlePositions = localArticles.value
|
||||
.map(article => article.position)
|
||||
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
|
||||
.map((article, index) => article.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const orderedArticles = localArticles.value.map(article => article.id);
|
||||
|
||||
|
||||
+12
@@ -98,6 +98,17 @@ const handleAction = ({ action, id, category: categoryData }) => {
|
||||
deleteCategory(categoryData);
|
||||
}
|
||||
};
|
||||
|
||||
const reorderCategories = async reorderedGroup => {
|
||||
try {
|
||||
await store.dispatch('categories/reorder', {
|
||||
portalSlug: route.params.portalSlug,
|
||||
reorderedGroup,
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_CATEGORY.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -122,6 +133,7 @@ const handleAction = ({ action, id, category: categoryData }) => {
|
||||
:categories="categories"
|
||||
@click="openCategoryArticles"
|
||||
@action="handleAction"
|
||||
@reorder="reorderCategories"
|
||||
/>
|
||||
<CategoryEmptyState
|
||||
v-else
|
||||
|
||||
+60
-16
@@ -1,14 +1,22 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'action']);
|
||||
const emit = defineEmits(['click', 'action', 'reorder']);
|
||||
|
||||
const localCategories = ref(props.categories);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
return localCategories.value?.length > 1;
|
||||
});
|
||||
|
||||
const handleClick = slug => {
|
||||
emit('click', slug);
|
||||
@@ -17,21 +25,57 @@ const handleClick = slug => {
|
||||
const handleAction = ({ action, value, id }, category) => {
|
||||
emit('action', { action, value, id, category });
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
const sortedPositions = localCategories.value
|
||||
.map((category, index) => category.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const reorderedGroup = localCategories.value.reduce(
|
||||
(obj, category, index) => {
|
||||
obj[category.id] = sortedPositions[index];
|
||||
return obj;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
emit('reorder', reorderedGroup);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.categories,
|
||||
newCategories => {
|
||||
localCategories.value = newCategories;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CategoryCard
|
||||
v-for="category in categories"
|
||||
:id="category.id"
|
||||
:key="category.id"
|
||||
:title="category.name"
|
||||
:icon="category.icon"
|
||||
:description="category.description"
|
||||
:articles-count="category.meta.articles_count || 0"
|
||||
:slug="category.slug"
|
||||
@click="handleClick(category.slug)"
|
||||
@action="handleAction($event, category)"
|
||||
/>
|
||||
</ul>
|
||||
<Draggable
|
||||
v-model="localCategories"
|
||||
:disabled="!dragEnabled"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
role="list"
|
||||
class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<li class="list-none">
|
||||
<CategoryCard
|
||||
:id="element.id"
|
||||
:title="element.name"
|
||||
:icon="element.icon"
|
||||
:description="element.description"
|
||||
:articles-count="element.meta?.articles_count || 0"
|
||||
:slug="element.slug"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@click="handleClick(element.slug)"
|
||||
@action="handleAction($event, element)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
</Draggable>
|
||||
</template>
|
||||
|
||||
+1
@@ -62,6 +62,7 @@ const onCreate = async () => {
|
||||
from: route.name,
|
||||
});
|
||||
|
||||
selectedLocale.value = '';
|
||||
dialogRef.value?.close();
|
||||
useAlert(
|
||||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { reactive, ref, computed, onMounted, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useWindowSize } from '@vueuse/core';
|
||||
@@ -12,7 +12,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
searchContacts,
|
||||
createContactSearcher,
|
||||
createNewContact,
|
||||
fetchContactableInboxes,
|
||||
processContactableInboxes,
|
||||
@@ -39,6 +39,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const searchContacts = createContactSearcher();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
@@ -59,6 +60,23 @@ const isFetchingInboxes = ref(false);
|
||||
const isSearching = ref(false);
|
||||
const showComposeNewConversation = ref(false);
|
||||
|
||||
const formState = reactive({
|
||||
message: '',
|
||||
subject: '',
|
||||
ccEmails: '',
|
||||
bccEmails: '',
|
||||
attachedFiles: [],
|
||||
});
|
||||
|
||||
const clearFormState = () => {
|
||||
Object.assign(formState, {
|
||||
subject: '',
|
||||
ccEmails: '',
|
||||
bccEmails: '',
|
||||
attachedFiles: [],
|
||||
});
|
||||
};
|
||||
|
||||
const contactById = useMapGetter('contacts/getContactById');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
@@ -90,15 +108,17 @@ const onContactSearch = debounce(
|
||||
isSearching.value = true;
|
||||
contacts.value = [];
|
||||
try {
|
||||
contacts.value = await searchContacts(query);
|
||||
const results = await searchContacts(query);
|
||||
// null means the request was aborted (a newer search is in-flight),
|
||||
if (results === null) return;
|
||||
contacts.value = results;
|
||||
isSearching.value = false;
|
||||
} catch (error) {
|
||||
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
300,
|
||||
400,
|
||||
false
|
||||
);
|
||||
|
||||
@@ -121,6 +141,7 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
|
||||
contact = rest;
|
||||
}
|
||||
selectedContact.value = contact;
|
||||
contacts.value = [];
|
||||
if (contact?.id) {
|
||||
isFetchingInboxes.value = true;
|
||||
try {
|
||||
@@ -140,12 +161,14 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
|
||||
|
||||
const handleTargetInbox = inbox => {
|
||||
targetInbox.value = inbox;
|
||||
if (!inbox) clearFormState();
|
||||
resetContacts();
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
selectedContact.value = null;
|
||||
targetInbox.value = null;
|
||||
clearFormState();
|
||||
};
|
||||
|
||||
const closeCompose = () => {
|
||||
@@ -160,6 +183,12 @@ const closeCompose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const discardCompose = () => {
|
||||
clearFormState();
|
||||
formState.message = '';
|
||||
closeCompose();
|
||||
};
|
||||
|
||||
const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||
try {
|
||||
const data = await store.dispatch('contactConversations/create', {
|
||||
@@ -171,7 +200,7 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
|
||||
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
|
||||
};
|
||||
closeCompose();
|
||||
discardCompose();
|
||||
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
|
||||
return true; // Return success
|
||||
} catch (error) {
|
||||
@@ -193,7 +222,11 @@ watch(
|
||||
(currentContact, previousContact) => {
|
||||
if (currentContact && props.contactId) {
|
||||
// Reset on contact change
|
||||
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
|
||||
if (currentContact?.id !== previousContact?.id) {
|
||||
clearSelectedContact();
|
||||
clearFormState();
|
||||
formState.message = '';
|
||||
}
|
||||
|
||||
// First process the contactable inboxes to get the right structure
|
||||
const processedInboxes = processContactableInboxes(
|
||||
@@ -244,7 +277,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
handleClickOutside,
|
||||
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
|
||||
// This will prevent closing the compose conversation modal when the editor Create link popup is open
|
||||
{ ignore: ['div.ProseMirror-prompt'] },
|
||||
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
|
||||
]"
|
||||
class="relative"
|
||||
:class="{
|
||||
@@ -265,6 +298,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
@click.self="onModalBackdropClick"
|
||||
>
|
||||
<ComposeNewConversationForm
|
||||
:form-state="formState"
|
||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
@@ -285,7 +319,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
@update-target-inbox="handleTargetInbox"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@create-conversation="createConversation"
|
||||
@discard="closeCompose"
|
||||
@discard="discardCompose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+53
-23
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { reactive, ref, computed } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, requiredIf } from '@vuelidate/validators';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
prepareWhatsAppMessagePayload,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
|
||||
|
||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
|
||||
import ContactSelector from './ContactSelector.vue';
|
||||
import InboxSelector from './InboxSelector.vue';
|
||||
import EmailOptions from './EmailOptions.vue';
|
||||
@@ -22,6 +25,7 @@ import MessageEditor from './MessageEditor.vue';
|
||||
import ActionButtons from './ActionButtons.vue';
|
||||
import InboxEmptyState from './InboxEmptyState.vue';
|
||||
import AttachmentPreviews from './AttachmentPreviews.vue';
|
||||
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contacts: { type: Array, default: () => [] },
|
||||
@@ -37,10 +41,12 @@ const props = defineProps({
|
||||
contactsUiFlags: { type: Object, default: null },
|
||||
messageSignature: { type: String, default: '' },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
formState: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'searchContacts',
|
||||
'resetContactSearch',
|
||||
'discard',
|
||||
'updateSelectedContact',
|
||||
'updateTargetInbox',
|
||||
@@ -50,6 +56,8 @@ const emit = defineEmits([
|
||||
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
|
||||
const copilot = useCopilotReply();
|
||||
|
||||
const showContactsDropdown = ref(false);
|
||||
const showInboxesDropdown = ref(false);
|
||||
const showCcEmailsDropdown = ref(false);
|
||||
@@ -57,13 +65,13 @@ const showBccEmailsDropdown = ref(false);
|
||||
|
||||
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
|
||||
|
||||
const state = reactive({
|
||||
const state = props.formState || {
|
||||
message: '',
|
||||
subject: '',
|
||||
ccEmails: '',
|
||||
bccEmails: '',
|
||||
attachedFiles: [],
|
||||
});
|
||||
};
|
||||
|
||||
const inboxTypes = computed(() => ({
|
||||
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
|
||||
@@ -156,22 +164,8 @@ const isAnyDropdownActive = computed(() => {
|
||||
});
|
||||
|
||||
const handleContactSearch = value => {
|
||||
showContactsDropdown.value = true;
|
||||
const query = typeof value === 'string' ? value.trim() : '';
|
||||
const hasAlphabet = Array.from(query).some(char => {
|
||||
const lower = char.toLowerCase();
|
||||
const upper = char.toUpperCase();
|
||||
return lower !== upper;
|
||||
});
|
||||
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
|
||||
|
||||
const keys = ['email', 'phone_number', 'name'].filter(key => {
|
||||
if (key === 'phone_number' && hasAlphabet) return false;
|
||||
if (key === 'name' && isEmailLike) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
emit('searchContacts', { keys, query: value });
|
||||
showContactsDropdown.value = value.trim().length > 1;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const handleDropdownUpdate = (type, value) => {
|
||||
@@ -185,13 +179,17 @@ const handleDropdownUpdate = (type, value) => {
|
||||
};
|
||||
|
||||
const searchCcEmails = value => {
|
||||
showCcEmailsDropdown.value = true;
|
||||
emit('searchContacts', { keys: ['email'], query: value });
|
||||
showBccEmailsDropdown.value = false;
|
||||
emit('resetContactSearch');
|
||||
showCcEmailsDropdown.value = value.trim().length >= 2;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const searchBccEmails = value => {
|
||||
showBccEmailsDropdown.value = true;
|
||||
emit('searchContacts', { keys: ['email'], query: value });
|
||||
showCcEmailsDropdown.value = false;
|
||||
emit('resetContactSearch');
|
||||
showBccEmailsDropdown.value = value.trim().length >= 2;
|
||||
emit('searchContacts', value);
|
||||
};
|
||||
|
||||
const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||
@@ -209,6 +207,7 @@ const stripMessageFormatting = channelType => {
|
||||
|
||||
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
|
||||
v$.value.$reset();
|
||||
copilot.reset(false);
|
||||
|
||||
// Strip unsupported formatting when changing the target inbox
|
||||
if (channelType) {
|
||||
@@ -235,6 +234,7 @@ const removeSignatureFromMessage = () => {
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
copilot.reset(false);
|
||||
removeSignatureFromMessage();
|
||||
|
||||
stripMessageFormatting(DEFAULT_FORMATTING);
|
||||
@@ -244,6 +244,7 @@ const removeTargetInbox = value => {
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
copilot.reset(false);
|
||||
removeSignatureFromMessage();
|
||||
emit('clearSelectedContact');
|
||||
state.message = '';
|
||||
@@ -275,6 +276,7 @@ const handleAttachFile = files => {
|
||||
};
|
||||
|
||||
const clearForm = () => {
|
||||
copilot.reset(false);
|
||||
Object.assign(state, {
|
||||
message: '',
|
||||
subject: '',
|
||||
@@ -337,6 +339,24 @@ const shouldShowMessageEditor = computed(() => {
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
});
|
||||
|
||||
const isCopilotActive = computed(() => copilot.isActive?.value ?? false);
|
||||
|
||||
const onSubmitCopilotReply = () => {
|
||||
const acceptedMessage = copilot.accept();
|
||||
state.message = acceptedMessage;
|
||||
};
|
||||
|
||||
useKeyboardEvents({
|
||||
'$mod+Enter': {
|
||||
action: () => {
|
||||
if (isCopilotActive.value && !copilot.isButtonDisabled.value) {
|
||||
onSubmitCopilotReply();
|
||||
}
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -367,6 +387,7 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:show-inboxes-dropdown="showInboxesDropdown"
|
||||
:contactable-inboxes-list="contactableInboxesList"
|
||||
:has-errors="validationStates.isInboxInvalid"
|
||||
:is-fetching-inboxes="isFetchingInboxes"
|
||||
@update-inbox="removeTargetInbox"
|
||||
@toggle-dropdown="showInboxesDropdown = $event"
|
||||
@handle-inbox-action="handleInboxAction"
|
||||
@@ -395,6 +416,7 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:channel-type="inboxChannelType"
|
||||
:medium="targetInbox?.medium || ''"
|
||||
:copilot="copilot"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
@@ -404,7 +426,15 @@ const shouldShowMessageEditor = computed(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CopilotReplyBottomPanel
|
||||
v-if="isCopilotActive"
|
||||
:is-generating-content="copilot.isButtonDisabled.value"
|
||||
class="h-[3.25rem] !px-4 !py-2"
|
||||
@submit="onSubmitCopilotReply"
|
||||
@cancel="copilot.reset"
|
||||
/>
|
||||
<ActionButtons
|
||||
v-else
|
||||
:attached-files="state.attachedFiles"
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
|
||||
+12
-10
@@ -44,14 +44,16 @@ const bccEmailsArray = computed(() =>
|
||||
);
|
||||
|
||||
const contactEmailsList = computed(() => {
|
||||
return props.contacts?.map(({ name, id, email }) => ({
|
||||
id,
|
||||
label: email,
|
||||
email,
|
||||
thumbnail: { name: name, src: '' },
|
||||
value: id,
|
||||
action: 'email',
|
||||
}));
|
||||
return props.contacts
|
||||
?.filter(contact => contact.email)
|
||||
.map(({ name, id, email }) => ({
|
||||
id,
|
||||
label: email,
|
||||
email,
|
||||
thumbnail: { name: name, src: '' },
|
||||
value: id,
|
||||
action: 'email',
|
||||
}));
|
||||
});
|
||||
|
||||
// Handle updates from TagInput and convert array back to string
|
||||
@@ -95,8 +97,8 @@ const inputClass = computed(() => {
|
||||
:show-dropdown="showCcEmailsDropdown"
|
||||
:is-loading="isLoading"
|
||||
type="email"
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
@focus="emit('updateDropdown', 'cc', true)"
|
||||
@input="emit('searchCcEmails', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'cc', false)"
|
||||
@update:model-value="handleCcUpdate"
|
||||
@@ -127,9 +129,9 @@ const inputClass = computed(() => {
|
||||
:show-dropdown="showBccEmailsDropdown"
|
||||
:is-loading="isLoading"
|
||||
type="email"
|
||||
allow-create
|
||||
class="flex-1 min-h-7"
|
||||
focus-on-mount
|
||||
@focus="emit('updateDropdown', 'bcc', true)"
|
||||
@input="emit('searchBccEmails', $event)"
|
||||
@on-click-outside="emit('updateDropdown', 'bcc', false)"
|
||||
@update:model-value="handleBccUpdate"
|
||||
|
||||
+7
-1
@@ -1,9 +1,15 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
|
||||
>
|
||||
<span class="text-sm dark:text-n-amber-11 text-n-amber-11">
|
||||
{{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { generateLabelForContactableInboxesList } from 'dashboard/components-nex
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
targetInbox: {
|
||||
@@ -28,6 +29,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isFetchingInboxes: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -71,7 +76,9 @@ const targetInboxLabel = computed(() => {
|
||||
v-on-click-outside="() => emit('toggleDropdown', false)"
|
||||
class="relative flex items-center h-7"
|
||||
>
|
||||
<Spinner v-if="isFetchingInboxes" :size="16" />
|
||||
<Button
|
||||
v-else
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
|
||||
+61
-21
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import CopilotEditorSection from 'dashboard/components/widgets/conversation/CopilotEditorSection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
hasErrors: { type: Boolean, default: false },
|
||||
@@ -10,6 +11,7 @@ const props = defineProps({
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
copilot: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
||||
@@ -20,29 +22,67 @@ const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const isCopilotActive = computed(() => props.copilot?.isActive?.value ?? false);
|
||||
|
||||
const executeCopilotAction = (action, data) => {
|
||||
if (props.copilot) {
|
||||
props.copilot.execute(action, data);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 h-full">
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:editor-key="editorKey"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
/>
|
||||
<div class="flex-1 h-full px-4 py-4">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
>
|
||||
<div
|
||||
:key="copilot ? copilot.editorTransitionKey.value : 'rich'"
|
||||
class="h-full"
|
||||
>
|
||||
<CopilotEditorSection
|
||||
v-if="isCopilotActive"
|
||||
:show-copilot-editor="copilot.showEditor.value"
|
||||
:is-generating-content="copilot.isGenerating.value"
|
||||
:generated-content="copilot.generatedContent.value"
|
||||
class="!mb-0"
|
||||
@focus="() => {}"
|
||||
@blur="() => {}"
|
||||
@clear-selection="() => {}"
|
||||
@content-ready="copilot.setContentReady"
|
||||
@send="copilot.sendFollowUp"
|
||||
/>
|
||||
<Editor
|
||||
v-else
|
||||
v-model="modelValue"
|
||||
:editor-key="editorKey"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-0 [&>div]:py-0 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[12rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
enable-captain-tools
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
@execute-copilot-action="executeCopilotAction"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+36
-31
@@ -176,38 +176,43 @@ export const prepareWhatsAppMessagePayload = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const generateContactQuery = ({ keys = ['email'], query }) => {
|
||||
return {
|
||||
payload: keys.map(key => {
|
||||
const filterPayload = {
|
||||
attribute_key: key,
|
||||
filter_operator: 'contains',
|
||||
values: [query],
|
||||
attribute_model: 'standard',
|
||||
};
|
||||
if (keys.findIndex(k => k === key) !== keys.length - 1) {
|
||||
filterPayload.query_operator = 'or';
|
||||
}
|
||||
return filterPayload;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
// API Calls
|
||||
export const searchContacts = async ({ keys, query }) => {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.filter(
|
||||
undefined,
|
||||
'name',
|
||||
generateContactQuery({ keys, query })
|
||||
);
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
contact => contact.phoneNumber || contact.email
|
||||
);
|
||||
return filteredPayload || [];
|
||||
const MIN_SEARCH_LENGTH = 2;
|
||||
|
||||
export const createContactSearcher = () => {
|
||||
let controller = null;
|
||||
|
||||
return async (query, { skipMinLength = false } = {}) => {
|
||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||
|
||||
controller?.abort();
|
||||
|
||||
if (!trimmed || (!skipMinLength && trimmed.length < MIN_SEARCH_LENGTH))
|
||||
return [];
|
||||
|
||||
controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
|
||||
|
||||
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
|
||||
// Filter contacts that have either phone_number or email
|
||||
const filteredPayload = camelCasedPayload?.filter(
|
||||
contact => contact.phoneNumber || contact.email
|
||||
);
|
||||
return filteredPayload || [];
|
||||
} catch (error) {
|
||||
// Return null for aborted requests so callers can distinguish
|
||||
// "request was cancelled" from "no results found"
|
||||
if (error?.name === 'AbortError' || error?.name === 'CanceledError') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const createNewContact = async input => {
|
||||
|
||||
+101
-99
@@ -70,7 +70,7 @@ describe('composeConversationHelper', () => {
|
||||
const result = helpers.buildContactableInboxesList(inboxes);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 1,
|
||||
icon: 'i-ri-mail-line',
|
||||
icon: 'i-woot-mail',
|
||||
label: 'Email Inbox (support@example.com)',
|
||||
action: 'inbox',
|
||||
value: 1,
|
||||
@@ -336,72 +336,13 @@ describe('composeConversationHelper', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateContactQuery', () => {
|
||||
it('generates correct query structure for contact search', () => {
|
||||
const query = 'test@example.com';
|
||||
const expected = {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: [query],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
|
||||
expected
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty query', () => {
|
||||
const expected = {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: [''],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
helpers.generateContactQuery({ keys: ['email'], query: '' })
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it('handles mutliple keys', () => {
|
||||
const expected = {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
query_operator: 'or',
|
||||
},
|
||||
{
|
||||
attribute_key: 'phone_number',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
helpers.generateContactQuery({
|
||||
keys: ['email', 'phone_number'],
|
||||
query: 'john',
|
||||
})
|
||||
).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
describe('searchContacts', () => {
|
||||
describe('createContactSearcher', () => {
|
||||
let searchContacts;
|
||||
beforeEach(() => {
|
||||
searchContacts = helpers.createContactSearcher();
|
||||
});
|
||||
|
||||
it('searches contacts and returns camelCase results', async () => {
|
||||
const mockPayload = [
|
||||
{
|
||||
@@ -413,14 +354,11 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
];
|
||||
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts({
|
||||
keys: ['email'],
|
||||
query: 'john',
|
||||
});
|
||||
const result = await searchContacts('john');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -432,16 +370,56 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith(
|
||||
'john',
|
||||
1,
|
||||
'name',
|
||||
'',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty array for queries shorter than 2 characters', async () => {
|
||||
const result = await searchContacts('j');
|
||||
expect(result).toEqual([]);
|
||||
expect(ContactAPI.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty array for empty or whitespace-only queries', async () => {
|
||||
expect(await searchContacts('')).toEqual([]);
|
||||
expect(await searchContacts(' ')).toEqual([]);
|
||||
expect(await searchContacts(null)).toEqual([]);
|
||||
expect(ContactAPI.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts previous in-flight request when a new search starts', async () => {
|
||||
const mockPayload = [
|
||||
{ id: 1, name: 'Result', email: 'r@test.com', phone_number: null },
|
||||
];
|
||||
|
||||
let resolveFirst;
|
||||
const firstCall = new Promise(resolve => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
ContactAPI.search
|
||||
.mockReturnValueOnce(firstCall)
|
||||
.mockResolvedValueOnce({ data: { payload: mockPayload } });
|
||||
|
||||
// Start first search (will hang)
|
||||
const first = searchContacts('alpha');
|
||||
// Start second search (aborts first)
|
||||
const second = searchContacts('beta');
|
||||
|
||||
// Resolve the first call with CanceledError (simulating axios abort)
|
||||
const canceledError = new Error('canceled');
|
||||
canceledError.name = 'CanceledError';
|
||||
resolveFirst(Promise.reject(canceledError));
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
expect(firstResult).toBeNull();
|
||||
expect(secondResult).toEqual([
|
||||
{ id: 1, name: 'Result', email: 'r@test.com', phoneNumber: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('searches contacts and returns only contacts with email or phone number', async () => {
|
||||
@@ -469,14 +447,11 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
];
|
||||
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts({
|
||||
keys: ['email'],
|
||||
query: 'john',
|
||||
});
|
||||
const result = await searchContacts('john');
|
||||
|
||||
// Should only return contacts with either email or phone number
|
||||
expect(result).toEqual([
|
||||
@@ -496,24 +471,21 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
|
||||
payload: [
|
||||
{
|
||||
attribute_key: 'email',
|
||||
filter_operator: 'contains',
|
||||
values: ['john'],
|
||||
attribute_model: 'standard',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(ContactAPI.search).toHaveBeenCalledWith(
|
||||
'john',
|
||||
1,
|
||||
'name',
|
||||
'',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty search results', async () => {
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: [] },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts('nonexistent');
|
||||
const result = await searchContacts('nonexistent');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -536,11 +508,11 @@ describe('composeConversationHelper', () => {
|
||||
},
|
||||
];
|
||||
|
||||
ContactAPI.filter.mockResolvedValue({
|
||||
ContactAPI.search.mockResolvedValue({
|
||||
data: { payload: mockPayload },
|
||||
});
|
||||
|
||||
const result = await helpers.searchContacts('test');
|
||||
const result = await searchContacts('test');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -562,6 +534,36 @@ describe('composeConversationHelper', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContactSearcher isolation', () => {
|
||||
it('creates isolated searcher instances that do not cancel each other', async () => {
|
||||
const searcherA = helpers.createContactSearcher();
|
||||
const searcherB = helpers.createContactSearcher();
|
||||
|
||||
const payloadA = [
|
||||
{ id: 1, name: 'Alice', email: 'a@test.com', phone_number: null },
|
||||
];
|
||||
const payloadB = [
|
||||
{ id: 2, name: 'Bob', email: 'b@test.com', phone_number: null },
|
||||
];
|
||||
|
||||
ContactAPI.search
|
||||
.mockResolvedValueOnce({ data: { payload: payloadA } })
|
||||
.mockResolvedValueOnce({ data: { payload: payloadB } });
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
searcherA('alice'),
|
||||
searcherB('bob'),
|
||||
]);
|
||||
|
||||
expect(resultA).toEqual([
|
||||
{ id: 1, name: 'Alice', email: 'a@test.com', phoneNumber: null },
|
||||
]);
|
||||
expect(resultB).toEqual([
|
||||
{ id: 2, name: 'Bob', email: 'b@test.com', phoneNumber: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewContact', () => {
|
||||
it('creates new contact with capitalized name', async () => {
|
||||
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
defaultOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const [isOpen, toggle] = useToggle(props.defaultOpen);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-bind="$attrs"
|
||||
class="flex items-center justify-between w-full cursor-pointer pb-2 pt-4 border-t border-n-weak"
|
||||
@click="toggle()"
|
||||
>
|
||||
<span class="text-heading-2 text-n-slate-12 w-full">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isOpen" class="w-full space-y-4 pt-4 mb-4">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
helpText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full py-2 mb-2 [interpolate-size:allow-keywords]">
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-8 gap-1.5 lg:gap-4 items-start lg:items-center"
|
||||
>
|
||||
<label class="text-heading-3 text-n-slate-12 col-span-1 lg:col-span-2">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="col-span-1 lg:col-span-6">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="helpText" class="grid grid-cols-1 lg:grid-cols-8">
|
||||
<div class="col-span-1 lg:col-span-2 invisible" />
|
||||
<p
|
||||
v-if="helpText"
|
||||
class="mt-1.5 col-span-1 lg:col-span-6 text-label-small text-n-slate-11 ltr:ml-1 rtl:mr-1"
|
||||
>
|
||||
{{ helpText }}
|
||||
</p>
|
||||
</div>
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = defineModel({ type: Boolean, default: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col items-start outline outline-1 -outline-offset-1 outline-n-weak rounded-xl [interpolate-size:allow-keywords]"
|
||||
>
|
||||
<div class="flex flex-col gap-1 items-start w-full px-4 py-3">
|
||||
<div class="flex items-center gap-3 w-full justify-between">
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ header }}
|
||||
</span>
|
||||
<ToggleSwitch v-model="modelValue" />
|
||||
</div>
|
||||
<span v-if="description" class="text-body-main text-n-slate-11">
|
||||
{{ description }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.editor"
|
||||
class="w-full border-t border-n-weak"
|
||||
:class="{ 'p-0': compact, 'px-4 pb-4 pt-2': !compact }"
|
||||
>
|
||||
<slot name="editor" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -112,6 +112,19 @@ const containerStyles = computed(() => ({
|
||||
height: `${props.size}px`,
|
||||
}));
|
||||
|
||||
const borderRadiusClass = computed(() => {
|
||||
if (props.roundedFull) {
|
||||
return 'rounded-full';
|
||||
}
|
||||
|
||||
// Approximates 25% of size
|
||||
if (props.size <= 16) return 'rounded'; // 4px
|
||||
if (props.size <= 24) return 'rounded-md'; // 6px
|
||||
if (props.size <= 32) return 'rounded-lg'; // 8px
|
||||
if (props.size <= 48) return 'rounded-xl'; // 12px
|
||||
return 'rounded-2xl'; // 16px
|
||||
});
|
||||
|
||||
const avatarStyles = computed(() => ({
|
||||
...containerStyles.value,
|
||||
backgroundColor:
|
||||
@@ -184,7 +197,7 @@ watch(
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="relative inline-flex group/avatar z-0 flex-shrink-0"
|
||||
class="relative inline-flex group/avatar z-0 flex-shrink-0 align-middle"
|
||||
:style="containerStyles"
|
||||
>
|
||||
<!-- Status Badge -->
|
||||
@@ -216,9 +229,9 @@ watch(
|
||||
<!-- Avatar Container -->
|
||||
<span
|
||||
role="img"
|
||||
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium"
|
||||
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium outline outline-1 -outline-offset-1 outline-[rgb(0_0_0_/_0.03)] dark:outline-[rgb(255_255_255_/_0.04)]"
|
||||
:class="[
|
||||
roundedFull ? 'rounded-full' : 'rounded-xl',
|
||||
borderRadiusClass,
|
||||
{
|
||||
'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]':
|
||||
!showDefaultAvatar && (!src || !isImageValid),
|
||||
@@ -267,7 +280,8 @@ watch(
|
||||
:handle-image-upload="handleImageUpload"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
:class="borderRadiusClass"
|
||||
@click="handleUploadAvatar"
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -117,7 +117,7 @@ const handleCreateAssistant = () => {
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
||||
<header class="sticky top-0 z-10 px-6">
|
||||
<div class="w-full max-w-[60rem] mx-auto">
|
||||
<div class="w-full max-w-5xl mx-auto">
|
||||
<div
|
||||
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
||||
>
|
||||
@@ -140,7 +140,9 @@ const handleCreateAssistant = () => {
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
:variant="
|
||||
showAssistantSwitcherDropdown ? 'faded' : 'ghost'
|
||||
"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:disabled="isFetchingAssistants"
|
||||
@@ -204,7 +206,7 @@ const handleCreateAssistant = () => {
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
|
||||
<div class="w-full max-w-5xl h-full mx-auto py-4">
|
||||
<slot v-if="!showPaywall" name="controls" />
|
||||
<div
|
||||
v-if="isFetching"
|
||||
@@ -222,11 +224,12 @@ const handleCreateAssistant = () => {
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
|
||||
<PaginationFooter
|
||||
:current-page="currentPage"
|
||||
:total-items="totalCount"
|
||||
:items-per-page="itemsPerPage"
|
||||
class="max-w-[67rem]"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</footer>
|
||||
|
||||
@@ -27,7 +27,7 @@ const openBilling = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
|
||||
class="w-full max-w-5xl mx-auto h-full max-h-[448px] grid place-content-center"
|
||||
>
|
||||
<BasePaywallModal
|
||||
class="mx-auto"
|
||||
|
||||
+3
-1
@@ -18,7 +18,9 @@ const dialogRef = ref(null);
|
||||
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
const meta = useMapGetter('captainResponses/getMeta');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const totalCount = computed(() => meta.value.totalCount || 0);
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
@@ -37,7 +39,7 @@ defineExpose({ dialogRef });
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')"
|
||||
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
|
||||
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
|
||||
@@ -56,8 +56,13 @@ const selectedLabel = computed(() => {
|
||||
});
|
||||
|
||||
const selectOption = option => {
|
||||
selectedValue.value = option.value;
|
||||
emit('update:modelValue', option.value);
|
||||
if (selectedValue.value === option.value) {
|
||||
selectedValue.value = '';
|
||||
emit('update:modelValue', '');
|
||||
} else {
|
||||
selectedValue.value = option.value;
|
||||
emit('update:modelValue', option.value);
|
||||
}
|
||||
open.value = false;
|
||||
search.value = '';
|
||||
};
|
||||
|
||||
@@ -79,8 +79,8 @@ defineExpose({
|
||||
:aria-multiselectable="multiple"
|
||||
>
|
||||
<li
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
v-for="(option, index) in options"
|
||||
:key="`${option.value}-${index}`"
|
||||
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2"
|
||||
:class="{
|
||||
'bg-n-alpha-2': isSelected(option),
|
||||
|
||||
@@ -53,6 +53,11 @@ const props = defineProps({
|
||||
default: 'lg',
|
||||
validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value),
|
||||
},
|
||||
position: {
|
||||
type: String,
|
||||
default: 'center',
|
||||
validator: value => ['center', 'top'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm', 'close']);
|
||||
@@ -61,6 +66,7 @@ const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const dialogContentRef = ref(null);
|
||||
const isOpen = ref(false);
|
||||
|
||||
const maxWidthClass = computed(() => {
|
||||
const classesMap = {
|
||||
@@ -75,13 +81,30 @@ const maxWidthClass = computed(() => {
|
||||
return classesMap[props.width] ?? 'max-w-md';
|
||||
});
|
||||
|
||||
const positionClass = computed(() =>
|
||||
props.position === 'top' ? 'dialog-position-top' : ''
|
||||
);
|
||||
|
||||
const open = () => {
|
||||
isOpen.value = true;
|
||||
dialogRef.value?.showModal();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emit('close');
|
||||
dialogRef.value?.close();
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
// Only close if the close event originated from this dialog,
|
||||
// not from a child dialog (e.g. ProseMirror prompt) bubbling up.
|
||||
const handleDialogClose = e => e.target === dialogRef.value && close();
|
||||
|
||||
// Only close on click-outside if this dialog is the topmost one.
|
||||
// If another dialog (e.g. ProseMirror prompt) is open on top, ignore.
|
||||
const handleClickOutside = () => {
|
||||
const dialogs = document.querySelectorAll('dialog[open]');
|
||||
if (dialogs[dialogs.length - 1] === dialogRef.value) close();
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
@@ -98,14 +121,15 @@ defineExpose({ open, close });
|
||||
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
||||
:class="[
|
||||
maxWidthClass,
|
||||
positionClass,
|
||||
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
|
||||
]"
|
||||
@close="close"
|
||||
@close.prevent="handleDialogClose"
|
||||
>
|
||||
<OnClickOutside @trigger="close">
|
||||
<OnClickOutside @trigger="handleClickOutside">
|
||||
<form
|
||||
ref="dialogContentRef"
|
||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
@submit.prevent="confirm"
|
||||
@click.stop
|
||||
>
|
||||
@@ -119,7 +143,7 @@ defineExpose({ open, close });
|
||||
</p>
|
||||
</slot>
|
||||
</div>
|
||||
<slot />
|
||||
<slot v-if="isOpen" />
|
||||
<!-- Dialog content will be injected here -->
|
||||
<slot name="footer">
|
||||
<div
|
||||
@@ -156,4 +180,9 @@ defineExpose({ open, close });
|
||||
dialog::backdrop {
|
||||
@apply bg-n-alpha-black1 backdrop-blur-[4px];
|
||||
}
|
||||
|
||||
.dialog-position-top {
|
||||
margin-top: clamp(2rem, 5vh, 5rem);
|
||||
margin-bottom: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,10 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: 'max-h-96',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -15,7 +19,10 @@ defineProps({
|
||||
>
|
||||
{{ title }}
|
||||
</div>
|
||||
<ul class="gap-2 grid reset-base list-none px-2 max-h-96 overflow-y-auto">
|
||||
<ul
|
||||
class="gap-2 grid reset-base list-none px-2 overflow-y-auto"
|
||||
:class="height"
|
||||
>
|
||||
<slot />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ const openLink = link => {
|
||||
]"
|
||||
>
|
||||
<section
|
||||
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80"
|
||||
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80 z-20"
|
||||
>
|
||||
<div
|
||||
class="absolute -top-[0.77rem] ltr:left-12 rtl:right-12 w-6 h-6 ltr:rotate-45 rtl:-rotate-45 rtl:rounded-tr ltr:rounded-tl rtl:border-r ltr:border-l border-t border-n-weak bg-n-alpha-3 z-10"
|
||||
|
||||
@@ -50,12 +50,12 @@ const currentFilter = computed(() =>
|
||||
);
|
||||
|
||||
const getOperator = (filter, selectedOperator) => {
|
||||
const operatorFromOptions = filter.filterOperators.find(
|
||||
const operatorFromOptions = filter?.filterOperators?.find(
|
||||
operator => operator.value === selectedOperator
|
||||
);
|
||||
|
||||
if (!operatorFromOptions) {
|
||||
return filter.filterOperators[0];
|
||||
return filter?.filterOperators?.[0];
|
||||
}
|
||||
|
||||
return operatorFromOptions;
|
||||
@@ -138,7 +138,11 @@ const validate = () => {
|
||||
return !validationError.value;
|
||||
};
|
||||
|
||||
defineExpose({ validate });
|
||||
const resetValidation = () => {
|
||||
showErrors.value = false;
|
||||
};
|
||||
|
||||
defineExpose({ validate, resetValidation });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -166,18 +170,20 @@ defineExpose({ validate });
|
||||
<FilterSelect
|
||||
v-model="filterOperator"
|
||||
variant="ghost"
|
||||
:options="currentFilter.filterOperators"
|
||||
:options="currentFilter?.filterOperators"
|
||||
/>
|
||||
<template v-if="currentOperator.hasInput">
|
||||
<template v-if="currentOperator?.hasInput">
|
||||
<MultiSelect
|
||||
v-if="inputType === 'multiSelect'"
|
||||
v-model="values"
|
||||
:options="currentFilter.options"
|
||||
dropdown-max-height="max-h-72"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'searchSelect'"
|
||||
v-model="values"
|
||||
:options="currentFilter.options"
|
||||
dropdown-max-height="max-h-64"
|
||||
/>
|
||||
<SingleSelect
|
||||
v-else-if="inputType === 'booleanSelect'"
|
||||
|
||||
@@ -45,7 +45,7 @@ const { height } = useWindowSize();
|
||||
const { height: dropdownHeight } = useElementBounding(dropdownRef);
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return props.options.find(o => o.value === selected.value) || {};
|
||||
return props.options?.find(o => o.value === selected.value) || {};
|
||||
});
|
||||
|
||||
const iconToRender = computed(() => {
|
||||
@@ -87,18 +87,25 @@ const updateSelected = newValue => {
|
||||
</template>
|
||||
<DropdownBody
|
||||
ref="dropdownRef"
|
||||
class="min-w-48 z-50"
|
||||
class="min-w-56 z-50"
|
||||
:class="dropdownPosition"
|
||||
strong
|
||||
>
|
||||
<DropdownSection class="[&>ul]:max-h-80">
|
||||
<DropdownItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:icon="option.icon"
|
||||
@click="updateSelected(option.value)"
|
||||
/>
|
||||
<DropdownSection class="[&>ul]:max-h-72">
|
||||
<template v-for="option in options" :key="option.value">
|
||||
<li
|
||||
v-if="option.disabled"
|
||||
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
|
||||
>
|
||||
{{ option.label }}
|
||||
</li>
|
||||
<DropdownItem
|
||||
v-else
|
||||
:label="option.label"
|
||||
:icon="option.icon"
|
||||
@click="updateSelected(option.value)"
|
||||
/>
|
||||
</template>
|
||||
</DropdownSection>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
|
||||
@@ -8,7 +8,7 @@ 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({
|
||||
const { options, maxChips, dropdownMaxHeight } = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
@@ -17,6 +17,10 @@ const { options, maxChips } = defineProps({
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
dropdownMaxHeight: {
|
||||
type: String,
|
||||
default: 'max-h-80',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -123,7 +127,7 @@ const toggleOption = option => {
|
||||
</Button>
|
||||
</template>
|
||||
<DropdownBody class="top-0 min-w-48 z-50" strong>
|
||||
<DropdownSection class="[&>ul]:max-h-80">
|
||||
<DropdownSection :height="dropdownMaxHeight">
|
||||
<DropdownItem
|
||||
v-for="option in options"
|
||||
:key="option.id"
|
||||
|
||||
@@ -12,10 +12,12 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
const {
|
||||
options,
|
||||
disableSearch,
|
||||
disableDeselect,
|
||||
placeholderIcon,
|
||||
placeholder,
|
||||
placeholderTrailingIcon,
|
||||
searchPlaceholder,
|
||||
dropdownMaxHeight,
|
||||
} = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
@@ -41,6 +43,14 @@ const {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
dropdownMaxHeight: {
|
||||
type: String,
|
||||
default: 'max-h-80',
|
||||
},
|
||||
disableDeselect: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -63,6 +73,8 @@ const selectedItem = computed(() => {
|
||||
const optionToSearch = Array.isArray(selected.value)
|
||||
? selected.value[0]
|
||||
: selected.value;
|
||||
|
||||
if (!optionToSearch) return null;
|
||||
// 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);
|
||||
@@ -77,7 +89,7 @@ const toggleSelected = option => {
|
||||
};
|
||||
|
||||
if (selected.value && selected.value.id === optionToToggle.id) {
|
||||
selected.value = null;
|
||||
if (!disableDeselect) selected.value = null;
|
||||
} else {
|
||||
selected.value = optionToToggle;
|
||||
}
|
||||
@@ -124,7 +136,7 @@ const toggleSelected = option => {
|
||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<DropdownSection class="[&>ul]:max-h-80">
|
||||
<DropdownSection :height="dropdownMaxHeight">
|
||||
<template v-if="searchResults.length">
|
||||
<DropdownItem
|
||||
v-for="option in searchResults"
|
||||
|
||||
@@ -9,12 +9,12 @@ export function useChannelIcon(inbox) {
|
||||
'Channel::Sms': 'i-woot-sms',
|
||||
'Channel::Telegram': 'i-woot-telegram',
|
||||
'Channel::TwilioSms': 'i-woot-sms',
|
||||
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
|
||||
'Channel::TwitterProfile': 'i-woot-x',
|
||||
'Channel::WebWidget': 'i-woot-website',
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
'Channel::Voice': 'i-ri-phone-fill',
|
||||
'Channel::Voice': 'i-woot-voice',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('useChannelIcon', () => {
|
||||
it('returns correct icon for Voice channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Voice' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-ri-phone-fill');
|
||||
expect(icon).toBe('i-woot-voice');
|
||||
});
|
||||
|
||||
it('returns correct icon for Line channel', () => {
|
||||
@@ -46,7 +46,7 @@ describe('useChannelIcon', () => {
|
||||
it('returns correct icon for Twitter channel', () => {
|
||||
const inbox = { channel_type: 'Channel::TwitterProfile' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-ri-twitter-x-fill');
|
||||
expect(icon).toBe('i-woot-x');
|
||||
});
|
||||
|
||||
it('returns correct icon for WebWidget channel', () => {
|
||||
|
||||
@@ -108,7 +108,7 @@ onMounted(() => {
|
||||
<label
|
||||
v-if="label"
|
||||
:for="uniqueId"
|
||||
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
||||
class="mb-0.5 text-heading-3 text-n-slate-12"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
@@ -145,7 +145,7 @@ onMounted(() => {
|
||||
/>
|
||||
<p
|
||||
v-if="message"
|
||||
class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
|
||||
class="min-w-0 mt-1 mb-0 text-label-small truncate transition-all duration-500 ease-in-out"
|
||||
:class="messageClass"
|
||||
>
|
||||
{{ message }}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: [Object, String],
|
||||
required: true,
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'slate',
|
||||
validator: value =>
|
||||
['slate', 'amber', 'teal', 'ruby', 'blue', 'iris'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const COLOR_CLASSES = {
|
||||
slate: 'bg-n-label-color outline-n-label-border text-n-slate-12',
|
||||
amber: 'bg-n-amber-2 outline-n-amber-4 text-n-amber-11',
|
||||
teal: 'bg-n-teal-2 outline-n-teal-4 text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-2 outline-n-ruby-4 text-n-ruby-11',
|
||||
blue: 'bg-n-blue-2 outline-n-blue-4 text-n-blue-11',
|
||||
iris: 'bg-n-iris-2 outline-n-iris-4 text-n-iris-11',
|
||||
};
|
||||
|
||||
const isStringLabel = computed(() => typeof props.label === 'string');
|
||||
|
||||
const labelTitle = computed(() => {
|
||||
return isStringLabel.value ? props.label : props.label?.title;
|
||||
});
|
||||
|
||||
const labelDescription = computed(() => {
|
||||
return (!isStringLabel.value && props.label?.description) || '';
|
||||
});
|
||||
|
||||
const labelColor = computed(() => {
|
||||
return isStringLabel.value ? null : props.label.color;
|
||||
});
|
||||
|
||||
const colorClasses = computed(() => COLOR_CLASSES[props.color]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:title="labelDescription"
|
||||
class="rounded-lg -outline-offset-1 outline outline-1 inline-flex items-center flex-shrink-0"
|
||||
:class="[
|
||||
colorClasses,
|
||||
compact ? 'px-1.5 h-6 gap-1 rounded-md' : 'px-2.5 h-8 gap-1.5 rounded-lg',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
v-if="labelColor"
|
||||
class="rounded-sm flex-shrink-0"
|
||||
:class="compact ? 'size-1.5' : 'size-2'"
|
||||
:style="{ background: labelColor }"
|
||||
/>
|
||||
<slot v-else name="icon" />
|
||||
<span
|
||||
class="whitespace-nowrap"
|
||||
:class="compact ? 'text-label-small' : 'text-label !font-420'"
|
||||
>
|
||||
{{ labelTitle }}
|
||||
</span>
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,12 +3,14 @@ import { onMounted, computed, ref, toRefs } from 'vue';
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
import { provideMessageContext } from './provider.js';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
MESSAGE_TYPES,
|
||||
@@ -41,6 +43,7 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue';
|
||||
|
||||
import MessageError from './MessageError.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Attachment
|
||||
@@ -126,6 +129,7 @@ const props = defineProps({
|
||||
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
isEmailInbox: { type: Boolean, default: false },
|
||||
private: { type: Boolean, default: false },
|
||||
additionalAttributes: { type: Object, default: () => ({}) }, // eslint-disable-line vue/no-unused-properties
|
||||
sender: { type: Object, default: null },
|
||||
senderId: { type: Number, default: null },
|
||||
senderType: { type: String, default: null },
|
||||
@@ -139,6 +143,9 @@ const showBackgroundHighlight = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
/**
|
||||
* Computes the message variant based on props
|
||||
@@ -162,7 +169,14 @@ const variant = computed(() => {
|
||||
if (props.contentAttributes?.isUnsupported)
|
||||
return MESSAGE_VARIANTS.UNSUPPORTED;
|
||||
|
||||
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
return MESSAGE_VARIANTS.AGENT;
|
||||
}
|
||||
|
||||
const isBot =
|
||||
props.sender?.type === SENDER_TYPES.AGENT_BOT ||
|
||||
props.senderType === SENDER_TYPES.AGENT_BOT ||
|
||||
(!props.sender && !props.additionalAttributes?.senderName);
|
||||
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
|
||||
return MESSAGE_VARIANTS.BOT;
|
||||
}
|
||||
@@ -381,13 +395,17 @@ const shouldRenderMessage = computed(() => {
|
||||
const isUnsupported = props.contentAttributes?.isUnsupported;
|
||||
const isAnIntegrationMessage =
|
||||
props.contentType === CONTENT_TYPES.INTEGRATIONS;
|
||||
const isFailedMessage = props.status === MESSAGE_STATUS.FAILED;
|
||||
const hasExternalError = !!props.contentAttributes?.externalError;
|
||||
|
||||
return (
|
||||
hasAttachments ||
|
||||
props.content ||
|
||||
isEmailContentType ||
|
||||
isUnsupported ||
|
||||
isAnIntegrationMessage
|
||||
isAnIntegrationMessage ||
|
||||
isFailedMessage ||
|
||||
hasExternalError
|
||||
);
|
||||
});
|
||||
|
||||
@@ -424,14 +442,27 @@ function handleReplyTo() {
|
||||
}
|
||||
|
||||
const avatarInfo = computed(() => {
|
||||
// If no sender, return bot info
|
||||
if (!props.sender) {
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
const { name, avatar_url, channel_type, medium } = inbox.value;
|
||||
const iconName = avatar_url
|
||||
? null
|
||||
: getInboxIconByType(channel_type, medium);
|
||||
return {
|
||||
name: t('CONVERSATION.BOT'),
|
||||
src: '',
|
||||
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
|
||||
src: avatar_url || '',
|
||||
iconName,
|
||||
};
|
||||
}
|
||||
|
||||
// If no sender, check for Slack (or other integration) sender info
|
||||
if (!props.sender) {
|
||||
const { senderName, senderAvatarUrl } = props.additionalAttributes || {};
|
||||
if (senderName) {
|
||||
return { name: senderName, src: senderAvatarUrl ?? '' };
|
||||
}
|
||||
return { name: t('CONVERSATION.BOT'), src: '' };
|
||||
}
|
||||
|
||||
const { sender } = props;
|
||||
const { name, type, avatarUrl, thumbnail } = sender || {};
|
||||
|
||||
@@ -451,6 +482,9 @@ const avatarInfo = computed(() => {
|
||||
});
|
||||
|
||||
const avatarTooltip = computed(() => {
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'));
|
||||
}
|
||||
if (avatarInfo.value.name === '') return '';
|
||||
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
||||
});
|
||||
@@ -484,7 +518,7 @@ provideMessageContext({
|
||||
<div
|
||||
v-if="shouldRenderMessage"
|
||||
:id="`message${props.id}`"
|
||||
class="flex mb-2 w-full message-bubble-container"
|
||||
class="flex w-full mb-2 message-bubble-container"
|
||||
:data-message-id="props.id"
|
||||
:class="[
|
||||
flexOrientationClass,
|
||||
|
||||
@@ -12,11 +12,16 @@ defineProps({
|
||||
|
||||
const emit = defineEmits(['retry']);
|
||||
|
||||
const { orientation, status, createdAt } = useMessageContext();
|
||||
const { orientation, status, createdAt, content, attachments } =
|
||||
useMessageContext();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
|
||||
const canRetry = computed(() => {
|
||||
const hasContent = content.value !== null;
|
||||
const hasAttachments = attachments.value && attachments.value.length > 0;
|
||||
return !hasOneDayPassed(createdAt.value) && (hasContent || hasAttachments);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -81,6 +81,7 @@ const isDelivered = computed(() => {
|
||||
isATwilioChannel.value ||
|
||||
isASmsInbox.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { emitter } from 'shared/helpers/mitt';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
|
||||
|
||||
@@ -80,7 +81,7 @@ const replyToPreview = computed(() => {
|
||||
|
||||
const { content, attachments } = inReplyTo.value;
|
||||
|
||||
if (content) return content;
|
||||
if (content) return new MessageFormatter(content).formattedMessage;
|
||||
if (attachments?.length) {
|
||||
const firstAttachment = attachments[0];
|
||||
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
|
||||
@@ -107,9 +108,10 @@ const replyToPreview = computed(() => {
|
||||
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
|
||||
@click="scrollToMessage"
|
||||
>
|
||||
<span class="break-all line-clamp-2">
|
||||
{{ replyToPreview }}
|
||||
</span>
|
||||
<div
|
||||
v-dompurify-html="replyToPreview"
|
||||
class="prose prose-bubble line-clamp-2"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
<MessageMeta
|
||||
|
||||
@@ -71,10 +71,10 @@ const pageInfo = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
|
||||
class="flex justify-between h-[3.375rem] w-full border-t border-n-weak mx-auto bg-n-surface-1 py-3 px-6 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-0% before:to-transparent before:h-4 before:pointer-events-none"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
|
||||
<span class="min-w-0 text-body-main line-clamp-1 text-n-slate-11">
|
||||
{{ currentPageInformation }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -97,11 +97,13 @@ const pageInfo = computed(() => {
|
||||
:disabled="isFirstPage"
|
||||
@click="changePage(currentPage - 1)"
|
||||
/>
|
||||
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
|
||||
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
|
||||
<div class="inline-flex items-center gap-2 text-sm">
|
||||
<span
|
||||
class="px-3 tabular-nums py-0.5 font-420 bg-n-input-background text-body-main text-n-slate-12 rounded-md"
|
||||
>
|
||||
{{ formatFullNumber(currentPage) }}
|
||||
</span>
|
||||
<span class="truncate">
|
||||
<span class="truncate text-body-main text-n-slate-11">
|
||||
{{ pageInfo }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import RadioCard from '../RadioCard.vue';
|
||||
import RadioCard from './RadioCard.vue';
|
||||
|
||||
const selectedOption = ref('round_robin');
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabledLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabledMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const handleChange = () => {
|
||||
if (!props.isActive && !props.disabled) {
|
||||
emit('select', props.id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
|
||||
:class="[
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
|
||||
!disabled && !isActive ? 'hover:outline-n-strong' : '',
|
||||
]"
|
||||
@click="handleChange"
|
||||
>
|
||||
<div class="flex flex-col gap-2 items-start">
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-heading-3 text-n-slate-12">
|
||||
{{ label }}
|
||||
</h3>
|
||||
<Label v-if="disabled" :label="disabledLabel" color="amber" compact />
|
||||
</div>
|
||||
<input
|
||||
:id="`${id}`"
|
||||
:checked="isActive"
|
||||
:value="id"
|
||||
:name="id"
|
||||
:disabled="disabled"
|
||||
type="radio"
|
||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-body-main text-n-slate-11">
|
||||
{{ disabled && disabledMessage ? disabledMessage : description }}
|
||||
</p>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
validator: options =>
|
||||
options.every(
|
||||
opt => typeof opt === 'object' && 'value' in opt && 'label' in opt
|
||||
),
|
||||
},
|
||||
groups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
validator: groups =>
|
||||
groups.every(
|
||||
group =>
|
||||
'label' in group &&
|
||||
Array.isArray(group.options) &&
|
||||
group.options.every(opt => 'value' in opt && 'label' in opt)
|
||||
),
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: [String, Number, Boolean],
|
||||
default: '',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-fit relative">
|
||||
<select
|
||||
v-model="modelValue"
|
||||
:disabled="disabled"
|
||||
class="appearance-none bg-none rounded-lg border-0 outline-1 outline -outline-offset-1 transition-all duration-200 bg-n-surface-1 !mb-0 py-2 px-3 pr-10 text-sm"
|
||||
:class="{
|
||||
'outline-n-weak hover:outline-n-slate-6 focus:outline-n-blue-9':
|
||||
!error && !disabled,
|
||||
'outline-n-red-9 focus:outline-n-red-9': error && !disabled,
|
||||
'outline-n-weak bg-n-slate-2 cursor-not-allowed opacity-60': disabled,
|
||||
}"
|
||||
>
|
||||
<option v-if="placeholder" value="" disabled>
|
||||
{{ placeholder }}
|
||||
</option>
|
||||
<template v-if="groups.length">
|
||||
<optgroup
|
||||
v-for="group in groups"
|
||||
:key="group.label"
|
||||
:label="group.label"
|
||||
>
|
||||
<option
|
||||
v-for="option in group.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</template>
|
||||
<template v-else>
|
||||
<option
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="size-4 text-n-slate-11"
|
||||
:class="{ 'opacity-50': disabled }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -25,8 +25,8 @@ const reauthorizationRequired = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="size-5 grid place-content-center rounded-full bg-n-alpha-2">
|
||||
<ChannelIcon :inbox="inbox" class="size-3" />
|
||||
<span class="size-4 grid place-content-center rounded-full">
|
||||
<ChannelIcon :inbox="inbox" class="size-4" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||
<div
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useWindowSize, useEventListener } from '@vueuse/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
@@ -50,6 +51,18 @@ const isRTL = useMapGetter('accounts/isRTL');
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const isMobile = computed(() => windowWidth.value < 768);
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const hasAdvancedAssignment = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.ADVANCED_ASSIGNMENT
|
||||
);
|
||||
});
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -273,7 +286,7 @@ const menuItems = computed(() => {
|
||||
children: sortedInboxes.value.map(inbox => ({
|
||||
name: `${inbox.name}-${inbox.id}`,
|
||||
label: inbox.name,
|
||||
icon: h(ChannelIcon, { inbox, class: 'size-[12px]' }),
|
||||
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
|
||||
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
||||
component: leafProps =>
|
||||
h(ChannelLeaf, {
|
||||
@@ -582,18 +595,49 @@ const menuItems = computed(() => {
|
||||
name: 'Settings Teams',
|
||||
label: t('SIDEBAR.TEAMS'),
|
||||
icon: 'i-lucide-users',
|
||||
activeOn: [
|
||||
'settings_teams_list',
|
||||
'settings_teams_new',
|
||||
'settings_teams_finish',
|
||||
'settings_teams_add_agents',
|
||||
'settings_teams_show',
|
||||
'settings_teams_edit',
|
||||
'settings_teams_edit_members',
|
||||
'settings_teams_edit_finish',
|
||||
],
|
||||
to: accountScopedRoute('settings_teams_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Agent Assignment',
|
||||
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
||||
icon: 'i-lucide-user-cog',
|
||||
to: accountScopedRoute('assignment_policy_index'),
|
||||
},
|
||||
...(hasAdvancedAssignment.value
|
||||
? [
|
||||
{
|
||||
name: 'Settings Agent Assignment',
|
||||
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
||||
icon: 'i-lucide-user-cog',
|
||||
activeOn: [
|
||||
'assignment_policy_index',
|
||||
'agent_assignment_policy_index',
|
||||
'agent_assignment_policy_create',
|
||||
'agent_assignment_policy_edit',
|
||||
'agent_capacity_policy_index',
|
||||
'agent_capacity_policy_create',
|
||||
'agent_capacity_policy_edit',
|
||||
],
|
||||
to: accountScopedRoute('assignment_policy_index'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Settings Inboxes',
|
||||
label: t('SIDEBAR.INBOXES'),
|
||||
icon: 'i-lucide-inbox',
|
||||
activeOn: [
|
||||
'settings_inbox_list',
|
||||
'settings_inbox_show',
|
||||
'settings_inbox_new',
|
||||
'settings_inbox_finish',
|
||||
'settings_inboxes_page_channel',
|
||||
'settings_inboxes_add_agents',
|
||||
],
|
||||
to: accountScopedRoute('settings_inbox_list'),
|
||||
},
|
||||
{
|
||||
@@ -686,7 +730,7 @@ const menuItems = computed(() => {
|
||||
closeMobileSidebar,
|
||||
{ ignore: ['#mobile-sidebar-launcher'] },
|
||||
]"
|
||||
class="bg-n-background flex flex-col text-sm pb-0.5 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
|
||||
class="bg-n-background flex flex-col text-sm pb-px fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
|
||||
:class="[
|
||||
{
|
||||
'shadow-lg md:shadow-none': isMobileSidebarOpen,
|
||||
@@ -807,7 +851,7 @@ const menuItems = computed(() => {
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="p-1 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
|
||||
class="px-1 py-1.5 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
|
||||
:class="isEffectivelyCollapsed ? 'justify-center' : 'justify-between'"
|
||||
>
|
||||
<SidebarProfileMenu
|
||||
|
||||
@@ -44,8 +44,10 @@ const shouldRenderComponent = computed(() => {
|
||||
:active
|
||||
/>
|
||||
<template v-else>
|
||||
<Icon v-if="icon" :icon="icon" class="size-4 inline-block" />
|
||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
|
||||
<Icon :icon="icon" class="size-4 inline-block" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
|
||||
</template>
|
||||
</component>
|
||||
</Policy>
|
||||
|
||||
@@ -19,20 +19,24 @@ const updateValue = () => {
|
||||
<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-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 flex-shrink-0"
|
||||
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'"
|
||||
class="group relative h-4 rounded-full w-7 flex-shrink-0 select-none focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 transition-colors duration-200 ease-in-out"
|
||||
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6'"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
@click="updateValue"
|
||||
>
|
||||
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-out"
|
||||
class="absolute top-1/2 ltr:left-0.5 rtl:right-0.5 -translate-y-1/2 transition-transform duration-[350ms] ease-[cubic-bezier(0.34,1.56,0.64,1)]"
|
||||
:class="
|
||||
modelValue
|
||||
? 'translate-x-3 bg-n-background'
|
||||
: 'translate-x-0 bg-n-background'
|
||||
? 'ltr:translate-x-3 rtl:-translate-x-3 group-active:ltr:translate-x-[6px] rtl:group-active:-translate-x-[6px]'
|
||||
: 'ltr:translate-x-0 rtl:translate-x-0'
|
||||
"
|
||||
/>
|
||||
>
|
||||
<span
|
||||
class="block h-3 w-3 rounded-full bg-n-background shadow-md transition-[width] duration-[180ms] ease-in-out group-active:w-[18px]"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { BaseTable, BaseTableRow, BaseTableCell } from './index';
|
||||
import Button from '../button/Button.vue';
|
||||
import Avatar from '../avatar/Avatar.vue';
|
||||
import ToggleSwitch from '../switch/Switch.vue';
|
||||
|
||||
const automationData = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Welcome Message',
|
||||
description: 'Send welcome message to new contacts',
|
||||
active: true,
|
||||
createdOn: 'Apr 21, 2022',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Auto-assign to Sales',
|
||||
description: 'Automatically assign sales conversations to sales team',
|
||||
active: false,
|
||||
createdOn: 'May 15, 2022',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Tag Premium Users',
|
||||
description: 'Add premium tag to conversations from premium users',
|
||||
active: true,
|
||||
createdOn: 'Jun 10, 2022',
|
||||
},
|
||||
]);
|
||||
|
||||
const agentData = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
role: 'Administrator',
|
||||
verified: true,
|
||||
avatarUrl: '',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
role: 'Agent',
|
||||
verified: true,
|
||||
avatarUrl: '',
|
||||
},
|
||||
]);
|
||||
|
||||
const emptyData = ref([]);
|
||||
|
||||
const headers = ['Name', 'Active', 'Created on', 'Actions'];
|
||||
const agentHeaders = ['Agent', 'Role', 'Verification', 'Actions'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Table" :layout="{ type: 'grid', width: '100%' }">
|
||||
<!-- Basic Table -->
|
||||
<Variant title="Basic Table">
|
||||
<div class="p-4 bg-n-surface-1">
|
||||
<BaseTable :headers="headers" :items="automationData">
|
||||
<template #row="{ items }">
|
||||
<BaseTableRow
|
||||
v-for="automation in items"
|
||||
:key="automation.id"
|
||||
:item="automation"
|
||||
>
|
||||
<template #default>
|
||||
<BaseTableCell>
|
||||
<div class="flex items-center gap-2 min-w-0 max-w-full">
|
||||
<span
|
||||
class="text-body-main text-n-slate-12 truncate min-w-0 flex-1"
|
||||
>
|
||||
{{ automation.name }}
|
||||
</span>
|
||||
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
|
||||
<span
|
||||
class="text-body-main text-n-slate-11 truncate min-w-0 flex-1"
|
||||
>
|
||||
{{ automation.description }}
|
||||
</span>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
|
||||
<BaseTableCell>
|
||||
<div class="flex justify-center">
|
||||
<ToggleSwitch v-model="automation.active" />
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
|
||||
<BaseTableCell>
|
||||
<span
|
||||
class="text-body-main text-n-slate-12 whitespace-nowrap"
|
||||
>
|
||||
{{ automation.createdOn }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
|
||||
<BaseTableCell align="end" class="w-24">
|
||||
<div class="flex gap-3 justify-end flex-shrink-0">
|
||||
<Button icon="i-woot-edit-pen" slate sm />
|
||||
<Button icon="i-woot-bin" slate sm />
|
||||
<Button icon="i-woot-clone" slate sm />
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
</BaseTableRow>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Table with Avatars -->
|
||||
<Variant title="Table with Avatars">
|
||||
<div class="p-4 bg-n-surface-1">
|
||||
<BaseTable :headers="agentHeaders" :items="agentData">
|
||||
<template #row="{ items }">
|
||||
<BaseTableRow v-for="agent in items" :key="agent.id" :item="agent">
|
||||
<template #default>
|
||||
<BaseTableCell>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Avatar :user="agent" :size="40" class="flex-shrink-0" />
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ agent.name }}
|
||||
</span>
|
||||
<span class="text-body-main text-n-slate-11 truncate">
|
||||
{{ agent.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
|
||||
<BaseTableCell>
|
||||
<span
|
||||
class="text-body-main text-n-slate-12 whitespace-nowrap"
|
||||
>
|
||||
{{ agent.role }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
|
||||
<BaseTableCell>
|
||||
<span
|
||||
class="text-body-main text-n-slate-12 whitespace-nowrap"
|
||||
>
|
||||
{{ agent.verified ? 'Verified' : 'Pending' }}
|
||||
</span>
|
||||
</BaseTableCell>
|
||||
|
||||
<BaseTableCell align="end" class="w-24">
|
||||
<div class="flex gap-3 justify-end flex-shrink-0">
|
||||
<Button icon="i-woot-edit-pen" slate sm />
|
||||
<Button icon="i-woot-bin" slate sm />
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
</BaseTableRow>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Empty State -->
|
||||
<Variant title="Empty State">
|
||||
<div class="p-4 bg-n-surface-1">
|
||||
<BaseTable
|
||||
:headers="headers"
|
||||
:items="emptyData"
|
||||
no-data-message="No automation rules found"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
headers: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
noDataMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const hasHeaderSlot = computed(() => !!props.headers.length);
|
||||
const showHeaders = computed(
|
||||
() => hasHeaderSlot.value && props.items.length > 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<table class="min-w-full table-auto divide-y divide-n-weak">
|
||||
<thead v-if="showHeaders" class="border-t border-n-weak">
|
||||
<tr>
|
||||
<th
|
||||
v-for="(header, index) in headers"
|
||||
:key="index"
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 text-start text-heading-3 text-n-slate-12 capitalize"
|
||||
>
|
||||
<slot :name="`header-${index}`" :header="header">
|
||||
{{ header }}
|
||||
</slot>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-n-weak text-n-slate-11">
|
||||
<template v-if="items.length">
|
||||
<slot name="row" :items="items" />
|
||||
</template>
|
||||
<tr v-else-if="noDataMessage && !loading">
|
||||
<td
|
||||
:colspan="headers.length || 1"
|
||||
class="py-20 text-center text-body-main !text-base text-n-slate-11"
|
||||
>
|
||||
{{ noDataMessage }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
align: {
|
||||
type: String,
|
||||
default: 'start',
|
||||
validator: value => ['start', 'center', 'end'].includes(value),
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td
|
||||
class="py-3 ltr:pr-4 rtl:pl-4 text-body-main"
|
||||
:class="{
|
||||
'text-start': align === 'start',
|
||||
'text-center': align === 'center',
|
||||
'text-end': align === 'end',
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr>
|
||||
<slot :item="item" />
|
||||
</tr>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as BaseTable } from './BaseTable.vue';
|
||||
export { default as BaseTableRow } from './BaseTableRow.vue';
|
||||
export { default as BaseTableCell } from './BaseTableCell.vue';
|
||||
@@ -36,6 +36,11 @@ const props = defineProps({
|
||||
},
|
||||
focusOnMount: { type: Boolean, default: false },
|
||||
allowCreate: { type: Boolean, default: false },
|
||||
// Skip label-based dedup when the consumer already filters menuItems by ID.
|
||||
// Prevents removing all same-name items when one is selected (e.g. duplicate agent names).
|
||||
skipLabelDedup: { type: Boolean, default: false },
|
||||
// When false, the dropdown won't auto-open on mount; it opens only on click/focus.
|
||||
autoOpenDropdown: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -56,7 +61,7 @@ const modelValue = defineModel({
|
||||
const tagInputRef = ref(null);
|
||||
const tags = ref(props.modelValue);
|
||||
const newTag = ref('');
|
||||
const isFocused = ref(true);
|
||||
const isFocused = ref(props.autoOpenDropdown);
|
||||
|
||||
const rules = computed(() => getValidationRules(props.type));
|
||||
const v$ = useVuelidate(rules, { newTag });
|
||||
@@ -67,18 +72,18 @@ const isNewTagInValidType = computed(() =>
|
||||
|
||||
const showInput = computed(() =>
|
||||
props.mode === MODE.SINGLE
|
||||
? isFocused.value && !tags.value.length
|
||||
? !tags.value.length
|
||||
: isFocused.value || !tags.value.length
|
||||
);
|
||||
|
||||
const showDropdownMenu = computed(() =>
|
||||
props.mode === MODE.SINGLE && tags.value.length >= 1
|
||||
? false
|
||||
: props.showDropdown
|
||||
: props.showDropdown && isFocused.value
|
||||
);
|
||||
|
||||
const filteredMenuItems = computed(() =>
|
||||
buildTagMenuItems({
|
||||
const filteredMenuItems = computed(() => {
|
||||
const items = buildTagMenuItems({
|
||||
mode: props.mode,
|
||||
tags: tags.value,
|
||||
menuItems: props.menuItems,
|
||||
@@ -86,8 +91,14 @@ const filteredMenuItems = computed(() =>
|
||||
isLoading: props.isLoading,
|
||||
type: props.type,
|
||||
isNewTagInValidType: isNewTagInValidType.value,
|
||||
})
|
||||
);
|
||||
allowCreate: props.allowCreate,
|
||||
skipLabelDedup: props.skipLabelDedup,
|
||||
});
|
||||
if (props.type !== INPUT_TYPES.TEXT) return items;
|
||||
const query = newTag.value?.trim()?.toLowerCase();
|
||||
if (!query) return items;
|
||||
return items.filter(item => item.label?.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
const emitDataOnAdd = value => {
|
||||
const matchingMenuItem = findMatchingMenuItem(props.menuItems, value);
|
||||
@@ -112,10 +123,13 @@ const addTag = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
[INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(props.type) ||
|
||||
props.allowCreate
|
||||
) {
|
||||
const isValidatedType = [INPUT_TYPES.EMAIL, INPUT_TYPES.TEL].includes(
|
||||
props.type
|
||||
);
|
||||
|
||||
if (!isValidatedType && !props.allowCreate && props.showDropdown) return;
|
||||
|
||||
if (isValidatedType || props.allowCreate) {
|
||||
if (!(await v$.value.$validate())) return;
|
||||
emitDataOnAdd(trimmedTag);
|
||||
}
|
||||
@@ -125,28 +139,31 @@ const addTag = async () => {
|
||||
const removeTag = index => {
|
||||
tags.value.splice(index, 1);
|
||||
modelValue.value = tags.value;
|
||||
emit('remove');
|
||||
emit('remove', index);
|
||||
};
|
||||
|
||||
const handleDropdownAction = async ({
|
||||
email: emailAddress,
|
||||
phoneNumber,
|
||||
label,
|
||||
...rest
|
||||
}) => {
|
||||
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
|
||||
if (!props.showDropdown) return;
|
||||
|
||||
const isEmail = props.type === 'email';
|
||||
newTag.value = isEmail ? emailAddress : phoneNumber;
|
||||
const isEmail = props.type === INPUT_TYPES.EMAIL;
|
||||
const tagValue = isEmail ? emailAddress : phoneNumber || label;
|
||||
|
||||
if (!(await v$.value.$validate())) return;
|
||||
if (isEmail || props.type === INPUT_TYPES.TEL) {
|
||||
newTag.value = tagValue;
|
||||
if (!(await v$.value.$validate())) return;
|
||||
}
|
||||
|
||||
const payload = isEmail
|
||||
? { email: emailAddress, ...rest }
|
||||
: { phoneNumber, ...rest };
|
||||
|
||||
emit('add', payload);
|
||||
updateValueAndFocus(emailAddress);
|
||||
emit(
|
||||
'add',
|
||||
isEmail ? { email: emailAddress, ...rest } : { phoneNumber, label, ...rest }
|
||||
);
|
||||
updateValueAndFocus(tagValue);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -163,7 +180,7 @@ const handleKeydown = event => {
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (tags.value.length) isFocused.value = false;
|
||||
isFocused.value = false;
|
||||
emit('onClickOutside');
|
||||
};
|
||||
|
||||
|
||||
@@ -261,6 +261,71 @@ describe('tagInputHelper', () => {
|
||||
});
|
||||
expect(result).toEqual(menuItems);
|
||||
});
|
||||
|
||||
it('does not create suggestion when allowCreate is false', () => {
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
type: INPUT_TYPES.EMAIL,
|
||||
newTag: 'test@example.com',
|
||||
allowCreate: false,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('still returns available menu items when allowCreate is false', () => {
|
||||
const menuItems = [
|
||||
{ label: 'Agent 1', value: '1' },
|
||||
{ label: 'Agent 2', value: '2' },
|
||||
];
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
menuItems,
|
||||
newTag: 'Agent',
|
||||
allowCreate: false,
|
||||
});
|
||||
expect(result).toEqual(menuItems);
|
||||
});
|
||||
|
||||
it('creates new item when allowCreate is true (default)', () => {
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
type: INPUT_TYPES.EMAIL,
|
||||
newTag: 'test@example.com',
|
||||
allowCreate: true,
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
label: 'test@example.com',
|
||||
action: 'create',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips label dedup when skipLabelDedup is true', () => {
|
||||
const menuItems = [
|
||||
{ label: 'HDMA', value: 1 },
|
||||
{ label: 'HDMA', value: 2 },
|
||||
];
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
tags: ['HDMA'],
|
||||
menuItems,
|
||||
skipLabelDedup: true,
|
||||
});
|
||||
expect(result).toEqual(menuItems);
|
||||
});
|
||||
|
||||
it('filters by label when skipLabelDedup is false (default)', () => {
|
||||
const menuItems = [
|
||||
{ label: 'HDMA', value: 1 },
|
||||
{ label: 'HDMA', value: 2 },
|
||||
];
|
||||
const result = buildTagMenuItems({
|
||||
...baseParams,
|
||||
tags: ['HDMA'],
|
||||
menuItems,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAddTag', () => {
|
||||
|
||||
@@ -84,25 +84,29 @@ export const buildTagMenuItems = ({
|
||||
isLoading,
|
||||
type,
|
||||
isNewTagInValidType,
|
||||
allowCreate = true,
|
||||
skipLabelDedup = false,
|
||||
}) => {
|
||||
if (mode === MODE.SINGLE && tags.length >= 1) return [];
|
||||
|
||||
const availableMenuItems = menuItems.filter(
|
||||
item => !tags.includes(item.label)
|
||||
);
|
||||
const availableMenuItems = skipLabelDedup
|
||||
? menuItems
|
||||
: menuItems.filter(item => !tags.includes(item.label));
|
||||
|
||||
// Show typed value as suggestion only if:
|
||||
// 1. There's a value being typed
|
||||
// 2. The value isn't already in the tags
|
||||
// 3. Validation passes (email/phone) and There are no menu items available
|
||||
// 4. allowCreate is enabled
|
||||
const trimmedNewTag = newTag?.trim();
|
||||
const shouldShowTypedValue =
|
||||
const shouldShowCreateSuggestion =
|
||||
allowCreate &&
|
||||
trimmedNewTag &&
|
||||
!tags.includes(trimmedNewTag) &&
|
||||
!isLoading &&
|
||||
!availableMenuItems.length;
|
||||
|
||||
if (shouldShowTypedValue) {
|
||||
if (shouldShowCreateSuggestion) {
|
||||
const { isValid, formattedValue } = validateAndFormatNewTag(
|
||||
trimmedNewTag,
|
||||
type,
|
||||
|
||||
@@ -18,7 +18,7 @@ const props = defineProps({
|
||||
},
|
||||
year: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ defineProps({
|
||||
|
||||
<div
|
||||
v-if="isComingSoon"
|
||||
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-background/90 via-n-background/70 to-n-background/95 cursor-not-allowed"
|
||||
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-surface-1/90 via-n-surface-1/70 to-n-surface-1/95 cursor-not-allowed"
|
||||
>
|
||||
<span class="text-n-slate-12 font-medium text-sm">
|
||||
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
|
||||
|
||||
@@ -17,10 +17,7 @@ import {
|
||||
useFunctionGetter,
|
||||
} from 'dashboard/composables/store.js';
|
||||
|
||||
// [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate
|
||||
// from the current one. But we should consider using tanstack virtual in the future
|
||||
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
|
||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
import ChatListHeader from './ChatListHeader.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ConversationFilter from 'next/filter/ConversationFilter.vue';
|
||||
@@ -29,9 +26,9 @@ import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import IntersectionObserver from './IntersectionObserver.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -46,7 +43,6 @@ import {
|
||||
useSnakeCase,
|
||||
} from 'dashboard/composables/useTransformKeys';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
@@ -70,8 +66,6 @@ import { matchesFilters } from '../store/modules/conversations/helpers/filterHel
|
||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
|
||||
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
|
||||
const props = defineProps({
|
||||
conversationInbox: { type: [String, Number], default: 0 },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
@@ -91,9 +85,9 @@ const store = useStore();
|
||||
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
const conversationListRef = ref(null);
|
||||
const conversationDynamicScroller = ref(null);
|
||||
const virtualListRef = ref(null);
|
||||
|
||||
provide('contextMenuElementTarget', conversationDynamicScroller);
|
||||
provide('contextMenuElementTarget', virtualListRef);
|
||||
|
||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||
@@ -145,6 +139,7 @@ const {
|
||||
isConversationSelected,
|
||||
onAssignAgent,
|
||||
onAssignLabels,
|
||||
onRemoveLabels,
|
||||
onAssignTeamsForBulk,
|
||||
onUpdateConversations,
|
||||
} = useBulkActions();
|
||||
@@ -160,12 +155,6 @@ const {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
// computed
|
||||
const intersectionObserverOptions = computed(() => {
|
||||
return {
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
};
|
||||
});
|
||||
|
||||
const hasAppliedFilters = computed(() => {
|
||||
return appliedFilters.value.length !== 0;
|
||||
@@ -383,18 +372,6 @@ function setFiltersFromUISettings() {
|
||||
|
||||
function emitConversationLoaded() {
|
||||
emit('conversationLoad');
|
||||
// [VITE] removing this since the library has changed
|
||||
// nextTick(() => {
|
||||
// // Addressing a known issue in the virtual list library where dynamically added items
|
||||
// // might not render correctly. This workaround involves a slight manual adjustment
|
||||
// // to the scroll position, triggering the list to refresh its rendering.
|
||||
// const virtualList = conversationListRef.value;
|
||||
// const scrollToOffset = virtualList?.scrollToOffset;
|
||||
// const currentOffset = virtualList?.getOffset() || 0;
|
||||
// if (scrollToOffset) {
|
||||
// scrollToOffset(currentOffset + 1);
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
function fetchFilteredConversations(payload) {
|
||||
@@ -606,16 +583,13 @@ function loadMoreConversations() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add a method to handle scroll events
|
||||
function handleScroll() {
|
||||
const scroller = conversationDynamicScroller.value;
|
||||
if (scroller && scroller.hasScrollbar) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scroller.$el;
|
||||
if (scrollHeight - (scrollTop + clientHeight) < 100) {
|
||||
loadMoreConversations();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
|
||||
// If the list doesn’t fill the viewport, loading can stall.
|
||||
// IntersectionObserver triggers as soon as the sentinel is visible.
|
||||
const intersectionObserverOptions = computed(() => ({
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
}));
|
||||
|
||||
function updateAssigneeTab(selectedTab) {
|
||||
if (activeAssigneeTab.value !== selectedTab) {
|
||||
@@ -821,8 +795,6 @@ useEmitter('fetch_conversation_stats', () => {
|
||||
store.dispatch('conversationStats/get', conversationFilters.value);
|
||||
});
|
||||
|
||||
useEventListener(conversationDynamicScroller, 'scroll', handleScroll);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('setChatListFilters', conversationFilters.value);
|
||||
setFiltersFromUISettings();
|
||||
@@ -859,6 +831,7 @@ provide('deSelectConversation', deSelectConversation);
|
||||
provide('assignAgent', onAssignAgent);
|
||||
provide('assignTeam', onAssignTeam);
|
||||
provide('assignLabels', onAssignLabels);
|
||||
provide('removeLabels', onRemoveLabels);
|
||||
provide('updateConversationStatus', handleResolveConversation);
|
||||
provide('toggleContextMenu', onContextMenuToggle);
|
||||
provide('markAsUnread', markAsUnread);
|
||||
@@ -975,61 +948,40 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
/>
|
||||
<div
|
||||
ref="conversationListRef"
|
||||
class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
|
||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||
class="flex-1 min-h-0 overflow-y-auto conversations-list"
|
||||
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<DynamicScroller
|
||||
ref="conversationDynamicScroller"
|
||||
:items="conversationList"
|
||||
:min-item-size="24"
|
||||
class="overflow-auto w-full h-full"
|
||||
<Virtualizer
|
||||
ref="virtualListRef"
|
||||
v-slot="{ item, index }"
|
||||
:data="conversationList"
|
||||
>
|
||||
<template #default="{ item, index, active }">
|
||||
<!--
|
||||
If we encounter resizing issues, we can set the `watchData` prop to true
|
||||
this will deeply watch the entire object instead of just size dependencies
|
||||
But it can impact performance
|
||||
-->
|
||||
<DynamicScrollerItem
|
||||
:item="item"
|
||||
:active="active"
|
||||
:data-index="index"
|
||||
:size-dependencies="[
|
||||
item.messages,
|
||||
item.labels,
|
||||
item.uuid,
|
||||
item.inbox_id,
|
||||
]"
|
||||
>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</DynamicScrollerItem>
|
||||
</template>
|
||||
<template #after>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</template>
|
||||
</DynamicScroller>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:data-index="index"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</Virtualizer>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</div>
|
||||
<Dialog
|
||||
ref="deleteConversationDialogRef"
|
||||
|
||||
@@ -10,6 +10,7 @@ export default {
|
||||
'assignAgent',
|
||||
'assignTeam',
|
||||
'assignLabels',
|
||||
'removeLabels',
|
||||
'updateConversationStatus',
|
||||
'toggleContextMenu',
|
||||
'markAsUnread',
|
||||
@@ -49,7 +50,6 @@ export default {
|
||||
|
||||
<template>
|
||||
<ConversationCard
|
||||
:key="source.id"
|
||||
:active-label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
@@ -63,6 +63,7 @@ export default {
|
||||
@assign-agent="assignAgent"
|
||||
@assign-team="assignTeam"
|
||||
@assign-label="assignLabels"
|
||||
@remove-label="removeLabels"
|
||||
@update-conversation-status="updateConversationStatus"
|
||||
@context-menu-toggle="toggleContextMenu"
|
||||
@mark-as-unread="markAsUnread"
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start w-full gap-6">
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<h4 v-if="title" class="text-lg font-medium text-n-slate-12">
|
||||
{{ title }}
|
||||
</h4>
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<div class="flex-grow h-px bg-n-weak" />
|
||||
</div>
|
||||
<p v-if="description" class="mb-0 text-sm font-normal text-n-slate-12">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-6">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
getActiveDateRange,
|
||||
moveCalendarDate,
|
||||
DATE_RANGE_TYPES,
|
||||
CALENDAR_TYPES,
|
||||
CALENDAR_PERIODS,
|
||||
isNavigableRange,
|
||||
getRangeAtOffset,
|
||||
} from './helpers/DatePickerHelper';
|
||||
import {
|
||||
isValid,
|
||||
@@ -13,14 +16,14 @@ import {
|
||||
subDays,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
isBefore,
|
||||
subMonths,
|
||||
addMonths,
|
||||
isSameMonth,
|
||||
differenceInCalendarMonths,
|
||||
differenceInCalendarWeeks,
|
||||
setMonth,
|
||||
setYear,
|
||||
isAfter,
|
||||
getWeek,
|
||||
} from 'date-fns';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import DatePickerButton from './components/DatePickerButton.vue';
|
||||
@@ -32,98 +35,187 @@ import CalendarWeek from './components/CalendarWeek.vue';
|
||||
import CalendarFooter from './components/CalendarFooter.vue';
|
||||
|
||||
const emit = defineEmits(['dateRangeChanged']);
|
||||
const { LAST_7_DAYS, LAST_30_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
|
||||
const { t } = useI18n();
|
||||
|
||||
const dateRange = defineModel('dateRange', {
|
||||
type: Array,
|
||||
default: undefined,
|
||||
});
|
||||
|
||||
const rangeType = defineModel('rangeType', {
|
||||
type: String,
|
||||
default: undefined,
|
||||
});
|
||||
const { LAST_7_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
|
||||
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
|
||||
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
|
||||
|
||||
const showDatePicker = ref(false);
|
||||
const calendarViews = ref({ start: WEEK, end: WEEK });
|
||||
const currentDate = ref(new Date());
|
||||
const selectedStartDate = ref(startOfDay(subDays(currentDate.value, 6))); // LAST_7_DAYS
|
||||
const selectedEndDate = ref(endOfDay(currentDate.value));
|
||||
// Setting the start and end calendar
|
||||
const startCurrentDate = ref(startOfDay(selectedStartDate.value));
|
||||
|
||||
// Use dates from v-model if provided, otherwise default to last 7 days
|
||||
const selectedStartDate = ref(
|
||||
dateRange.value?.[0]
|
||||
? startOfDay(dateRange.value[0])
|
||||
: startOfDay(subDays(currentDate.value, 6)) // LAST_7_DAYS
|
||||
);
|
||||
const selectedEndDate = ref(
|
||||
dateRange.value?.[1]
|
||||
? endOfDay(dateRange.value[1])
|
||||
: endOfDay(currentDate.value)
|
||||
);
|
||||
// Calendar month positioning (left and right calendars)
|
||||
// These control which months are displayed in the dual calendar view
|
||||
const startCurrentDate = ref(startOfMonth(selectedStartDate.value));
|
||||
const endCurrentDate = ref(
|
||||
isSameMonth(selectedStartDate.value, selectedEndDate.value)
|
||||
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Moves to the start of the next month if dates are in the same month (Mounted case LAST_7_DAYS)
|
||||
: startOfMonth(selectedEndDate.value) // Always shows the month of the end date starting from the first (Mounted case LAST_7_DAYS)
|
||||
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Same month: show next month on right (e.g., Jan 25-31 shows Jan + Feb)
|
||||
: startOfMonth(selectedEndDate.value) // Different months: show end month on right (e.g., Dec 5 - Jan 3 shows Dec + Jan)
|
||||
);
|
||||
const selectingEndDate = ref(false);
|
||||
const selectedRange = ref(LAST_7_DAYS);
|
||||
const selectedRange = ref(rangeType.value || LAST_7_DAYS);
|
||||
const hoveredEndDate = ref(null);
|
||||
const monthOffset = ref(0);
|
||||
|
||||
const showMonthNavigation = computed(() =>
|
||||
isNavigableRange(selectedRange.value)
|
||||
);
|
||||
const canNavigateNext = computed(() => {
|
||||
if (!isNavigableRange(selectedRange.value)) return false;
|
||||
// Compare selected start to the current period's start to determine if we're in the past
|
||||
const currentRange = getActiveDateRange(
|
||||
selectedRange.value,
|
||||
currentDate.value
|
||||
);
|
||||
return selectedStartDate.value < currentRange.start;
|
||||
});
|
||||
|
||||
const navigationLabel = computed(() => {
|
||||
const range = selectedRange.value;
|
||||
if (range === DATE_RANGE_TYPES.MONTH_TO_DATE) {
|
||||
return new Intl.DateTimeFormat(navigator.language, {
|
||||
month: 'long',
|
||||
}).format(selectedStartDate.value);
|
||||
}
|
||||
if (range === DATE_RANGE_TYPES.THIS_WEEK) {
|
||||
const currentWeekRange = getActiveDateRange(range, currentDate.value);
|
||||
const isCurrentWeek =
|
||||
selectedStartDate.value.getTime() === currentWeekRange.start.getTime();
|
||||
if (isCurrentWeek) return null;
|
||||
const weekNumber = getWeek(selectedStartDate.value, { weekStartsOn: 1 });
|
||||
return t('DATE_PICKER.WEEK_NUMBER', { weekNumber });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const manualStartDate = ref(selectedStartDate.value);
|
||||
const manualEndDate = ref(selectedEndDate.value);
|
||||
|
||||
// Watcher will set the start and end dates based on the selected range
|
||||
watch(selectedRange, newRange => {
|
||||
if (newRange !== CUSTOM_RANGE) {
|
||||
// If selecting a range other than last 7 days or last 30 days, set the start and end dates to the selected start and end dates
|
||||
// If selecting last 7 days or last 30 days is, set the start date to the selected start date
|
||||
// and the end date to one month ahead of the start date if the start date and end date are in the same month
|
||||
// Otherwise set the end date to the selected end date
|
||||
const isLastSevenOrThirtyDays =
|
||||
newRange === LAST_7_DAYS || newRange === LAST_30_DAYS;
|
||||
startCurrentDate.value = selectedStartDate.value;
|
||||
endCurrentDate.value =
|
||||
isLastSevenOrThirtyDays &&
|
||||
isSameMonth(selectedStartDate.value, selectedEndDate.value)
|
||||
? startOfMonth(addMonths(selectedStartDate.value, 1))
|
||||
: selectedEndDate.value;
|
||||
selectingEndDate.value = false;
|
||||
} else if (!selectingEndDate.value) {
|
||||
// If selecting a custom range and not selecting an end date, set the start date to the selected start date
|
||||
startCurrentDate.value = startOfDay(currentDate.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Watcher will set the input values based on the selected start and end dates
|
||||
// Watcher 1: Sync v-model props from parent component
|
||||
// Handles: URL params, parent component updates, rangeType changes
|
||||
watch(
|
||||
[selectedStartDate, selectedEndDate],
|
||||
([newStart, newEnd]) => {
|
||||
if (isValid(newStart)) {
|
||||
manualStartDate.value = newStart;
|
||||
} else {
|
||||
manualStartDate.value = selectedStartDate.value;
|
||||
[rangeType, dateRange],
|
||||
([newRangeType, newDateRange]) => {
|
||||
if (newRangeType && newRangeType !== selectedRange.value) {
|
||||
selectedRange.value = newRangeType;
|
||||
monthOffset.value = 0;
|
||||
|
||||
// If rangeType changes without dateRange, recompute dates from the range
|
||||
if (!newDateRange && newRangeType !== CUSTOM_RANGE) {
|
||||
const activeDates = getActiveDateRange(newRangeType, currentDate.value);
|
||||
if (activeDates) {
|
||||
selectedStartDate.value = startOfDay(activeDates.start);
|
||||
selectedEndDate.value = endOfDay(activeDates.end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isValid(newEnd)) {
|
||||
manualEndDate.value = newEnd;
|
||||
} else {
|
||||
manualEndDate.value = selectedEndDate.value;
|
||||
// When parent provides new dateRange (e.g., from URL params)
|
||||
// Skip if navigating with arrows — offset controls dates in that case
|
||||
if (newDateRange?.[0] && newDateRange?.[1] && monthOffset.value === 0) {
|
||||
selectedStartDate.value = startOfDay(newDateRange[0]);
|
||||
selectedEndDate.value = endOfDay(newDateRange[1]);
|
||||
|
||||
// Update calendar to show the months of the new date range
|
||||
startCurrentDate.value = startOfMonth(newDateRange[0]);
|
||||
endCurrentDate.value = isSameMonth(newDateRange[0], newDateRange[1])
|
||||
? startOfMonth(addMonths(newDateRange[1], 1))
|
||||
: startOfMonth(newDateRange[1]);
|
||||
|
||||
// Recalculate offset so arrow navigation is relative to restored range
|
||||
// TODO: When offset resolves to 0 (current period), the end date may be
|
||||
// stale if the URL was saved on a previous day. "This month" / "This week"
|
||||
// should show up-to-today dates for the current period. For now, the stale
|
||||
// end date is shown until the user clicks an arrow or re-selects the range.
|
||||
if (isNavigableRange(selectedRange.value)) {
|
||||
const current = getActiveDateRange(
|
||||
selectedRange.value,
|
||||
currentDate.value
|
||||
);
|
||||
if (selectedRange.value === DATE_RANGE_TYPES.THIS_WEEK) {
|
||||
monthOffset.value = differenceInCalendarWeeks(
|
||||
newDateRange[0],
|
||||
current.start,
|
||||
{ weekStartsOn: 1 }
|
||||
);
|
||||
} else {
|
||||
monthOffset.value = differenceInCalendarMonths(
|
||||
newDateRange[0],
|
||||
current.start
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Watcher to ensure dates are always in logical order
|
||||
// This watch is will ensure that the start date is always before the end date
|
||||
// Watcher 2: Keep manual input fields in sync with selected dates
|
||||
// Updates the input field values when dates change programmatically
|
||||
watch(
|
||||
[startCurrentDate, endCurrentDate],
|
||||
([newStart, newEnd], [oldStart, oldEnd]) => {
|
||||
const monthDifference = differenceInCalendarMonths(newEnd, newStart);
|
||||
|
||||
if (newStart !== oldStart) {
|
||||
if (isAfter(newStart, newEnd) || monthDifference === 0) {
|
||||
// Adjust the end date forward if the start date is adjusted and is after the end date or in the same month
|
||||
endCurrentDate.value = addMonths(newStart, 1);
|
||||
}
|
||||
}
|
||||
if (newEnd !== oldEnd) {
|
||||
if (isBefore(newEnd, newStart) || monthDifference === 0) {
|
||||
// Adjust the start date backward if the end date is adjusted and is before the start date or in the same month
|
||||
startCurrentDate.value = subMonths(newEnd, 1);
|
||||
}
|
||||
}
|
||||
[selectedStartDate, selectedEndDate],
|
||||
([newStart, newEnd]) => {
|
||||
manualStartDate.value = isValid(newStart)
|
||||
? newStart
|
||||
: selectedStartDate.value;
|
||||
manualEndDate.value = isValid(newEnd) ? newEnd : selectedEndDate.value;
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const setDateRange = range => {
|
||||
selectedRange.value = range.value;
|
||||
monthOffset.value = 0;
|
||||
const { start, end } = getActiveDateRange(range.value, currentDate.value);
|
||||
selectedStartDate.value = start;
|
||||
selectedEndDate.value = end;
|
||||
|
||||
// Position calendar to show the months of the selected range
|
||||
startCurrentDate.value = startOfMonth(start);
|
||||
endCurrentDate.value = isSameMonth(start, end)
|
||||
? startOfMonth(addMonths(start, 1))
|
||||
: startOfMonth(end);
|
||||
};
|
||||
|
||||
const navigateMonth = direction => {
|
||||
monthOffset.value += direction === 'prev' ? -1 : 1;
|
||||
if (monthOffset.value > 0) monthOffset.value = 0;
|
||||
|
||||
const { start, end } = getRangeAtOffset(
|
||||
selectedRange.value,
|
||||
monthOffset.value,
|
||||
currentDate.value
|
||||
);
|
||||
selectedStartDate.value = start;
|
||||
selectedEndDate.value = end;
|
||||
|
||||
startCurrentDate.value = startOfMonth(start);
|
||||
endCurrentDate.value = isSameMonth(start, end)
|
||||
? startOfMonth(addMonths(start, 1))
|
||||
: startOfMonth(end);
|
||||
|
||||
emit('dateRangeChanged', [start, end, selectedRange.value]);
|
||||
};
|
||||
|
||||
const moveCalendar = (calendar, direction, period = MONTH) => {
|
||||
@@ -134,12 +226,27 @@ const moveCalendar = (calendar, direction, period = MONTH) => {
|
||||
direction,
|
||||
period
|
||||
);
|
||||
startCurrentDate.value = start;
|
||||
endCurrentDate.value = end;
|
||||
|
||||
// Prevent calendar months from overlapping
|
||||
const monthDiff = differenceInCalendarMonths(end, start);
|
||||
if (monthDiff === 0) {
|
||||
// If they would be the same month, adjust the other calendar
|
||||
if (calendar === START_CALENDAR) {
|
||||
endCurrentDate.value = addMonths(start, 1);
|
||||
startCurrentDate.value = start;
|
||||
} else {
|
||||
startCurrentDate.value = subMonths(end, 1);
|
||||
endCurrentDate.value = end;
|
||||
}
|
||||
} else {
|
||||
startCurrentDate.value = start;
|
||||
endCurrentDate.value = end;
|
||||
}
|
||||
};
|
||||
|
||||
const selectDate = day => {
|
||||
selectedRange.value = CUSTOM_RANGE;
|
||||
monthOffset.value = 0;
|
||||
if (!selectingEndDate.value || day < selectedStartDate.value) {
|
||||
selectedStartDate.value = day;
|
||||
selectedEndDate.value = null;
|
||||
@@ -175,10 +282,10 @@ const openCalendar = (index, calendarType, period = MONTH) => {
|
||||
const updateManualInput = (newDate, calendarType) => {
|
||||
if (calendarType === START_CALENDAR) {
|
||||
selectedStartDate.value = newDate;
|
||||
startCurrentDate.value = newDate;
|
||||
startCurrentDate.value = startOfMonth(newDate);
|
||||
} else {
|
||||
selectedEndDate.value = newDate;
|
||||
endCurrentDate.value = newDate;
|
||||
endCurrentDate.value = startOfMonth(newDate);
|
||||
}
|
||||
selectingEndDate.value = false;
|
||||
};
|
||||
@@ -188,13 +295,22 @@ const handleManualInputError = message => {
|
||||
};
|
||||
|
||||
const resetDatePicker = () => {
|
||||
startCurrentDate.value = startOfDay(currentDate.value); // Resets to today at start of the day
|
||||
endCurrentDate.value = addMonths(startOfDay(currentDate.value), 1); // Resets to one month ahead
|
||||
selectedStartDate.value = startOfDay(subDays(currentDate.value, 6));
|
||||
selectedEndDate.value = endOfDay(currentDate.value);
|
||||
// Calculate Last 7 days from today
|
||||
const startDate = startOfDay(subDays(currentDate.value, 6));
|
||||
const endDate = endOfDay(currentDate.value);
|
||||
|
||||
selectedStartDate.value = startDate;
|
||||
selectedEndDate.value = endDate;
|
||||
|
||||
// Position calendar to show the months of Last 7 days
|
||||
// Example: If today is Feb 5, Last 7 days = Jan 30 - Feb 5, so show Jan + Feb
|
||||
startCurrentDate.value = startOfMonth(startDate);
|
||||
endCurrentDate.value = isSameMonth(startDate, endDate)
|
||||
? startOfMonth(addMonths(startDate, 1))
|
||||
: startOfMonth(endDate);
|
||||
selectingEndDate.value = false;
|
||||
selectedRange.value = LAST_7_DAYS;
|
||||
// Reset view modes if they are being used to toggle between different calendar views
|
||||
monthOffset.value = 0;
|
||||
calendarViews.value = { start: WEEK, end: WEEK };
|
||||
};
|
||||
|
||||
@@ -203,26 +319,58 @@ const emitDateRange = () => {
|
||||
useAlert('Please select a valid time range');
|
||||
} else {
|
||||
showDatePicker.value = false;
|
||||
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
|
||||
emit('dateRangeChanged', [
|
||||
selectedStartDate.value,
|
||||
selectedEndDate.value,
|
||||
selectedRange.value,
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
// Called when picker opens - positions calendar to show selected date range
|
||||
// Fixes issue where calendar showed wrong months when loaded from URL params
|
||||
const initializeCalendarMonths = () => {
|
||||
if (selectedStartDate.value && selectedEndDate.value) {
|
||||
startCurrentDate.value = startOfMonth(selectedStartDate.value);
|
||||
endCurrentDate.value = isSameMonth(
|
||||
selectedStartDate.value,
|
||||
selectedEndDate.value
|
||||
)
|
||||
? startOfMonth(addMonths(selectedEndDate.value, 1))
|
||||
: startOfMonth(selectedEndDate.value);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDatePicker = () => {
|
||||
showDatePicker.value = !showDatePicker.value;
|
||||
if (showDatePicker.value) initializeCalendarMonths();
|
||||
};
|
||||
|
||||
const closeDatePicker = () => {
|
||||
showDatePicker.value = false;
|
||||
if (isValid(selectedStartDate.value) && isValid(selectedEndDate.value)) {
|
||||
emitDateRange();
|
||||
} else {
|
||||
showDatePicker.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="closeDatePicker" class="relative font-inter">
|
||||
<div class="relative flex-shrink-0 font-inter">
|
||||
<DatePickerButton
|
||||
:selected-start-date="selectedStartDate"
|
||||
:selected-end-date="selectedEndDate"
|
||||
:selected-range="selectedRange"
|
||||
@open="showDatePicker = !showDatePicker"
|
||||
:show-month-navigation="showMonthNavigation"
|
||||
:can-navigate-next="canNavigateNext"
|
||||
:navigation-label="navigationLabel"
|
||||
@open="toggleDatePicker"
|
||||
@navigate-month="navigateMonth"
|
||||
/>
|
||||
<div
|
||||
v-if="showDatePicker"
|
||||
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
|
||||
v-on-clickaway="closeDatePicker"
|
||||
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
|
||||
>
|
||||
<CalendarDateRange
|
||||
:selected-range="selectedRange"
|
||||
|
||||
@@ -46,13 +46,13 @@ const onClickSetView = (type, mode) => {
|
||||
xs
|
||||
icon="i-lucide-chevron-left"
|
||||
class="rtl:rotate-180"
|
||||
@click="onClickPrev(calendarType)"
|
||||
@click.stop="onClickPrev(calendarType)"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="firstButtonLabel"
|
||||
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
|
||||
@click="onClickSetView(calendarType, viewMode)"
|
||||
@click.stop="onClickSetView(calendarType, viewMode)"
|
||||
>
|
||||
{{ firstButtonLabel }}
|
||||
</button>
|
||||
@@ -60,7 +60,7 @@ const onClickSetView = (type, mode) => {
|
||||
v-if="buttonLabel"
|
||||
class="p-0 text-sm font-medium text-center text-n-slate-12"
|
||||
:class="{ 'hover:text-n-brand': viewMode }"
|
||||
@click="onClickSetView(calendarType, YEAR)"
|
||||
@click.stop="onClickSetView(calendarType, YEAR)"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
@@ -71,7 +71,7 @@ const onClickSetView = (type, mode) => {
|
||||
xs
|
||||
icon="i-lucide-chevron-right"
|
||||
class="rtl:rotate-180"
|
||||
@click="onClickNext(calendarType)"
|
||||
@click.stop="onClickNext(calendarType)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+15
-14
@@ -18,24 +18,25 @@ const setDateRange = range => {
|
||||
<template>
|
||||
<div class="w-[200px] flex flex-col items-start">
|
||||
<h4
|
||||
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-n-slate-12"
|
||||
class="w-full px-5 py-4 text-xs font-bold capitalize text-start text-n-slate-10"
|
||||
>
|
||||
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
|
||||
</h4>
|
||||
<div class="flex flex-col items-start w-full">
|
||||
<button
|
||||
v-for="range in dateRanges"
|
||||
:key="range.label"
|
||||
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
|
||||
:class="
|
||||
range.value === selectedRange
|
||||
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
|
||||
: 'text-n-slate-12'
|
||||
"
|
||||
@click="setDateRange(range)"
|
||||
>
|
||||
{{ $t(range.label) }}
|
||||
</button>
|
||||
<template v-for="range in dateRanges" :key="range.label">
|
||||
<div v-if="range.separator" class="w-full border-t border-n-strong" />
|
||||
<button
|
||||
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
|
||||
:class="
|
||||
range.value === selectedRange
|
||||
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
|
||||
: 'text-n-slate-12'
|
||||
"
|
||||
@click="setDateRange(range)"
|
||||
>
|
||||
{{ $t(range.label) }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user