Merge branch 'develop' into feature/#7907-script-to-change-local-storage-to-aws

This commit is contained in:
Sony Mathew
2026-06-16 15:31:54 +05:30
committed by GitHub
91 changed files with 4762 additions and 6847 deletions
@@ -53,7 +53,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
def category_params
params.require(:category).permit(
:name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id
:name, :description, :position, :slug, :locale, :icon, :icon_color, :parent_category_id, :associated_category_id
)
end
@@ -214,3 +214,5 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
render json: error, status: error_status
end
end
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
+11 -1
View File
@@ -35,7 +35,17 @@ class Twilio::CallbackController < ApplicationController
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username
:Username,
:ReferralBody,
:ReferralHeadline,
:ReferralSourceId,
:ReferralSourceType,
:ReferralSourceUrl,
:ReferralMediaId,
:ReferralMediaContentType,
:ReferralMediaUrl,
:ReferralNumMedia,
:ReferralCtwaClid
)
end
end
+17 -5
View File
@@ -17,15 +17,11 @@ module PortalHelper
uri.to_s
end
def generate_portal_bg_color(portal_color, theme)
def generate_portal_bg(portal_color, theme)
base_color = theme == 'dark' ? 'black' : 'white'
"color-mix(in srgb, #{portal_color} 20%, #{base_color})"
end
def generate_portal_bg(portal_color, theme)
generate_portal_bg_color(portal_color, theme)
end
def generate_gradient_to_bottom(theme)
base_color = theme == 'dark' ? '#151718' : 'white'
"linear-gradient(to bottom, transparent, #{base_color})"
@@ -41,6 +37,10 @@ module PortalHelper
language_map[locale] || locale
end
def html_lang_attribute(locale)
locale.to_s.tr('_', '-')
end
def theme_query_string(theme)
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end
@@ -97,6 +97,18 @@ module PortalHelper
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
# Renders a stored category icon: a bare ri icon name (e.g. `vip-crown-2-fill/line`) saved color, or a plain emoji character.
def render_emoji_or_icon(value, color = nil)
return '' if value.blank?
# Emojis are non-ascii; bare icon names match this safe charset.
return ERB::Util.html_escape(value) unless value.match?(/\A[a-z][a-z0-9-]*\z/)
icon_class = value.start_with?('i-') ? value : "i-ri-#{value}"
style = "color: #{color};" if color.to_s.match?(/\A#\h{3,8}\z/)
tag.span(class: icon_class, style: style, 'aria-hidden': true)
end
def thumbnail_bg_color(username)
colors = ['#6D95BA', '#A4C3C3', '#E19191']
return colors.sample if username.blank?
@@ -26,6 +26,13 @@ const resetForm = () => {
form.description = '';
};
const open = (company = {}) => {
form.name = company.name || '';
form.domain = company.domain || '';
form.description = company.description || '';
dialogRef.value?.open();
};
const handleConfirm = () => {
if (isFormInvalid.value) return;
@@ -45,7 +52,7 @@ const onSuccess = () => {
closeDialog();
};
defineExpose({ dialogRef, onSuccess });
defineExpose({ dialogRef, onSuccess, open });
</script>
<template>
@@ -0,0 +1,153 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDebounceFn } from '@vueuse/core';
import CompanyAPI from 'dashboard/api/companies';
import { useAlert } from 'dashboard/composables';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CompanyCreateDialog from 'dashboard/components-next/Companies/CompanyCreateDialog.vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
// Name of the linked company, so the label shows before the list is loaded.
selectedName: {
type: String,
default: '',
},
isDetailsView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const CREATE_PREFIX = 'create:';
const options = ref([]);
const searchQuery = ref('');
const createDialogRef = ref(null);
const isCreatingCompany = ref(false);
const toOption = company => ({ label: company.name, value: company.id });
const createOption = computed(() => {
const name = searchQuery.value.trim();
if (!name) return null;
const exists = options.value.some(
option => option.label.toLowerCase() === name.toLowerCase()
);
if (exists) return null;
return {
label: t('COMPANIES.SELECTOR.CREATE_OPTION', { name }),
value: `${CREATE_PREFIX}${name}`,
};
});
const comboboxOptions = computed(() => {
const list = [...options.value];
// Keep the linked company visible even when it is not in the loaded results.
if (
props.modelValue &&
props.selectedName &&
!list.some(option => option.value === Number(props.modelValue))
) {
list.unshift({
label: props.selectedName,
value: Number(props.modelValue),
});
}
if (createOption.value) list.push(createOption.value);
return list;
});
const fetchCompanies = async query => {
try {
const {
data: { payload },
} = query
? await CompanyAPI.search(query)
: await CompanyAPI.get({ page: 1 });
options.value = (payload || []).map(toOption);
} catch {
options.value = [];
}
};
// Fetch lazily, only when the dropdown opens, instead of on mount.
const handleOpen = () => {
searchQuery.value = '';
fetchCompanies('');
};
const handleSearch = useDebounceFn(query => {
searchQuery.value = query?.trim() || '';
fetchCompanies(searchQuery.value);
}, 300);
// Open the create dialog (prefilled with the typed name) so the user can add
// domain/description before saving, instead of creating with just a name.
const createCompany = async company => {
isCreatingCompany.value = true;
try {
const {
data: { payload },
} = await CompanyAPI.create({ company });
createDialogRef.value?.onSuccess();
emit('select', { id: payload.id, name: payload.name });
useAlert(t('COMPANIES.CREATE.MESSAGES.SUCCESS'));
} catch {
useAlert(t('COMPANIES.CREATE.MESSAGES.ERROR'));
} finally {
isCreatingCompany.value = false;
}
};
const handleSelect = value => {
if (typeof value === 'string' && value.startsWith(CREATE_PREFIX)) {
createDialogRef.value?.open({ name: value.slice(CREATE_PREFIX.length) });
// Drop the transient "Add …" option so the button label doesn't stick to
// it if the dialog is dismissed without creating.
searchQuery.value = '';
return;
}
const id = value ? Number(value) : '';
const selected = comboboxOptions.value.find(option => option.value === id);
emit('select', { id, name: selected?.label || '' });
};
</script>
<template>
<ComboBox
:model-value="modelValue"
:options="comboboxOptions"
:display-label="selectedName"
:placeholder="t('COMPANIES.SELECTOR.PLACEHOLDER')"
:search-placeholder="t('COMPANIES.SEARCH_PLACEHOLDER')"
use-api-results
class="[&>div>button]:h-8 [&>div>div_ul]:max-h-56"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
}"
@open="handleOpen"
@search="handleSearch"
@update:model-value="handleSelect"
/>
<CompanyCreateDialog
ref="createDialogRef"
:is-loading="isCreatingCompany"
@create="createCompany"
/>
</template>
@@ -15,6 +15,7 @@ const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
email: { type: String, default: '' },
companyId: { type: [Number, String], default: '' },
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
@@ -41,6 +42,7 @@ const getInitialContactData = () => ({
id: props.id,
name: props.name,
email: props.email,
companyId: props.companyId,
phoneNumber: props.phoneNumber,
additionalAttributes: props.additionalAttributes,
});
@@ -5,8 +5,11 @@ import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CompanySelector from 'dashboard/components-next/Companies/CompanySelector.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
@@ -28,6 +31,7 @@ const props = defineProps({
const emit = defineEmits(['update']);
const { t } = useI18n();
const { currentAccount, isCloudFeatureEnabled } = useAccount();
const FORM_CONFIG = {
FIRST_NAME: { field: 'firstName' },
@@ -54,6 +58,7 @@ const defaultState = {
id: 0,
name: '',
email: '',
companyId: '',
firstName: '',
lastName: '',
phoneNumber: '',
@@ -85,6 +90,23 @@ const validationRules = {
const v$ = useVuelidate(validationRules, state);
const isFormInvalid = computed(() => v$.value.$invalid);
const hasCompaniesFeature = computed(
() =>
currentAccount.value?.id && isCloudFeatureEnabled(FEATURE_FLAGS.COMPANIES)
);
const showCompanySelector = computed(
() =>
hasCompaniesFeature.value &&
(Boolean(state.companyId) || !state.additionalAttributes.companyName)
);
const emitContactUpdate = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
};
const prepareStateBasedOnProps = () => {
if (props.isNewContact) {
@@ -96,6 +118,7 @@ const prepareStateBasedOnProps = () => {
name = '',
email: emailAddress,
phoneNumber,
companyId = '',
additionalAttributes = {},
} = props.contactData || {};
const { firstName, lastName } = splitName(name || '');
@@ -115,6 +138,7 @@ const prepareStateBasedOnProps = () => {
Object.assign(state, {
id,
name,
companyId: companyId || '',
firstName,
lastName,
email: emailAddress,
@@ -200,11 +224,7 @@ const getFormBinding = key => {
}
}
const isFormValid = await v$.value.$validate();
if (isFormValid) {
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
}
await emitContactUpdate();
},
});
};
@@ -221,6 +241,12 @@ const handleCountrySelection = value => {
emit('update', state);
};
const handleCompanySelection = async ({ id, name }) => {
state.companyId = id || '';
state.additionalAttributes.companyName = name || '';
await emitContactUpdate();
};
const resetValidation = () => {
v$.value.$reset();
};
@@ -273,6 +299,13 @@ defineExpose({
:placeholder="item.placeholder"
:show-border="isDetailsView"
/>
<CompanySelector
v-else-if="item.key === 'COMPANY_NAME' && showCompanySelector"
:model-value="state.companyId"
:selected-name="state.additionalAttributes.companyName"
:is-details-view="isDetailsView"
@select="handleCompanySelection"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
@@ -92,6 +92,7 @@ const handleAvatarHover = (id, isHovered) => {
:id="contact.id"
:name="contact.name"
:email="contact.email"
:company-id="contact.companyId"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
@@ -13,6 +13,7 @@ import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -131,7 +132,7 @@ const statusText = computed(() => {
const categoryName = computed(() => {
if (props.category?.slug) {
return `${props.category.icon} ${props.category.name}`;
return props.category.name;
}
return t(
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.CATEGORY.UNCATEGORISED'
@@ -222,7 +223,15 @@ const handleClick = id => {
{{ authorName || '-' }}
</span>
</div>
<span class="block text-sm whitespace-nowrap text-n-slate-11">
<span
class="flex items-center gap-1 text-sm whitespace-nowrap text-n-slate-11"
>
<EmojiIcon
v-if="category?.icon"
:value="category.icon"
:color="category.icon_color"
class="flex-shrink-0 size-4"
/>
{{ categoryName }}
</span>
<div
@@ -6,6 +6,7 @@ import { useToggle } from '@vueuse/core';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const props = defineProps({
id: {
@@ -20,6 +21,10 @@ const props = defineProps({
type: String,
required: true,
},
iconColor: {
type: String,
default: '',
},
description: {
type: String,
required: true,
@@ -55,10 +60,6 @@ const categoryMenuItems = [
},
];
const categoryTitleWithIcon = computed(() => {
return `${props.icon} ${props.title}`;
});
const description = computed(() => {
return props.description ? props.description : 'No description added';
});
@@ -83,10 +84,16 @@ const handleAction = ({ action, value }) => {
<div class="flex justify-between w-full gap-2">
<div class="flex items-center justify-start w-full min-w-0 gap-2">
<span
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
class="flex items-center gap-1.5 text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
@click="handleClick(slug)"
>
{{ categoryTitleWithIcon }}
<EmojiIcon
v-if="icon"
:value="icon"
:color="iconColor"
class="shrink-0 size-4"
/>
<span class="truncate">{{ title }}</span>
</span>
<span
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
@@ -8,6 +8,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
const props = defineProps({
@@ -107,10 +108,11 @@ const selectedCategory = computed(() => {
const categoryList = computed(() => {
return (
categories.value
.map(({ name, id, icon }) => ({
.map(({ name, id, icon, icon_color: iconColor }) => ({
label: name,
value: id,
emoji: icon,
iconColor,
isSelected: isNewArticle.value
? id === (selectedCategoryId.value || selectedCategory.value?.id)
: id === props.article?.category?.id,
@@ -202,10 +204,6 @@ onMounted(() => {
<div class="relative">
<OnClickOutside @trigger="openCategoryList = false">
<Button
:label="
selectedCategory?.name ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
"
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
variant="ghost"
color="slate"
@@ -213,12 +211,20 @@ onMounted(() => {
@click="openCategoryList = !openCategoryList"
>
<span
v-if="selectedCategory"
class="text-sm text-n-slate-12 hover:text-n-slate-11"
class="flex items-center gap-1.5 min-w-0 text-sm text-n-slate-12 hover:text-n-slate-11"
>
{{
`${selectedCategory.icon || ''} ${selectedCategory.name || t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')}`
}}
<EmojiIcon
v-if="selectedCategory?.icon"
:value="selectedCategory.icon"
:color="selectedCategory.icon_color"
class="flex-shrink-0 size-4"
/>
<span class="truncate">
{{
selectedCategory?.name ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
}}
</span>
</span>
</Button>
<DropdownMenu
@@ -13,6 +13,7 @@ import {
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const props = defineProps({
categories: {
@@ -63,18 +64,15 @@ const activeTabIndex = computed(() => {
return tabs.value.findIndex(tab => tab.value === tabParam);
});
const activeCategoryName = computed(() => {
const activeCategory = props.categories.find(
category => category.slug === route.params.categorySlug
);
const activeCategory = computed(() =>
props.categories.find(category => category.slug === route.params.categorySlug)
);
if (activeCategory) {
const { icon, name } = activeCategory;
return `${icon} ${name}`;
}
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL');
});
const activeCategoryName = computed(
() =>
activeCategory.value?.name ||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL')
);
const activeLocaleName = computed(() => {
return props.allowedLocales.find(
@@ -94,6 +92,7 @@ const categoryMenuItems = computed(() => {
value: category.slug,
action: 'filter',
emoji: category.icon,
iconColor: category.icon_color,
}));
const hasCategorySlug = !!route.params.categorySlug;
@@ -167,14 +166,23 @@ const handleTabChange = value => {
<div v-if="hasCategoryMenuItems" class="relative group">
<OnClickOutside @trigger="isCategoryMenuOpen = false">
<Button
:label="activeCategoryName"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
/>
>
<span class="flex items-center gap-1.5 min-w-0">
<EmojiIcon
v-if="activeCategory?.icon"
:value="activeCategory.icon"
:color="activeCategory.icon_color"
class="flex-shrink-0 size-4"
/>
<span class="truncate">{{ activeCategoryName }}</span>
</span>
</Button>
<DropdownMenu
v-if="isCategoryMenuOpen"
@@ -230,6 +230,7 @@ const categoryMenuItems = computed(() =>
value: category.id,
action: 'move',
emoji: category.icon,
iconColor: category.icon_color,
}))
);
@@ -38,8 +38,8 @@ const { t } = useI18n();
const route = useRoute();
const handleCategory = async formData => {
const { id, name, slug, icon, description, locale } = formData;
const categoryData = { name, icon, slug, description };
const { id, name, slug, icon, iconColor, description, locale } = formData;
const categoryData = { name, icon, icon_color: iconColor, slug, description };
if (props.mode === 'create') {
categoryData.locale = locale;
@@ -18,6 +18,7 @@ import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const props = defineProps({
mode: {
@@ -49,8 +50,9 @@ const props = defineProps({
const emit = defineEmits(['submit', 'cancel']);
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
);
const { t } = useI18n();
@@ -72,6 +74,7 @@ const state = reactive({
id: '',
name: '',
icon: '',
iconColor: '',
slug: '',
description: '',
locale: '',
@@ -109,8 +112,19 @@ const slugHelpText = computed(() => {
});
});
const onClickInsertEmoji = emoji => {
state.icon = emoji;
const onSelectIcon = ({ type, value, color }) => {
state.icon = value;
state.iconColor = type === 'icon' ? color : '';
isEmojiPickerOpen.value = false;
};
const onColorChange = color => {
state.iconColor = color;
};
const onRemoveIcon = () => {
state.icon = '';
state.iconColor = '';
isEmojiPickerOpen.value = false;
};
@@ -139,7 +153,14 @@ watch(
newCategory => {
if (props.mode === 'edit' && newCategory) {
const { id, name, icon, slug, description } = newCategory;
Object.assign(state, { id, name, icon, slug, description });
Object.assign(state, {
id,
name,
icon,
iconColor: newCategory.icon_color || '',
slug,
description,
});
}
},
{ immediate: true }
@@ -197,19 +218,29 @@ defineExpose({ state, isSubmitDisabled });
<template #prefix>
<OnClickOutside @trigger="isEmojiPickerOpen = false">
<Button
:label="state.icon"
color="slate"
size="sm"
type="button"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[2.38rem] !w-[2.375rem] absolute top-[2rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
variant="ghost"
color="slate"
class="!h-[2.38rem] !w-[2.375rem] !p-0 absolute top-[2rem] start-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
>
<EmojiIcon
v-if="state.icon"
:value="state.icon"
:color="state.iconColor"
class="size-5 text-xl !leading-5"
/>
<span v-else class="i-lucide-smile-plus size-4" />
</Button>
<EmojiIconPicker
v-if="isEmojiPickerOpen"
class="left-0 top-16"
:value="state.icon"
:color="state.iconColor"
show-remove-button
:on-click="onClickInsertEmoji"
@select="onSelectIcon"
@color-change="onColorChange"
@remove="onRemoveIcon"
/>
</OnClickOutside>
</template>
@@ -106,6 +106,7 @@ const breadcrumbItems = computed(() => {
}
),
emoji: selectedCategoryEmoji.value,
iconColor: selectedCategory.value?.icon_color,
});
}
return items;
@@ -68,6 +68,7 @@ watch(
:id="element.id"
:title="element.name"
:icon="element.icon"
:icon-color="element.icon_color"
:description="element.description"
:articles-count="element.meta?.articles_count || 0"
:slug="element.slug"
@@ -52,8 +52,8 @@ const activeLocaleCode = computed(() => activeLocale.value?.code ?? '');
const onUpdateCategory = async () => {
if (!categoryFormRef.value) return;
const { state } = categoryFormRef.value;
const { id, name, slug, icon, description } = state;
const categoryData = { name, icon, slug, description };
const { id, name, slug, icon, iconColor, description } = state;
const categoryData = { name, icon, icon_color: iconColor, slug, description };
categoryData.id = id;
try {
@@ -53,8 +53,9 @@ const generateUid = () => {
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
);
const {
@@ -215,10 +216,11 @@ useEventListener(document, 'paste', onPaste);
class="!w-10"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
<EmojiIconPicker
v-if="isEmojiPickerOpen"
mode="emoji"
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
:on-click="onClickInsertEmoji"
@select="onClickInsertEmoji($event.value)"
/>
</div>
<FileUpload
@@ -2,6 +2,7 @@
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
defineProps({
items: {
@@ -47,8 +48,17 @@ const onClick = (item, index) => {
</button>
<!-- The last breadcrumb item is plain text -->
<span v-else class="text-sm truncate text-n-slate-12 min-w-0 block">
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
<span
v-else
class="inline-flex items-center gap-1 text-sm truncate min-w-0"
>
<EmojiIcon
v-if="item.emoji"
:value="item.emoji"
:color="item.iconColor"
class="flex-shrink-0 size-4"
/>
<span class="truncate text-n-slate-12">{{ item.label }}</span>
</span>
</li>
</ol>
@@ -14,6 +14,9 @@ const props = defineProps({
value.every(option => 'value' in option && 'label' in option),
},
placeholder: { type: String, default: '' },
// Fallback label shown when the selected value is not in `options` yet
// (e.g. API-backed lists that load lazily on open).
displayLabel: { type: String, default: '' },
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
searchPlaceholder: { type: String, default: '' },
@@ -23,7 +26,7 @@ const props = defineProps({
useApiResults: { type: Boolean, default: false }, // useApiResults prop to determine if search is handled by API
});
const emit = defineEmits(['update:modelValue', 'search']);
const emit = defineEmits(['update:modelValue', 'search', 'open']);
const { t } = useI18n();
@@ -52,7 +55,7 @@ const selectedLabel = computed(() => {
const selected = props.options.find(
option => option.value === selectedValue.value
);
return selected?.label ?? selectPlaceholder.value;
return selected?.label ?? (props.displayLabel || selectPlaceholder.value);
});
const selectOption = option => {
@@ -72,6 +75,7 @@ const toggleDropdown = () => {
open.value = !open.value;
if (open.value) {
search.value = '';
emit('open');
nextTick(() => dropdownRef.value?.focus());
}
};
@@ -87,7 +87,7 @@ defineExpose({
}"
role="option"
:aria-selected="isSelected(option)"
@click="emit('select', option)"
@click.stop="emit('select', option)"
>
<span
:class="{
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
@@ -162,7 +163,7 @@ onMounted(() => {
>
<p
v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm truncate min-w-0"
>
{{ section.title }}
</p>
@@ -207,9 +208,12 @@ onMounted(() => {
class="flex-shrink-0 size-3.5"
/>
</slot>
<span v-if="item.emoji" class="flex-shrink-0">{{
item.emoji
}}</span>
<EmojiIcon
v-if="item.emoji"
:value="item.emoji"
:color="item.iconColor"
class="flex-shrink-0 size-4"
/>
<slot name="label" :item="item">
<span
v-if="item.label"
@@ -260,7 +264,12 @@ onMounted(() => {
class="flex-shrink-0 size-3.5"
/>
</slot>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<EmojiIcon
v-if="item.emoji"
:value="item.emoji"
:color="item.iconColor"
class="flex-shrink-0 size-4"
/>
<slot name="label" :item="item">
<span
v-if="item.label"
@@ -0,0 +1,27 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ICON_COLORS } from './constants';
const { t } = useI18n();
const selectedColor = defineModel({ type: String, default: '' });
</script>
<template>
<div class="flex items-center justify-between gap-1 px-0.5 mx-2 py-1 mb-px">
<button
v-for="color in ICON_COLORS"
:key="color.value"
v-tooltip.top="t(`EMOJI_ICON_PICKER.COLORS.${color.name}`)"
type="button"
class="flex items-center justify-center flex-shrink-0 transition-all rounded-full size-5 ring-offset-2 ring-offset-n-surface-2 hover:ring-2"
:class="{ 'ring-2': selectedColor === color.value }"
:style="{ backgroundColor: color.value, '--tw-ring-color': color.value }"
@click="selectedColor = color.value"
>
<span
v-if="selectedColor === color.value"
class="text-white i-lucide-check size-3"
/>
</button>
</div>
</template>
@@ -0,0 +1,35 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { isIconValue, iconClassFor } from './constants';
// Renders a saved picker value: a color-tinted icon or an emoji.
const props = defineProps({
value: {
type: String,
default: '',
},
color: {
type: String,
default: '',
},
});
const isIcon = computed(() => isIconValue(props.value));
const iconClass = computed(() => iconClassFor(props.value));
const iconStyle = computed(() =>
props.color ? { color: props.color } : undefined
);
</script>
<template>
<span class="inline-flex items-center justify-center leading-none">
<Icon
v-if="isIcon"
:icon="iconClass"
class="size-full"
:style="iconStyle"
/>
<span v-else v-dompurify-html="value" />
</span>
</template>
@@ -0,0 +1,310 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { Virtualizer } from 'virtua/vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ColorPalette from './ColorPalette.vue';
import { CURATED_ICONS } from './icons';
import {
DEFAULT_ICON_COLOR,
ICON_STYLE,
PICKER_MODE,
PICKER_TAB,
isIconValue,
} from './constants';
import {
buildEmojiSections,
getEmojiTint,
getRecentEmojis,
addRecentEmoji,
} from 'shared/components/emoji/pickerHelper';
const props = defineProps({
// 'both' shows Icons + Emojis tabs; 'emoji' shows only the emoji panel.
mode: {
type: String,
default: PICKER_MODE.BOTH,
validator: value => Object.values(PICKER_MODE).includes(value),
},
// Saved value: an emoji char or an icon name (e.g. "rocket-line").
value: {
type: String,
default: '',
},
// Current stored icon color (hex), used to preselect a swatch.
color: {
type: String,
default: '',
},
showRemoveButton: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'remove', 'colorChange']);
const { t } = useI18n();
const showTabs = computed(() => props.mode === PICKER_MODE.BOTH);
const showHeader = computed(
() => showTabs.value || (props.showRemoveButton && props.value)
);
const activeTab = ref(
props.value && !isIconValue(props.value)
? PICKER_TAB.EMOJIS
: PICKER_TAB.ICONS
);
const tabs = computed(() => [
{
id: PICKER_TAB.ICONS,
label: t('EMOJI_ICON_PICKER.TABS.ICONS'),
icon: 'i-lucide-shapes',
},
{
id: PICKER_TAB.EMOJIS,
label: t('EMOJI_ICON_PICKER.TABS.EMOJIS'),
icon: 'i-lucide-smile',
},
]);
// Icons
const iconSearch = ref('');
const selectedColor = ref(props.color || DEFAULT_ICON_COLOR);
// Preselect the outline/filled toggle from the saved icon's style suffix.
const iconStyle = ref(
props.value.endsWith(`-${ICON_STYLE.FILL}`)
? ICON_STYLE.FILL
: ICON_STYLE.LINE
);
const filteredIcons = computed(() => {
const term = iconSearch.value.trim().toLowerCase();
if (!term) return CURATED_ICONS;
return CURATED_ICONS.filter(
icon => icon.name.includes(term) || icon.keywords.includes(term)
);
});
const toggleIconStyle = () => {
iconStyle.value =
iconStyle.value === ICON_STYLE.LINE ? ICON_STYLE.FILL : ICON_STYLE.LINE;
};
// Each icon button tints its hover with the selected color (low opacity).
const iconHoverStyle = computed(() => ({
'--ep-tint': `${selectedColor.value}24`,
}));
const selectIcon = icon => {
// Store the name with its style suffix, e.g. "rocket-fill".
const value = `${icon.name}-${iconStyle.value}`;
emit('select', {
type: 'icon',
value,
icon: value,
color: selectedColor.value,
});
};
watch(selectedColor, color => {
if (isIconValue(props.value)) emit('colorChange', color);
});
// Emojis
const emojiSearch = ref('');
const recentEmojis = ref([]);
onMounted(() => {
recentEmojis.value = getRecentEmojis();
});
const emojiSections = computed(() =>
buildEmojiSections(
emojiSearch.value,
recentEmojis.value,
t('EMOJI_ICON_PICKER.FREQUENTLY_USED')
)
);
// Tints an emoji button with a light shade of the emoji's own color on hover.
const applyEmojiTint = (event, emoji) => {
event.currentTarget.style.setProperty('--ep-tint', getEmojiTint(emoji));
};
const selectEmoji = emoji => {
recentEmojis.value = addRecentEmoji(emoji);
emit('select', { type: 'emoji', value: emoji.emoji, emoji: emoji.emoji });
};
</script>
<template>
<div
role="dialog"
class="absolute z-20 flex flex-col overflow-hidden shadow-xl w-[22rem] bg-n-surface-2 backdrop-blur-[100px] rounded-2xl outline outline-1 outline-n-weak dark:outline-n-strong/50"
>
<div v-if="showHeader" class="flex items-center justify-between gap-2 p-2">
<div v-if="showTabs" class="flex gap-0.5 p-0.5 rounded-lg bg-n-alpha-1">
<button
v-for="tab in tabs"
:key="tab.id"
v-tooltip.top="tab.label"
type="button"
class="flex items-center justify-center !p-0 transition-all rounded-md size-7 active:scale-[0.92]"
:class="
activeTab === tab.id
? 'bg-n-surface-1 shadow-sm text-n-slate-12'
: 'text-n-slate-10 hover:text-n-slate-12'
"
@click="activeTab = tab.id"
>
<span :class="tab.icon" class="size-4" />
</button>
</div>
<Button
v-if="showRemoveButton && value"
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
variant="ghost"
color="slate"
size="sm"
icon="i-lucide-eraser"
class="flex-shrink-0"
@click="emit('remove')"
/>
</div>
<!-- Icons panel -->
<div
v-if="showTabs && activeTab === PICKER_TAB.ICONS"
class="flex flex-col gap-1.5"
:style="iconHoverStyle"
>
<ColorPalette v-model="selectedColor" />
<div class="flex items-center gap-1 px-2">
<Input
v-model="iconSearch"
size="md"
class="flex-1"
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_ICON')"
custom-input-class="!ps-9 !bg-transparent"
autofocus
>
<template #prefix>
<span
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-3"
/>
</template>
</Input>
<button
v-tooltip.top="
iconStyle === ICON_STYLE.FILL
? t('EMOJI_ICON_PICKER.STYLE.FILLED')
: t('EMOJI_ICON_PICKER.STYLE.OUTLINE')
"
type="button"
class="flex items-center justify-center flex-shrink-0 transition-all rounded-lg !p-2 size-9 text-n-slate-11 hover:bg-[var(--ep-tint)] active:scale-[0.92]"
@click="toggleIconStyle"
>
<span
:class="
iconStyle === ICON_STYLE.FILL
? 'i-ri-contrast-2-fill'
: 'i-ri-contrast-2-line'
"
class="size-[1.2rem]"
:style="{ color: selectedColor }"
/>
</button>
</div>
<div
v-if="filteredIcons.length"
class="grid grid-cols-10 h-52 gap-0.5 overflow-y-auto no-scrollbar content-start px-2 pb-2"
>
<button
v-for="icon in filteredIcons"
:key="icon.name"
type="button"
:title="icon.name"
class="flex items-center justify-center !p-0 size-8 transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
@click="selectIcon(icon)"
>
<span
:class="icon[iconStyle]"
class="size-[1.125rem]"
:style="{ color: selectedColor }"
/>
</button>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-2 h-52 text-n-slate-10"
>
<span class="i-lucide-search-x size-7" />
<span class="text-sm font-medium">
{{ t('EMOJI_ICON_PICKER.NO_ICON') }}
</span>
</div>
</div>
<!-- Emojis panel -->
<div
v-if="!showTabs || activeTab === PICKER_TAB.EMOJIS"
class="flex flex-col gap-1.5"
:class="{ 'pt-2': !showHeader }"
>
<div class="px-2">
<Input
v-model="emojiSearch"
size="md"
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_EMOJI')"
custom-input-class="!ps-9 !bg-transparent"
autofocus
>
<template #prefix>
<span
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-3"
/>
</template>
</Input>
</div>
<div
v-if="emojiSections.length"
class="h-60 overflow-y-auto px-2 no-scrollbar pb-2"
>
<Virtualizer v-slot="{ item: section }" :data="emojiSections">
<h5
class="px-1 pt-2 pb-1 m-0 text-xs font-medium tracking-wide uppercase text-n-slate-10"
>
{{ section.name }}
</h5>
<div class="grid grid-cols-10 gap-0.5">
<button
v-for="emoji in section.emojis"
:key="`${section.name}-${emoji.slug}`"
type="button"
:title="emoji.name"
class="flex items-center justify-center !p-0 size-8 w-full max-w-[2rem] text-xl transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
@mouseenter="applyEmojiTint($event, emoji.emoji)"
@click="selectEmoji(emoji)"
>
{{ emoji.emoji }}
</button>
</div>
</Virtualizer>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-2 h-60 text-n-slate-10"
>
<span class="i-lucide-smile-plus size-7" />
<span class="text-sm font-medium">
{{ t('EMOJI_ICON_PICKER.NO_EMOJI') }}
</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,40 @@
// Prefix that turns a stored icon name (e.g. "rocket-line") into a class.
// Swapping icon libraries means changing this and the curated set only.
export const ICON_PREFIX = 'i-ri-';
export const ICON_STYLE = {
LINE: 'line',
FILL: 'fill',
};
// Icon values are ascii names (e.g. "rocket-line"); emoji are non-ascii.
export const isIconValue = value =>
typeof value === 'string' && /^[a-z][a-z0-9-]*$/.test(value);
export const iconClassFor = value =>
value.startsWith(ICON_PREFIX) ? value : `${ICON_PREFIX}${value}`;
export const ICON_COLORS = [
{ name: 'SLATE', value: '#64748B' },
{ name: 'RED', value: '#EF4444' },
{ name: 'ORANGE', value: '#F97316' },
{ name: 'AMBER', value: '#F59E0B' },
{ name: 'GREEN', value: '#22C55E' },
{ name: 'TEAL', value: '#14B8A6' },
{ name: 'BLUE', value: '#3B82F6' },
{ name: 'INDIGO', value: '#6366F1' },
{ name: 'VIOLET', value: '#8B5CF6' },
{ name: 'PINK', value: '#EC4899' },
];
export const DEFAULT_ICON_COLOR = '#3B82F6';
export const PICKER_MODE = {
BOTH: 'both',
EMOJI: 'emoji',
};
export const PICKER_TAB = {
ICONS: 'icons',
EMOJIS: 'emojis',
};
@@ -0,0 +1,983 @@
// Curated Remix (ri) icons, each with a `line` and `fill` class. The class
// literals are kept here so Tailwind generates them. { name, keywords, line, fill }
export const CURATED_ICONS = [
{
name: 'home',
keywords: 'home house main',
line: 'i-ri-home-line',
fill: 'i-ri-home-fill',
},
{
name: 'star',
keywords: 'favorite star',
line: 'i-ri-star-line',
fill: 'i-ri-star-fill',
},
{
name: 'heart',
keywords: 'like love',
line: 'i-ri-heart-line',
fill: 'i-ri-heart-fill',
},
{
name: 'bookmark',
keywords: 'save',
line: 'i-ri-bookmark-line',
fill: 'i-ri-bookmark-fill',
},
{
name: 'flag-2',
keywords: 'flag report',
line: 'i-ri-flag-2-line',
fill: 'i-ri-flag-2-fill',
},
{
name: 'price-tag-3',
keywords: 'tag label',
line: 'i-ri-price-tag-3-line',
fill: 'i-ri-price-tag-3-fill',
},
{
name: 'map-pin',
keywords: 'location place',
line: 'i-ri-map-pin-line',
fill: 'i-ri-map-pin-fill',
},
{
name: 'sparkling-2',
keywords: 'magic ai shine sparkle',
line: 'i-ri-sparkling-2-line',
fill: 'i-ri-sparkling-2-fill',
},
{
name: 'fire',
keywords: 'flame hot trending',
line: 'i-ri-fire-line',
fill: 'i-ri-fire-fill',
},
{
name: 'lightbulb',
keywords: 'idea tip',
line: 'i-ri-lightbulb-line',
fill: 'i-ri-lightbulb-fill',
},
{
name: 'rocket',
keywords: 'launch startup ship',
line: 'i-ri-rocket-line',
fill: 'i-ri-rocket-fill',
},
{
name: 'focus-3',
keywords: 'target goal aim',
line: 'i-ri-focus-3-line',
fill: 'i-ri-focus-3-fill',
},
{
name: 'award',
keywords: 'prize badge medal',
line: 'i-ri-award-line',
fill: 'i-ri-award-fill',
},
{
name: 'trophy',
keywords: 'win achievement',
line: 'i-ri-trophy-line',
fill: 'i-ri-trophy-fill',
},
{
name: 'vip-crown-2',
keywords: 'crown premium vip',
line: 'i-ri-vip-crown-2-line',
fill: 'i-ri-vip-crown-2-fill',
},
{
name: 'vip-diamond',
keywords: 'gem diamond premium',
line: 'i-ri-vip-diamond-line',
fill: 'i-ri-vip-diamond-fill',
},
{
name: 'gift',
keywords: 'present reward',
line: 'i-ri-gift-line',
fill: 'i-ri-gift-fill',
},
{
name: 'notification-3',
keywords: 'bell alert',
line: 'i-ri-notification-3-line',
fill: 'i-ri-notification-3-fill',
},
{
name: 'global',
keywords: 'globe world web',
line: 'i-ri-global-line',
fill: 'i-ri-global-fill',
},
{
name: 'compass-3',
keywords: 'explore discover',
line: 'i-ri-compass-3-line',
fill: 'i-ri-compass-3-fill',
},
{
name: 'puzzle-2',
keywords: 'extension integration',
line: 'i-ri-puzzle-2-line',
fill: 'i-ri-puzzle-2-fill',
},
{
name: 'ticket-2',
keywords: 'support coupon',
line: 'i-ri-ticket-2-line',
fill: 'i-ri-ticket-2-fill',
},
{
name: 'megaphone',
keywords: 'announce broadcast marketing',
line: 'i-ri-megaphone-line',
fill: 'i-ri-megaphone-fill',
},
{
name: 'briefcase-4',
keywords: 'work job business',
line: 'i-ri-briefcase-4-line',
fill: 'i-ri-briefcase-4-fill',
},
{
name: 'building-2',
keywords: 'company office org',
line: 'i-ri-building-2-line',
fill: 'i-ri-building-2-fill',
},
{
name: 'store-2',
keywords: 'shop commerce',
line: 'i-ri-store-2-line',
fill: 'i-ri-store-2-fill',
},
{
name: 'community',
keywords: 'group team',
line: 'i-ri-community-line',
fill: 'i-ri-community-fill',
},
{
name: 'folder',
keywords: 'directory',
line: 'i-ri-folder-line',
fill: 'i-ri-folder-fill',
},
{
name: 'folder-open',
keywords: 'directory open',
line: 'i-ri-folder-open-line',
fill: 'i-ri-folder-open-fill',
},
{
name: 'file',
keywords: 'document',
line: 'i-ri-file-line',
fill: 'i-ri-file-fill',
},
{
name: 'file-text',
keywords: 'document doc',
line: 'i-ri-file-text-line',
fill: 'i-ri-file-text-fill',
},
{
name: 'file-copy',
keywords: 'documents files',
line: 'i-ri-file-copy-line',
fill: 'i-ri-file-copy-fill',
},
{
name: 'clipboard',
keywords: 'copy tasks',
line: 'i-ri-clipboard-line',
fill: 'i-ri-clipboard-fill',
},
{
name: 'book-2',
keywords: 'read guide book',
line: 'i-ri-book-2-line',
fill: 'i-ri-book-2-fill',
},
{
name: 'book-open',
keywords: 'read guide docs',
line: 'i-ri-book-open-line',
fill: 'i-ri-book-open-fill',
},
{
name: 'graduation-cap',
keywords: 'learn course education',
line: 'i-ri-graduation-cap-line',
fill: 'i-ri-graduation-cap-fill',
},
{
name: 'newspaper',
keywords: 'news article blog',
line: 'i-ri-newspaper-line',
fill: 'i-ri-newspaper-fill',
},
{
name: 'sticky-note',
keywords: 'note memo',
line: 'i-ri-sticky-note-line',
fill: 'i-ri-sticky-note-fill',
},
{
name: 'pencil',
keywords: 'write edit',
line: 'i-ri-pencil-line',
fill: 'i-ri-pencil-fill',
},
{
name: 'quill-pen',
keywords: 'write author',
line: 'i-ri-quill-pen-line',
fill: 'i-ri-quill-pen-fill',
},
{
name: 'mark-pen',
keywords: 'highlight',
line: 'i-ri-mark-pen-line',
fill: 'i-ri-mark-pen-fill',
},
{
name: 'calendar-2',
keywords: 'date schedule calendar',
line: 'i-ri-calendar-2-line',
fill: 'i-ri-calendar-2-fill',
},
{
name: 'calendar-event',
keywords: 'schedule event',
line: 'i-ri-calendar-event-line',
fill: 'i-ri-calendar-event-fill',
},
{
name: 'time',
keywords: 'clock time',
line: 'i-ri-time-line',
fill: 'i-ri-time-fill',
},
{
name: 'timer',
keywords: 'countdown',
line: 'i-ri-timer-line',
fill: 'i-ri-timer-fill',
},
{
name: 'history',
keywords: 'recent past',
line: 'i-ri-history-line',
fill: 'i-ri-history-fill',
},
{
name: 'hourglass',
keywords: 'wait time',
line: 'i-ri-hourglass-line',
fill: 'i-ri-hourglass-fill',
},
{
name: 'shopping-cart-2',
keywords: 'buy ecommerce cart',
line: 'i-ri-shopping-cart-2-line',
fill: 'i-ri-shopping-cart-2-fill',
},
{
name: 'shopping-bag-3',
keywords: 'buy purchase bag',
line: 'i-ri-shopping-bag-3-line',
fill: 'i-ri-shopping-bag-3-fill',
},
{
name: 'box-3',
keywords: 'package box shipping',
line: 'i-ri-box-3-line',
fill: 'i-ri-box-3-fill',
},
{
name: 'archive',
keywords: 'store box archive',
line: 'i-ri-archive-line',
fill: 'i-ri-archive-fill',
},
{
name: 'bank-card',
keywords: 'pay billing card credit',
line: 'i-ri-bank-card-line',
fill: 'i-ri-bank-card-fill',
},
{
name: 'wallet-3',
keywords: 'money pay wallet',
line: 'i-ri-wallet-3-line',
fill: 'i-ri-wallet-3-fill',
},
{
name: 'money-dollar-circle',
keywords: 'money price usd',
line: 'i-ri-money-dollar-circle-line',
fill: 'i-ri-money-dollar-circle-fill',
},
{
name: 'copper-coin',
keywords: 'coins money',
line: 'i-ri-copper-coin-line',
fill: 'i-ri-copper-coin-fill',
},
{
name: 'currency',
keywords: 'money exchange',
line: 'i-ri-currency-line',
fill: 'i-ri-currency-fill',
},
{
name: 'receipt',
keywords: 'invoice bill',
line: 'i-ri-receipt-line',
fill: 'i-ri-receipt-fill',
},
{
name: 'percent',
keywords: 'discount sale',
line: 'i-ri-percent-line',
fill: 'i-ri-percent-fill',
},
{
name: 'calculator',
keywords: 'math',
line: 'i-ri-calculator-line',
fill: 'i-ri-calculator-fill',
},
{
name: 'mail',
keywords: 'email message',
line: 'i-ri-mail-line',
fill: 'i-ri-mail-fill',
},
{
name: 'send-plane',
keywords: 'send submit',
line: 'i-ri-send-plane-line',
fill: 'i-ri-send-plane-fill',
},
{
name: 'chat-3',
keywords: 'chat talk message',
line: 'i-ri-chat-3-line',
fill: 'i-ri-chat-3-fill',
},
{
name: 'message-2',
keywords: 'chat comment',
line: 'i-ri-message-2-line',
fill: 'i-ri-message-2-fill',
},
{
name: 'question-answer',
keywords: 'chat conversation faq',
line: 'i-ri-question-answer-line',
fill: 'i-ri-question-answer-fill',
},
{
name: 'phone',
keywords: 'call',
line: 'i-ri-phone-line',
fill: 'i-ri-phone-fill',
},
{
name: 'vidicon',
keywords: 'video call meeting',
line: 'i-ri-vidicon-line',
fill: 'i-ri-vidicon-fill',
},
{
name: 'mic',
keywords: 'record voice audio',
line: 'i-ri-mic-line',
fill: 'i-ri-mic-fill',
},
{
name: 'group',
keywords: 'team people users',
line: 'i-ri-group-line',
fill: 'i-ri-group-fill',
},
{
name: 'user',
keywords: 'person account',
line: 'i-ri-user-line',
fill: 'i-ri-user-fill',
},
{
name: 'user-add',
keywords: 'add member',
line: 'i-ri-user-add-line',
fill: 'i-ri-user-add-fill',
},
{
name: 'contacts',
keywords: 'person card contact',
line: 'i-ri-contacts-line',
fill: 'i-ri-contacts-fill',
},
{
name: 'emotion-happy',
keywords: 'happy smile emoji',
line: 'i-ri-emotion-happy-line',
fill: 'i-ri-emotion-happy-fill',
},
{
name: 'thumb-up',
keywords: 'like approve',
line: 'i-ri-thumb-up-line',
fill: 'i-ri-thumb-up-fill',
},
{
name: 'hand-heart',
keywords: 'care support',
line: 'i-ri-hand-heart-line',
fill: 'i-ri-hand-heart-fill',
},
{
name: 'at',
keywords: 'mention email at',
line: 'i-ri-at-line',
fill: 'i-ri-at-fill',
},
{
name: 'image-2',
keywords: 'picture photo image',
line: 'i-ri-image-2-line',
fill: 'i-ri-image-2-fill',
},
{
name: 'camera-3',
keywords: 'photo camera',
line: 'i-ri-camera-3-line',
fill: 'i-ri-camera-3-fill',
},
{
name: 'film',
keywords: 'movie video',
line: 'i-ri-film-line',
fill: 'i-ri-film-fill',
},
{
name: 'music-2',
keywords: 'audio song music',
line: 'i-ri-music-2-line',
fill: 'i-ri-music-2-fill',
},
{
name: 'headphone',
keywords: 'audio support',
line: 'i-ri-headphone-line',
fill: 'i-ri-headphone-fill',
},
{
name: 'palette',
keywords: 'color design art',
line: 'i-ri-palette-line',
fill: 'i-ri-palette-fill',
},
{
name: 'brush-3',
keywords: 'paint design brush',
line: 'i-ri-brush-3-line',
fill: 'i-ri-brush-3-fill',
},
{
name: 'scissors-2',
keywords: 'cut',
line: 'i-ri-scissors-2-line',
fill: 'i-ri-scissors-2-fill',
},
{
name: 'code-s-slash',
keywords: 'develop code programming',
line: 'i-ri-code-s-slash-line',
fill: 'i-ri-code-s-slash-fill',
},
{
name: 'terminal-box',
keywords: 'console cli terminal',
line: 'i-ri-terminal-box-line',
fill: 'i-ri-terminal-box-fill',
},
{
name: 'git-branch',
keywords: 'version code git',
line: 'i-ri-git-branch-line',
fill: 'i-ri-git-branch-fill',
},
{
name: 'github',
keywords: 'git code',
line: 'i-ri-github-line',
fill: 'i-ri-github-fill',
},
{
name: 'cpu',
keywords: 'chip processor',
line: 'i-ri-cpu-line',
fill: 'i-ri-cpu-fill',
},
{
name: 'database-2',
keywords: 'storage data',
line: 'i-ri-database-2-line',
fill: 'i-ri-database-2-fill',
},
{
name: 'server',
keywords: 'host backend',
line: 'i-ri-server-line',
fill: 'i-ri-server-fill',
},
{
name: 'cloud',
keywords: 'storage cloud',
line: 'i-ri-cloud-line',
fill: 'i-ri-cloud-fill',
},
{
name: 'wifi',
keywords: 'network internet',
line: 'i-ri-wifi-line',
fill: 'i-ri-wifi-fill',
},
{
name: 'computer',
keywords: 'screen desktop monitor',
line: 'i-ri-computer-line',
fill: 'i-ri-computer-fill',
},
{
name: 'smartphone',
keywords: 'mobile phone',
line: 'i-ri-smartphone-line',
fill: 'i-ri-smartphone-fill',
},
{
name: 'macbook',
keywords: 'laptop computer',
line: 'i-ri-macbook-line',
fill: 'i-ri-macbook-fill',
},
{
name: 'bug',
keywords: 'issue error bug',
line: 'i-ri-bug-line',
fill: 'i-ri-bug-fill',
},
{
name: 'command',
keywords: 'keyboard shortcut',
line: 'i-ri-command-line',
fill: 'i-ri-command-fill',
},
{
name: 'settings-3',
keywords: 'config gear options settings',
line: 'i-ri-settings-3-line',
fill: 'i-ri-settings-3-fill',
},
{
name: 'equalizer',
keywords: 'controls adjust sliders filter',
line: 'i-ri-equalizer-line',
fill: 'i-ri-equalizer-fill',
},
{
name: 'tools',
keywords: 'fix tools config',
line: 'i-ri-tools-line',
fill: 'i-ri-tools-fill',
},
{
name: 'hammer',
keywords: 'build fix hammer',
line: 'i-ri-hammer-line',
fill: 'i-ri-hammer-fill',
},
{
name: 'key-2',
keywords: 'password access key',
line: 'i-ri-key-2-line',
fill: 'i-ri-key-2-fill',
},
{
name: 'lock',
keywords: 'secure private lock',
line: 'i-ri-lock-line',
fill: 'i-ri-lock-fill',
},
{
name: 'lock-unlock',
keywords: 'open access',
line: 'i-ri-lock-unlock-line',
fill: 'i-ri-lock-unlock-fill',
},
{
name: 'shield',
keywords: 'security protect',
line: 'i-ri-shield-line',
fill: 'i-ri-shield-fill',
},
{
name: 'shield-check',
keywords: 'secure verified',
line: 'i-ri-shield-check-line',
fill: 'i-ri-shield-check-fill',
},
{
name: 'eye',
keywords: 'view visible preview',
line: 'i-ri-eye-line',
fill: 'i-ri-eye-fill',
},
{
name: 'search',
keywords: 'find lookup search',
line: 'i-ri-search-line',
fill: 'i-ri-search-fill',
},
{
name: 'filter-3',
keywords: 'sort refine filter',
line: 'i-ri-filter-3-line',
fill: 'i-ri-filter-3-fill',
},
{
name: 'links',
keywords: 'url chain link',
line: 'i-ri-links-line',
fill: 'i-ri-links-fill',
},
{
name: 'share-forward',
keywords: 'share send distribute',
line: 'i-ri-share-forward-line',
fill: 'i-ri-share-forward-fill',
},
{
name: 'plug',
keywords: 'connect integration',
line: 'i-ri-plug-line',
fill: 'i-ri-plug-fill',
},
{
name: 'shut-down',
keywords: 'power on off',
line: 'i-ri-shut-down-line',
fill: 'i-ri-shut-down-fill',
},
{
name: 'qr-scan-2',
keywords: 'qr scan',
line: 'i-ri-qr-scan-2-line',
fill: 'i-ri-qr-scan-2-fill',
},
{
name: 'fingerprint',
keywords: 'identity auth fingerprint',
line: 'i-ri-fingerprint-line',
fill: 'i-ri-fingerprint-fill',
},
{
name: 'bar-chart',
keywords: 'analytics stats graph bar',
line: 'i-ri-bar-chart-line',
fill: 'i-ri-bar-chart-fill',
},
{
name: 'pie-chart',
keywords: 'analytics stats pie',
line: 'i-ri-pie-chart-line',
fill: 'i-ri-pie-chart-fill',
},
{
name: 'line-chart',
keywords: 'analytics trend graph line',
line: 'i-ri-line-chart-line',
fill: 'i-ri-line-chart-fill',
},
{
name: 'stock',
keywords: 'growth increase trending',
line: 'i-ri-stock-line',
fill: 'i-ri-stock-fill',
},
{
name: 'pulse',
keywords: 'activity health monitor',
line: 'i-ri-pulse-line',
fill: 'i-ri-pulse-fill',
},
{
name: 'dashboard-3',
keywords: 'speed performance dashboard gauge',
line: 'i-ri-dashboard-3-line',
fill: 'i-ri-dashboard-3-fill',
},
{
name: 'check',
keywords: 'done complete ok check',
line: 'i-ri-check-line',
fill: 'i-ri-check-fill',
},
{
name: 'checkbox-circle',
keywords: 'done success check',
line: 'i-ri-checkbox-circle-line',
fill: 'i-ri-checkbox-circle-fill',
},
{
name: 'close-circle',
keywords: 'error fail close',
line: 'i-ri-close-circle-line',
fill: 'i-ri-close-circle-fill',
},
{
name: 'information',
keywords: 'info information',
line: 'i-ri-information-line',
fill: 'i-ri-information-fill',
},
{
name: 'error-warning',
keywords: 'warning alert',
line: 'i-ri-error-warning-line',
fill: 'i-ri-error-warning-fill',
},
{
name: 'alert',
keywords: 'warning caution',
line: 'i-ri-alert-line',
fill: 'i-ri-alert-fill',
},
{
name: 'question',
keywords: 'support faq help',
line: 'i-ri-question-line',
fill: 'i-ri-question-fill',
},
{
name: 'layout-grid',
keywords: 'dashboard apps grid',
line: 'i-ri-layout-grid-line',
fill: 'i-ri-layout-grid-fill',
},
{
name: 'table',
keywords: 'grid data table',
line: 'i-ri-table-line',
fill: 'i-ri-table-fill',
},
{
name: 'function',
keywords: 'kanban board',
line: 'i-ri-function-line',
fill: 'i-ri-function-fill',
},
{
name: 'map-2',
keywords: 'location navigate map',
line: 'i-ri-map-2-line',
fill: 'i-ri-map-2-fill',
},
{
name: 'navigation',
keywords: 'direction gps',
line: 'i-ri-navigation-line',
fill: 'i-ri-navigation-fill',
},
{
name: 'car',
keywords: 'drive vehicle car',
line: 'i-ri-car-line',
fill: 'i-ri-car-fill',
},
{
name: 'truck',
keywords: 'delivery shipping',
line: 'i-ri-truck-line',
fill: 'i-ri-truck-fill',
},
{
name: 'plane',
keywords: 'travel flight plane',
line: 'i-ri-plane-line',
fill: 'i-ri-plane-fill',
},
{
name: 'bike',
keywords: 'cycle bike',
line: 'i-ri-bike-line',
fill: 'i-ri-bike-fill',
},
{
name: 'anchor',
keywords: 'ship marine anchor',
line: 'i-ri-anchor-line',
fill: 'i-ri-anchor-fill',
},
{
name: 'leaf',
keywords: 'eco nature green leaf',
line: 'i-ri-leaf-line',
fill: 'i-ri-leaf-fill',
},
{
name: 'plant',
keywords: 'nature plant grow',
line: 'i-ri-plant-line',
fill: 'i-ri-plant-fill',
},
{
name: 'sun',
keywords: 'day light weather sun',
line: 'i-ri-sun-line',
fill: 'i-ri-sun-fill',
},
{
name: 'moon',
keywords: 'night dark moon',
line: 'i-ri-moon-line',
fill: 'i-ri-moon-fill',
},
{
name: 'rainy',
keywords: 'weather rain',
line: 'i-ri-rainy-line',
fill: 'i-ri-rainy-fill',
},
{
name: 'umbrella',
keywords: 'rain protect umbrella',
line: 'i-ri-umbrella-line',
fill: 'i-ri-umbrella-fill',
},
{
name: 'landscape',
keywords: 'outdoor hike mountain',
line: 'i-ri-landscape-line',
fill: 'i-ri-landscape-fill',
},
{
name: 'drop',
keywords: 'water drop',
line: 'i-ri-drop-line',
fill: 'i-ri-drop-fill',
},
{
name: 'cup',
keywords: 'coffee drink cup',
line: 'i-ri-cup-line',
fill: 'i-ri-cup-fill',
},
{
name: 'restaurant',
keywords: 'food eat restaurant',
line: 'i-ri-restaurant-line',
fill: 'i-ri-restaurant-fill',
},
{
name: 'cake-3',
keywords: 'birthday celebrate cake',
line: 'i-ri-cake-3-line',
fill: 'i-ri-cake-3-fill',
},
{
name: 'goblet',
keywords: 'drink wine bar',
line: 'i-ri-goblet-line',
fill: 'i-ri-goblet-fill',
},
{
name: 'heart-pulse',
keywords: 'health medical',
line: 'i-ri-heart-pulse-line',
fill: 'i-ri-heart-pulse-fill',
},
{
name: 'stethoscope',
keywords: 'health doctor medical',
line: 'i-ri-stethoscope-line',
fill: 'i-ri-stethoscope-fill',
},
{
name: 'capsule',
keywords: 'medicine pill health',
line: 'i-ri-capsule-line',
fill: 'i-ri-capsule-fill',
},
{
name: 'add',
keywords: 'add new create plus',
line: 'i-ri-add-line',
fill: 'i-ri-add-fill',
},
{
name: 'delete-bin',
keywords: 'delete remove trash',
line: 'i-ri-delete-bin-line',
fill: 'i-ri-delete-bin-fill',
},
{
name: 'download',
keywords: 'save export download',
line: 'i-ri-download-line',
fill: 'i-ri-download-fill',
},
{
name: 'upload',
keywords: 'import upload',
line: 'i-ri-upload-line',
fill: 'i-ri-upload-fill',
},
{
name: 'refresh',
keywords: 'reload sync update refresh',
line: 'i-ri-refresh-line',
fill: 'i-ri-refresh-fill',
},
{
name: 'external-link',
keywords: 'open new external',
line: 'i-ri-external-link-line',
fill: 'i-ri-external-link-fill',
},
{
name: 'logout-box',
keywords: 'exit signout logout',
line: 'i-ri-logout-box-line',
fill: 'i-ri-logout-box-fill',
},
{
name: 'fullscreen',
keywords: 'expand maximize fullscreen',
line: 'i-ri-fullscreen-line',
fill: 'i-ri-fullscreen-fill',
},
{
name: 'drag-move',
keywords: 'drag reorder move',
line: 'i-ri-drag-move-line',
fill: 'i-ri-drag-move-fill',
},
{
name: 'paint-brush',
keywords: 'brush paint',
line: 'i-ri-paint-brush-line',
fill: 'i-ri-paint-brush-fill',
},
];
@@ -21,6 +21,12 @@ import ChannelIcon from 'next/icon/ChannelIcon.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import {
SIDEBAR_SORT_SECTIONS,
getSidebarSortOptions,
resolveSidebarSort,
sortSidebarItems,
} from 'dashboard/helper/sidebarSort';
const props = defineProps({
isMobileSidebarOpen: {
@@ -50,6 +56,7 @@ const { width: windowWidth } = useWindowSize();
const isMobile = computed(() => windowWidth.value < 768);
const accountId = useMapGetter('getCurrentAccountId');
const currentUserId = useMapGetter('getCurrentUserID');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
@@ -79,6 +86,11 @@ const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
store.dispatch('conversationUnreadCounts/get');
};
const fetchSidebarSortPreferences = ([currentAccountId, userId]) => {
if (!currentAccountId || !userId) return;
store.dispatch('sidebarSortPreferences/initialize');
};
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -192,6 +204,9 @@ const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
'customViews/getConversationCustomViews'
);
const getSidebarSectionSort = useMapGetter(
'sidebarSortPreferences/getSectionSort'
);
onMounted(() => {
store.dispatch('labels/get');
@@ -207,44 +222,62 @@ watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
immediate: true,
});
const normalizeUnreadCount = count => {
const unreadCount = Number(count);
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
};
watch([accountId, currentUserId], fetchSidebarSortPreferences, {
immediate: true,
});
const sortByUnreadCount = (items, labelKey, unreadCountKey) =>
items.slice().sort((a, b) => {
const unreadCountDiff =
normalizeUnreadCount(unreadCountKey(b)) -
normalizeUnreadCount(unreadCountKey(a));
if (unreadCountDiff !== 0) return unreadCountDiff;
return labelKey(a).localeCompare(labelKey(b));
const getSortOptionsForSection = section =>
getSidebarSortOptions(section, {
hasUnreadCounts: hasConversationUnreadCounts.value,
});
const getSortForSection = section =>
resolveSidebarSort(section, getSidebarSectionSort.value(section), {
hasUnreadCounts: hasConversationUnreadCounts.value,
});
const updateSortPreference = (section, sortBy) => {
store.dispatch('sidebarSortPreferences/setSectionSort', {
section,
sortBy,
});
};
const buildSortConfig = section => ({
sortOptions: getSortOptionsForSection(section),
activeSort: getSortForSection(section),
onSortChange: sortBy => updateSortPreference(section, sortBy),
});
const sortedFolders = computed(() =>
sortSidebarItems(conversationCustomViews.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.FOLDERS),
labelKey: view => view.name,
})
);
const sortedTeams = computed(() =>
sortByUnreadCount(
teams.value,
team => team.name,
team => getTeamUnreadCount.value(team.id)
)
sortSidebarItems(teams.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.TEAMS),
labelKey: team => team.name,
unreadCountKey: team => getTeamUnreadCount.value(team.id),
})
);
const sortedInboxes = computed(() =>
sortByUnreadCount(
inboxes.value,
inbox => inbox.name,
inbox => getInboxUnreadCount.value(inbox.id)
)
sortSidebarItems(inboxes.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.CHANNELS),
labelKey: inbox => inbox.name,
unreadCountKey: inbox => getInboxUnreadCount.value(inbox.id),
})
);
const sortedLabels = computed(() =>
sortByUnreadCount(
labels.value,
label => label.title,
label => getLabelUnreadCount.value(label.id)
)
sortSidebarItems(labels.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.LABELS),
labelKey: label => label.title,
unreadCountKey: label => getLabelUnreadCount.value(label.id),
})
);
const closeMobileSidebar = () => {
@@ -331,9 +364,10 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'),
icon: 'i-lucide-folder',
activeOn: ['conversations_through_folders'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.FOLDERS),
collapsible: true,
showTreeLine: true,
children: conversationCustomViews.value.map(view => ({
children: sortedFolders.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute('folder_conversations', { id: view.id }),
@@ -344,6 +378,7 @@ const menuItems = computed(() => {
label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users',
activeOn: ['conversations_through_team'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.TEAMS),
collapsible: true,
showTreeLine: true,
children: sortedTeams.value.map(team => ({
@@ -358,6 +393,7 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CHANNELS'),
icon: 'i-lucide-mailbox',
activeOn: ['conversation_through_inbox'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.CHANNELS),
collapsible: true,
showTreeLine: true,
children: sortedInboxes.value.map(inbox => ({
@@ -380,6 +416,7 @@ const menuItems = computed(() => {
label: t('SIDEBAR.LABELS'),
icon: 'i-lucide-tag',
activeOn: ['conversations_through_label'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.LABELS),
collapsible: true,
showTreeLine: true,
children: sortedLabels.value.map(label => ({
@@ -1,11 +1,13 @@
<script setup>
import { computed, ref, onMounted, nextTick } from 'vue';
import { onClickOutside } from '@vueuse/core';
import { useRouter } from 'vue-router';
import { useSidebarContext } from './provider';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'next/icon/Icon.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
import SidebarSortMenu from './SidebarSortMenu.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -14,7 +16,7 @@ const props = defineProps({
triggerRect: { type: Object, default: () => ({ top: 0, left: 0 }) },
});
const emit = defineEmits(['close', 'mouseenter', 'mouseleave']);
const emit = defineEmits(['close', 'mouseenter', 'mouseleave', 'sortToggle']);
const router = useRouter();
const { isAllowed, sidebarWidth } = useSidebarContext();
@@ -29,6 +31,14 @@ const toggleSubGroup = name => {
expandedSubGroup.value = expandedSubGroup.value === name ? null : name;
};
const handleSortToggle = isSortOpen => {
emit('sortToggle', isSortOpen);
};
onClickOutside(popoverRef, () => emit('close'), {
ignore: ['[data-popover-content]'],
});
const navigateAndClose = to => {
router.push(to);
emit('close');
@@ -123,24 +133,44 @@ onMounted(async () => {
>
<template v-for="child in accessibleChildren" :key="child.name">
<!-- SubGroup with children -->
<li v-if="child.children" class="py-0.5">
<button
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out text-left rtl:text-right"
@click="toggleSubGroup(child.name)"
<li v-if="child.children" class="group/sidebar-section py-0.5">
<div
class="flex items-center rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out"
>
<Icon
v-if="child.icon"
:icon="child.icon"
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
<span
class="size-3 transition-transform i-lucide-chevron-down"
:class="{
'rotate-180': expandedSubGroup === child.name,
}"
/>
</button>
<button
class="flex flex-1 min-w-0 items-center gap-2 ps-2 py-1.5 text-left rtl:text-right"
@click="toggleSubGroup(child.name)"
>
<Icon
v-if="child.icon"
:icon="child.icon"
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
</button>
<div class="flex flex-shrink-0 items-center gap-1 pe-2">
<SidebarSortMenu
v-if="child.sortOptions?.length"
:active-sort="child.activeSort"
:options="child.sortOptions"
:open-on-hover="false"
@sort="child.onSortChange"
@toggle="handleSortToggle"
/>
<button
type="button"
class="flex size-6 flex-shrink-0 items-center justify-center rounded-md text-n-slate-11 hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none"
@click.stop="toggleSubGroup(child.name)"
>
<span
class="size-4 flex-shrink-0 transition-transform i-lucide-chevron-down"
:class="{
'rotate-180': expandedSubGroup === child.name,
}"
/>
</button>
</div>
</div>
<Transition v-bind="transition">
<ul
v-if="expandedSubGroup === child.name"
@@ -55,6 +55,9 @@ const hasChildren = computed(
const isPopoverOpen = computed(() => activePopover.value === props.name);
const triggerRef = ref(null);
const triggerRect = ref({ top: 0, left: 0, bottom: 0, right: 0 });
// The sort dropdown teleports outside the popover; keep the popover open while
// it is showing so moving the cursor onto it does not close everything.
const isSortMenuOpen = ref(false);
const openPopover = () => {
if (triggerRef.value) {
@@ -82,7 +85,7 @@ const handleMouseEnter = () => {
};
const handleMouseLeave = () => {
if (!hasChildren.value) return;
if (!hasChildren.value || isSortMenuOpen.value) return;
scheduleClose(200);
};
@@ -91,9 +94,15 @@ const handlePopoverMouseEnter = () => {
};
const handlePopoverMouseLeave = () => {
if (isSortMenuOpen.value) return;
scheduleClose(100);
};
const handleSortToggle = isOpen => {
isSortMenuOpen.value = isOpen;
cancelClose();
};
// Close popover when mouse leaves the window
const handleWindowBlur = () => {
closeActivePopover();
@@ -273,6 +282,7 @@ watch(
@close="closePopover"
@mouseenter="handlePopoverMouseEnter"
@mouseleave="handlePopoverMouseLeave"
@sort-toggle="handleSortToggle"
/>
</div>
</template>
@@ -307,6 +317,9 @@ watch(
:end-tree-line="child.showTreeLine && isLastVisibleChild(child)"
:is-expanded="isExpanded"
:active-child="activeChild"
:sort-options="child.sortOptions"
:active-sort="child.activeSort"
@update-sort="child.onSortChange"
/>
<SidebarGroupLeaf
v-else-if="isAllowed(child.to)"
@@ -45,7 +45,9 @@ const count = computed(() =>
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
/>
</div>
<div class="flex items-center gap-1.5 flex-grow min-w-0 flex-1">
<div
class="flex items-center gap-1.5 flex-grow justify-between min-w-0 flex-1"
>
<span
class="truncate"
:class="{
@@ -57,11 +59,7 @@ const count = computed(() =>
</span>
<span
v-if="dynamicCount && !expandable"
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
:class="{
'text-n-slate-12 outline-n-slate-6': isActive,
'text-n-slate-11 outline-n-strong': !isActive,
}"
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
>
{{ count }}
</span>
@@ -1,5 +1,6 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
import SidebarSortMenu from './SidebarSortMenu.vue';
defineProps({
collapsible: {
@@ -18,6 +19,14 @@ defineProps({
type: [Object, String],
default: '',
},
sortOptions: {
type: Array,
default: () => [],
},
activeSort: {
type: String,
default: '',
},
showTreeLine: {
type: Boolean,
default: false,
@@ -28,7 +37,7 @@ defineProps({
},
});
const emit = defineEmits(['toggle']);
const emit = defineEmits(['toggle', 'update-sort']);
const TREE_VERTICAL_LINE =
"before:content-[''] before:absolute before:-top-1 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
@@ -37,34 +46,59 @@ const TREE_ELBOW =
</script>
<template>
<component
:is="collapsible ? 'button' : 'div'"
:type="collapsible ? 'button' : undefined"
:aria-expanded="collapsible ? isExpanded : undefined"
:title="label"
class="relative justify-between flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none min-w-0"
:class="[
showTreeLine && TREE_VERTICAL_LINE,
showTreeLine &&
(endTreeLine ? `before:h-3 ${TREE_ELBOW}` : 'before:-bottom-1'),
{
'w-full': !collapsible,
'pointer-events-none': !collapsible,
'ms-5 cursor-pointer hover:bg-n-alpha-2': collapsible,
},
]"
@click.stop="emit('toggle')"
>
<div class="min-w-0 inline-flex gap-2 items-center">
<Icon v-if="icon" :icon="icon" class="size-4 flex-shrink-0" />
<span class="text-sm font-medium leading-5 flex-grow truncate text-start">
{{ label }}
</span>
<div class="relative min-w-0" :class="{ 'ms-5': collapsible }">
<component
:is="collapsible ? 'button' : 'div'"
:type="collapsible ? 'button' : undefined"
:aria-expanded="collapsible ? isExpanded : undefined"
:title="label"
class="relative flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-n-slate-10 select-none"
:class="[
showTreeLine && TREE_VERTICAL_LINE,
showTreeLine &&
(endTreeLine ? `before:h-3 ${TREE_ELBOW}` : 'before:-bottom-1'),
{
'pointer-events-none': !collapsible,
'cursor-pointer hover:bg-n-alpha-2': collapsible,
'pe-14': collapsible && sortOptions.length,
'pe-8': collapsible && !sortOptions.length,
'pe-10': !collapsible && sortOptions.length,
},
]"
@click.stop="collapsible ? emit('toggle') : undefined"
>
<div class="inline-flex min-w-0 items-center gap-2">
<Icon v-if="icon" :icon="icon" class="size-4 flex-shrink-0" />
<span
class="flex-grow truncate text-start text-sm font-medium leading-5"
>
{{ label }}
</span>
</div>
</component>
<div
v-if="collapsible || sortOptions.length"
class="absolute end-2 top-1/2 flex -translate-y-1/2 items-center gap-1"
>
<SidebarSortMenu
v-if="sortOptions.length"
:active-sort="activeSort"
:options="sortOptions"
@sort="sortBy => emit('update-sort', sortBy)"
/>
<button
v-if="collapsible"
type="button"
class="flex size-6 flex-shrink-0 items-center justify-center rounded-md text-n-slate-10 hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none"
:aria-expanded="isExpanded"
:aria-label="label"
@click.stop="emit('toggle')"
>
<span
class="size-3 flex-shrink-0"
:class="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
/>
</button>
</div>
<span
v-if="collapsible"
class="size-3 flex-shrink-0 text-n-slate-10 me-0.5"
:class="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
/>
</component>
</div>
</template>
@@ -0,0 +1,209 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import { SIDEBAR_SORT_KEYS } from 'dashboard/helper/sidebarSort';
const props = defineProps({
activeSort: {
type: String,
default: '',
},
options: {
type: Array,
default: () => [],
},
openOnHover: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['sort', 'toggle']);
const SORT_OPTION_GROUPS = [
{
key: 'created',
options: [SIDEBAR_SORT_KEYS.CREATED_DESC, SIDEBAR_SORT_KEYS.CREATED_ASC],
},
{
key: 'alphabetical',
options: [
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
],
},
{
key: 'unread_count',
options: [
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
},
];
const { t } = useI18n();
const isOpen = ref(false);
const triggerRef = ref(null);
const popoverRef = ref(null);
let closeTimer;
const { fixedPosition, updatePosition } = useDropdownPosition(
triggerRef,
popoverRef,
isOpen,
{ align: 'start' }
);
const getSortOptionLabel = option => {
if (option === SIDEBAR_SORT_KEYS.CREATED_DESC) {
return t('SIDEBAR.SORT_OPTIONS.CREATED_DESC');
}
if (option === SIDEBAR_SORT_KEYS.CREATED_ASC) {
return t('SIDEBAR.SORT_OPTIONS.CREATED_ASC');
}
if (option === SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC) {
return t('SIDEBAR.SORT_OPTIONS.ALPHABETICAL_ASC');
}
if (option === SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC) {
return t('SIDEBAR.SORT_OPTIONS.ALPHABETICAL_DESC');
}
if (option === SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC) {
return t('SIDEBAR.SORT_OPTIONS.UNREAD_COUNT_DESC');
}
if (option === SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC) {
return t('SIDEBAR.SORT_OPTIONS.UNREAD_COUNT_ASC');
}
return '';
};
const getSortGroupLabel = groupKey => {
if (groupKey === 'created') {
return t('SIDEBAR.SORT_GROUPS.CREATED');
}
if (groupKey === 'alphabetical') {
return t('SIDEBAR.SORT_GROUPS.ALPHABETICAL');
}
if (groupKey === 'unread_count') {
return t('SIDEBAR.SORT_GROUPS.UNREAD_COUNT');
}
return '';
};
const sortMenuSections = computed(() =>
SORT_OPTION_GROUPS.map(group => ({
title: getSortGroupLabel(group.key),
items: group.options
.filter(option => props.options.includes(option))
.map(option => ({
label: getSortOptionLabel(option),
value: option,
action: 'sort',
isActive: option === props.activeSort,
})),
})).filter(section => section.items.length)
);
const clearCloseTimer = () => {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
};
const openMenu = async () => {
clearCloseTimer();
isOpen.value = true;
emit('toggle', true);
await nextTick();
updatePosition();
};
const closeMenu = () => {
clearCloseTimer();
isOpen.value = false;
emit('toggle', false);
};
const scheduleClose = () => {
clearCloseTimer();
closeTimer = setTimeout(closeMenu, 150);
};
const handleTriggerEnter = () => {
if (props.openOnHover) openMenu();
};
const handleTriggerLeave = () => {
if (props.openOnHover) scheduleClose();
};
const handleClickOutside = event => {
if (triggerRef.value?.contains(event.target)) return;
closeMenu();
};
const handleSortChange = ({ value }) => {
emit('sort', value);
closeMenu();
};
onBeforeUnmount(clearCloseTimer);
</script>
<template>
<div
ref="triggerRef"
class="relative invisible flex-shrink-0 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/sidebar-section:visible group-hover/sidebar-section:opacity-100 group-hover/sidebar-section:pointer-events-auto"
:class="{ '!visible !opacity-100 !pointer-events-auto': isOpen }"
@mouseenter="handleTriggerEnter"
@mouseleave="handleTriggerLeave"
>
<Button
:title="t('SIDEBAR.SORT_TOOLTIP')"
icon="i-lucide-arrow-up-down"
ghost
slate
xs
class="!size-6 !text-n-slate-11 hover:!text-n-slate-12"
:class="{ '!bg-n-alpha-2': isOpen }"
@click.stop="openMenu"
/>
<TeleportWithDirection>
<DropdownMenu
v-if="isOpen"
ref="popoverRef"
v-on-click-outside="handleClickOutside"
data-popover-content
:menu-sections="sortMenuSections"
:class="fixedPosition.class"
:style="fixedPosition.style"
class="w-60 !fixed"
@action="handleSortChange"
@mouseenter="clearCloseTimer"
@mouseleave="handleTriggerLeave"
>
<template #trailing-icon="{ item }">
<span
v-if="item.isActive"
class="i-lucide-check ms-auto size-4 flex-shrink-0 text-n-slate-11"
/>
</template>
</DropdownMenu>
</TeleportWithDirection>
</div>
</template>
@@ -1,5 +1,6 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
@@ -8,7 +9,6 @@ import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
import { useSidebarContext } from './provider';
import { useEventListener } from '@vueuse/core';
const props = defineProps({
name: { type: String, required: true },
@@ -17,11 +17,15 @@ const props = defineProps({
icon: { type: [Object, String], required: true },
children: { type: Array, default: undefined },
activeChild: { type: Object, default: undefined },
sortOptions: { type: Array, default: () => [] },
activeSort: { type: String, default: '' },
collapsible: { type: Boolean, default: false },
showTreeLine: { type: Boolean, default: false },
endTreeLine: { type: Boolean, default: false },
});
const emit = defineEmits(['update-sort']);
const { isAllowed } = useSidebarContext();
const scrollableContainer = ref(null);
const accountId = useMapGetter('getCurrentAccountId');
@@ -121,7 +125,7 @@ watch([hasActiveChild, storageKey], expandSubGroupOnActiveChild, {
</script>
<template>
<li class="relative flex flex-col list-none min-w-0">
<li class="group/sidebar-section relative flex flex-col list-none min-w-0">
<template v-if="hasAccessibleItems">
<SidebarGroupSeparator
v-show="isExpanded"
@@ -131,8 +135,11 @@ watch([hasActiveChild, storageKey], expandSubGroupOnActiveChild, {
:is-expanded="isSubGroupExpanded"
:show-tree-line="showTreeLine"
:end-tree-line="endTreeLine"
:sort-options="sortOptions"
:active-sort="activeSort"
class="my-1"
@toggle="toggleSubGroup"
@update-sort="sortBy => emit('update-sort', sortBy)"
/>
<ul
v-if="children.length"
@@ -81,6 +81,7 @@ const showDivider = index => {
<template v-for="(tab, index) in tabs" :key="index">
<button
:ref="el => (tabRefs[index] = el)"
type="button"
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
:class="[
activeTab === index
@@ -56,8 +56,9 @@ import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { emitter } from 'shared/helpers/mitt';
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
);
export default {
@@ -66,7 +67,7 @@ export default {
AttachmentPreview,
AudioRecorder,
ReplyBoxBanner,
EmojiInput,
EmojiIconPicker,
MessageSignatureMissingAlert,
ReplyBottomPanel,
ReplyEmailHead,
@@ -1284,13 +1285,15 @@ export default {
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<EmojiInput
<EmojiIconPicker
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
mode="emoji"
class="emoji-dialog"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout,
}"
:on-click="addIntoEditor"
@select="addIntoEditor($event.value)"
/>
<ReplyEmailHead
v-if="showReplyHead && isDefaultEditorMode"
@@ -0,0 +1,181 @@
export const SIDEBAR_SORT_KEYS = Object.freeze({
CREATED_DESC: 'created_at_desc',
CREATED_ASC: 'created_at_asc',
ALPHABETICAL_ASC: 'alphabetical_asc',
ALPHABETICAL_DESC: 'alphabetical_desc',
UNREAD_COUNT_DESC: 'unread_count_desc',
UNREAD_COUNT_ASC: 'unread_count_asc',
});
export const SIDEBAR_SORT_SECTIONS = Object.freeze({
FOLDERS: 'folders',
TEAMS: 'teams',
CHANNELS: 'channels',
LABELS: 'labels',
});
export const SIDEBAR_SORT_OPTIONS_BY_SECTION = Object.freeze({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
],
[SIDEBAR_SORT_SECTIONS.TEAMS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
[SIDEBAR_SORT_SECTIONS.CHANNELS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
[SIDEBAR_SORT_SECTIONS.LABELS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
});
const UNREAD_COUNT_SORT_OPTIONS = [
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
];
export const DEFAULT_SIDEBAR_SORT_PREFERENCES = Object.freeze({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.CREATED_DESC,
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
[SIDEBAR_SORT_SECTIONS.CHANNELS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
[SIDEBAR_SORT_SECTIONS.LABELS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
});
export const isValidSidebarSort = (section, sortBy) => {
return SIDEBAR_SORT_OPTIONS_BY_SECTION[section]?.includes(sortBy);
};
const isUnreadCountSort = sortBy => UNREAD_COUNT_SORT_OPTIONS.includes(sortBy);
export const getSidebarSortOptions = (
section,
{ hasUnreadCounts = true } = {}
) => {
const options = SIDEBAR_SORT_OPTIONS_BY_SECTION[section] || [];
if (hasUnreadCounts) return options;
return options.filter(option => !isUnreadCountSort(option));
};
export const resolveSidebarSort = (
section,
sortBy,
{ hasUnreadCounts = true } = {}
) => {
const options = getSidebarSortOptions(section, { hasUnreadCounts });
if (options.includes(sortBy)) return sortBy;
if (!hasUnreadCounts && isUnreadCountSort(sortBy)) {
return SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC;
}
return options[0] || SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC;
};
export const normalizeSidebarSortPreferences = (preferences = {}) => {
const savedPreferences = preferences || {};
return Object.keys(DEFAULT_SIDEBAR_SORT_PREFERENCES).reduce(
(result, section) => {
const sortBy = savedPreferences[section];
result[section] = isValidSidebarSort(section, sortBy)
? sortBy
: DEFAULT_SIDEBAR_SORT_PREFERENCES[section];
return result;
},
{}
);
};
const normalizeUnreadCount = count => {
const unreadCount = Number(count);
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
};
const getCreatedValue = item => {
const createdAt = item.created_at || item.createdAt;
if (typeof createdAt === 'number') return createdAt;
if (createdAt) {
const timestamp = Date.parse(createdAt);
if (Number.isFinite(timestamp)) return timestamp;
}
const id = Number(item.id);
return Number.isFinite(id) ? id : 0;
};
const getLabelValue = (item, labelKey) => {
return String(labelKey(item) || '');
};
const compareAlphabetically = (a, b, labelKey) => {
return getLabelValue(a, labelKey).localeCompare(
getLabelValue(b, labelKey),
undefined,
{
sensitivity: 'base',
}
);
};
export const sortSidebarItems = (
items,
{ sortBy, labelKey, unreadCountKey = () => 0 }
) => {
return (items || []).slice().sort((a, b) => {
if (sortBy === SIDEBAR_SORT_KEYS.CREATED_DESC) {
const createdDiff = getCreatedValue(b) - getCreatedValue(a);
if (createdDiff !== 0) return createdDiff;
return compareAlphabetically(a, b, labelKey);
}
if (sortBy === SIDEBAR_SORT_KEYS.CREATED_ASC) {
const createdDiff = getCreatedValue(a) - getCreatedValue(b);
if (createdDiff !== 0) return createdDiff;
return compareAlphabetically(a, b, labelKey);
}
if (sortBy === SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC) {
return compareAlphabetically(b, a, labelKey);
}
if (sortBy === SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC) {
const unreadCountDiff =
normalizeUnreadCount(unreadCountKey(b)) -
normalizeUnreadCount(unreadCountKey(a));
if (unreadCountDiff !== 0) return unreadCountDiff;
}
if (sortBy === SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC) {
const unreadCountDiff =
normalizeUnreadCount(unreadCountKey(a)) -
normalizeUnreadCount(unreadCountKey(b));
if (unreadCountDiff !== 0) return unreadCountDiff;
}
return compareAlphabetically(a, b, labelKey);
});
};
@@ -0,0 +1,205 @@
import {
DEFAULT_SIDEBAR_SORT_PREFERENCES,
SIDEBAR_SORT_KEYS,
SIDEBAR_SORT_SECTIONS,
getSidebarSortOptions,
normalizeSidebarSortPreferences,
resolveSidebarSort,
sortSidebarItems,
} from '../sidebarSort';
const items = [
{
id: 1,
name: 'Billing',
created_at: '2024-01-01T00:00:00.000Z',
},
{
id: 3,
name: 'Accounts',
created_at: '2024-03-01T00:00:00.000Z',
},
{
id: 2,
name: 'Support',
created_at: '2024-02-01T00:00:00.000Z',
},
];
describe('#sortSidebarItems', () => {
it('sorts by created date descending', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.CREATED_DESC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Accounts',
'Support',
'Billing',
]);
});
it('sorts by created date ascending', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.CREATED_ASC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Billing',
'Support',
'Accounts',
]);
});
it('falls back to id when created date is not present', () => {
const sortedItems = sortSidebarItems(
items.map(({ created_at: _createdAt, ...item }) => item),
{
sortBy: SIDEBAR_SORT_KEYS.CREATED_DESC,
labelKey: item => item.name,
}
);
expect(sortedItems.map(item => item.id)).toEqual([3, 2, 1]);
});
it('sorts alphabetically from A to Z', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Accounts',
'Billing',
'Support',
]);
});
it('sorts alphabetically from Z to A', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Support',
'Billing',
'Accounts',
]);
});
it('sorts by unread count descending and falls back to alphabetical order', () => {
const unreadCounts = {
1: 3,
2: 3,
3: 7,
};
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
labelKey: item => item.name,
unreadCountKey: item => unreadCounts[item.id],
});
expect(sortedItems.map(item => item.name)).toEqual([
'Accounts',
'Billing',
'Support',
]);
});
it('sorts by unread count ascending and falls back to alphabetical order', () => {
const unreadCounts = {
1: 3,
2: 3,
3: 7,
};
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
labelKey: item => item.name,
unreadCountKey: item => unreadCounts[item.id],
});
expect(sortedItems.map(item => item.name)).toEqual([
'Billing',
'Support',
'Accounts',
]);
});
});
describe('#normalizeSidebarSortPreferences', () => {
it('keeps valid preferences', () => {
const preferences = normalizeSidebarSortPreferences({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
});
expect(preferences).toEqual({
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
});
});
it('falls back to defaults for unsupported preferences', () => {
const preferences = normalizeSidebarSortPreferences({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
});
expect(preferences).toEqual(DEFAULT_SIDEBAR_SORT_PREFERENCES);
});
it('falls back to defaults when stored preferences are null', () => {
expect(normalizeSidebarSortPreferences(null)).toEqual(
DEFAULT_SIDEBAR_SORT_PREFERENCES
);
});
});
describe('#getSidebarSortOptions', () => {
it('keeps unread count options when unread counts are enabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
hasUnreadCounts: true,
});
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
});
it('removes unread count options when unread counts are disabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
hasUnreadCounts: false,
});
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
});
describe('#resolveSidebarSort', () => {
it('keeps unread count sort when unread counts are enabled', () => {
const sortBy = resolveSidebarSort(
SIDEBAR_SORT_SECTIONS.TEAMS,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
{ hasUnreadCounts: true }
);
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
});
it('falls back to alphabetical sort when unread counts are disabled', () => {
const sortBy = resolveSidebarSort(
SIDEBAR_SORT_SECTIONS.TEAMS,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
{ hasUnreadCounts: false }
);
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
});
@@ -25,6 +25,10 @@
"ACTIONS": {
"CREATE": "Add company"
},
"SELECTOR": {
"PLACEHOLDER": "Select company",
"CREATE_OPTION": "Add \"{name}\""
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
@@ -3,5 +3,33 @@
"PLACEHOLDER": "Search emojis",
"NOT_FOUND": "No emoji match your search",
"REMOVE": "Remove"
},
"EMOJI_ICON_PICKER": {
"TABS": {
"ICONS": "Icons",
"EMOJIS": "Emojis"
},
"SEARCH_EMOJI": "Search emoji…",
"SEARCH_ICON": "Search icons…",
"FREQUENTLY_USED": "Frequently used",
"NO_EMOJI": "No emoji match your search",
"NO_ICON": "No icons match your search",
"REMOVE": "Remove",
"STYLE": {
"OUTLINE": "Outline icons",
"FILLED": "Filled icons"
},
"COLORS": {
"SLATE": "Slate",
"RED": "Red",
"ORANGE": "Orange",
"AMBER": "Amber",
"GREEN": "Green",
"TEAL": "Teal",
"BLUE": "Blue",
"INDIGO": "Indigo",
"VIOLET": "Violet",
"PINK": "Pink"
}
}
}
@@ -390,6 +390,21 @@
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.",
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"SORT_TOOLTIP": "Sort",
"SORT_BY": "Sort by",
"SORT_GROUPS": {
"CREATED": "Sort by date of creation",
"ALPHABETICAL": "Sort in alphabetical order",
"UNREAD_COUNT": "Sort by unread count"
},
"SORT_OPTIONS": {
"CREATED_DESC": "Newest first",
"CREATED_ASC": "Oldest first",
"ALPHABETICAL_ASC": "Ascending (A - Z)",
"ALPHABETICAL_DESC": "Descending (Z - A)",
"UNREAD_COUNT_DESC": "Highest first",
"UNREAD_COUNT_ASC": "Lowest first"
},
"DOCS": "Read docs",
"SECURITY": "Security",
"CAPTAIN_AI": "Captain",
@@ -126,7 +126,7 @@ const showCompany = companyId => {
};
const openCreateCompanyDialog = () => {
createCompanyDialogRef.value?.dialogRef.open();
createCompanyDialogRef.value?.open();
};
const createCompany = async company => {
+2
View File
@@ -44,6 +44,7 @@ import portals from './modules/helpCenterPortals';
import reports from './modules/reports';
import sla from './modules/sla';
import slaReports from './modules/SLAReports';
import sidebarSortPreferences from './modules/sidebarSortPreferences';
import summaryReports from './modules/summaryReports';
import teamMembers from './modules/teamMembers';
import teams from './modules/teams';
@@ -108,6 +109,7 @@ export default createStore({
reports,
sla,
slaReports,
sidebarSortPreferences,
summaryReports,
teamMembers,
teams,
@@ -13,8 +13,12 @@ const buildContactFormData = contactParams => {
const formData = new FormData();
const { additional_attributes = {}, ...contactProperties } = contactParams;
Object.keys(contactProperties).forEach(key => {
if (contactProperties[key]) {
formData.append(key, contactProperties[key]);
const value = contactProperties[key];
const shouldAppendBlankCompanyId =
key === 'company_id' && value !== undefined;
if (value || shouldAppendBlankCompanyId) {
formData.append(key, value ?? '');
}
});
const { social_profiles, ...additionalAttributesProperties } =
@@ -0,0 +1,82 @@
import { LocalStorage } from 'shared/helpers/localStorage';
import {
DEFAULT_SIDEBAR_SORT_PREFERENCES,
isValidSidebarSort,
normalizeSidebarSortPreferences,
} from 'dashboard/helper/sidebarSort';
const STORAGE_NAME = 'chatwoot_sidebar_sort_preferences';
export const SET_SIDEBAR_SORT_PREFERENCES = 'SET_SIDEBAR_SORT_PREFERENCES';
const getPreferenceScope = rootGetters => {
const currentUserId = rootGetters.getCurrentUserID;
const currentAccountId = rootGetters.getCurrentAccountId;
if (!currentUserId || !currentAccountId) return null;
return `${currentUserId}:${currentAccountId}`;
};
export const state = {
preferences: { ...DEFAULT_SIDEBAR_SORT_PREFERENCES },
storageKey: null,
};
export const getters = {
getSectionSort: $state => section => {
return (
$state.preferences[section] || DEFAULT_SIDEBAR_SORT_PREFERENCES[section]
);
},
};
export const actions = {
initialize({ commit, rootGetters }) {
const storageKey = getPreferenceScope(rootGetters);
const storedPreferences = storageKey
? LocalStorage.getFromJsonStore(STORAGE_NAME, storageKey)
: {};
commit(SET_SIDEBAR_SORT_PREFERENCES, {
preferences: normalizeSidebarSortPreferences(storedPreferences),
storageKey,
});
},
setSectionSort({ commit, rootGetters, state: currentState }, payload = {}) {
const { section, sortBy } = payload;
if (!isValidSidebarSort(section, sortBy)) return;
const storageKey =
currentState.storageKey || getPreferenceScope(rootGetters);
const preferences = {
...currentState.preferences,
[section]: sortBy,
};
commit(SET_SIDEBAR_SORT_PREFERENCES, {
preferences,
storageKey,
});
if (storageKey) {
LocalStorage.updateJsonStore(STORAGE_NAME, storageKey, preferences);
}
},
};
export const mutations = {
[SET_SIDEBAR_SORT_PREFERENCES]($state, payload = {}) {
const { preferences = {}, storageKey = null } = payload;
$state.preferences = normalizeSidebarSortPreferences(preferences);
$state.storageKey = storageKey;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};
@@ -145,6 +145,33 @@ describe('#actions', () => {
[types.SET_CONTACT_UI_FLAG, { isUpdating: false }],
]);
});
it('preserves blank company_id when updating with form data', async () => {
axios.patch.mockResolvedValue({ data: { payload: contactList[0] } });
await actions.update(
{ commit },
{
id: contactList[0].id,
isFormData: true,
name: contactList[0].name,
companyId: '',
avatar: 'avatar-file',
additionalAttributes: {
companyName: '',
socialProfiles: {},
},
}
);
const lastPatchCall =
axios.patch.mock.calls[axios.patch.mock.calls.length - 1];
const formData = lastPatchCall[1];
expect(formData).toBeInstanceOf(FormData);
expect(formData.has('company_id')).toBe(true);
expect(formData.get('company_id')).toBe('');
});
});
describe('#create', () => {
@@ -0,0 +1,106 @@
import { LocalStorage } from 'shared/helpers/localStorage';
import {
DEFAULT_SIDEBAR_SORT_PREFERENCES,
SIDEBAR_SORT_KEYS,
SIDEBAR_SORT_SECTIONS,
} from 'dashboard/helper/sidebarSort';
import {
SET_SIDEBAR_SORT_PREFERENCES,
actions,
} from '../../sidebarSortPreferences';
vi.mock('shared/helpers/localStorage', () => ({
LocalStorage: {
getFromJsonStore: vi.fn(),
updateJsonStore: vi.fn(),
},
}));
const rootGetters = {
getCurrentUserID: 1,
getCurrentAccountId: 2,
};
describe('#actions', () => {
const commit = vi.fn();
beforeEach(() => {
commit.mockClear();
LocalStorage.getFromJsonStore.mockReset();
LocalStorage.updateJsonStore.mockReset();
});
describe('#initialize', () => {
it('loads scoped preferences from local storage', () => {
LocalStorage.getFromJsonStore.mockReturnValue({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
});
actions.initialize({ commit, rootGetters });
expect(LocalStorage.getFromJsonStore).toHaveBeenCalledWith(
'chatwoot_sidebar_sort_preferences',
'1:2'
);
expect(commit).toHaveBeenCalledWith(SET_SIDEBAR_SORT_PREFERENCES, {
preferences: {
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
},
storageKey: '1:2',
});
});
});
describe('#setSectionSort', () => {
it('persists valid preferences to local storage', () => {
const state = {
preferences: DEFAULT_SIDEBAR_SORT_PREFERENCES,
storageKey: '1:2',
};
actions.setSectionSort(
{ commit, rootGetters, state },
{
section: SIDEBAR_SORT_SECTIONS.LABELS,
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
}
);
const preferences = {
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
[SIDEBAR_SORT_SECTIONS.LABELS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
};
expect(commit).toHaveBeenCalledWith(SET_SIDEBAR_SORT_PREFERENCES, {
preferences,
storageKey: '1:2',
});
expect(LocalStorage.updateJsonStore).toHaveBeenCalledWith(
'chatwoot_sidebar_sort_preferences',
'1:2',
preferences
);
});
it('ignores invalid preferences', () => {
actions.setSectionSort(
{
commit,
rootGetters,
state: {
preferences: DEFAULT_SIDEBAR_SORT_PREFERENCES,
storageKey: '1:2',
},
},
{
section: SIDEBAR_SORT_SECTIONS.FOLDERS,
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
}
);
expect(commit).not.toHaveBeenCalled();
expect(LocalStorage.updateJsonStore).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,31 @@
import {
DEFAULT_SIDEBAR_SORT_PREFERENCES,
SIDEBAR_SORT_KEYS,
SIDEBAR_SORT_SECTIONS,
} from 'dashboard/helper/sidebarSort';
import { getters } from '../../sidebarSortPreferences';
describe('#getters', () => {
it('returns section sort preference', () => {
const state = {
preferences: {
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
},
};
expect(getters.getSectionSort(state)(SIDEBAR_SORT_SECTIONS.TEAMS)).toBe(
SIDEBAR_SORT_KEYS.CREATED_ASC
);
});
it('falls back to default section sort preference', () => {
const state = {
preferences: {},
};
expect(getters.getSectionSort(state)(SIDEBAR_SORT_SECTIONS.LABELS)).toBe(
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC
);
});
});
@@ -0,0 +1,34 @@
import {
DEFAULT_SIDEBAR_SORT_PREFERENCES,
SIDEBAR_SORT_KEYS,
SIDEBAR_SORT_SECTIONS,
} from 'dashboard/helper/sidebarSort';
import {
SET_SIDEBAR_SORT_PREFERENCES,
mutations,
} from '../../sidebarSortPreferences';
describe('#mutations', () => {
it('sets normalized preferences', () => {
const state = {
preferences: {},
storageKey: null,
};
mutations[SET_SIDEBAR_SORT_PREFERENCES](state, {
preferences: {
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
[SIDEBAR_SORT_SECTIONS.LABELS]: 'invalid',
},
storageKey: '1:2',
});
expect(state).toEqual({
preferences: {
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
},
storageKey: '1:2',
});
});
});
@@ -1,255 +0,0 @@
<script>
import emojis from './emojisGroup.json';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const SEARCH_KEY = 'Search';
export default {
components: { FluentIcon, NextButton },
props: {
onClick: {
type: Function,
default: () => {},
},
showRemoveButton: {
type: Boolean,
default: false,
},
},
data() {
return {
selectedKey: 'Search',
emojis,
search: '',
};
},
computed: {
categories() {
return [...this.emojis];
},
filterEmojisByCategory() {
const selectedCategoryName = this.emojis.find(category =>
category.name === this.selectedKey ? category.name : null
);
return selectedCategoryName?.emojis;
},
filterAllEmojisBySearch() {
return this.emojis.map(category => {
const allEmojis = category.emojis.filter(emoji =>
emoji.slug.replaceAll('_', ' ').includes(this.search.toLowerCase())
);
return allEmojis.length > 0
? { ...category, emojis: allEmojis }
: { ...category, emojis: [] };
});
},
hasNoSearch() {
return this.selectedKey !== SEARCH_KEY && this.search === '';
},
hasEmptySearchResult() {
return this.filterAllEmojisBySearch.every(
category => category.emojis.length === 0
);
},
},
watch: {
search() {
this.selectedKey = 'Search';
},
selectedKey() {
return this.selectedKey === 'Search' ? this.focusSearchInput() : null;
},
},
mounted() {
this.focusSearchInput();
},
methods: {
changeCategory(category) {
this.search = '';
this.$refs.emojiItem.scrollTo({ top: 0 });
this.selectedKey = category;
},
getFirstEmojiByCategoryName(categoryName) {
const categoryItem = this.emojis.find(category =>
category.name === categoryName ? category : null
);
return categoryItem ? categoryItem.emojis[0].emoji : '';
},
focusSearchInput() {
this.$nextTick(() => {
this.$refs.searchbar.focus();
});
},
},
};
</script>
<template>
<div
role="dialog"
class="emoji-dialog bg-n-background shadow-lg rounded-md outline outline-1 outline-n-weak box-content h-[18.75rem] absolute right-0 -top-[95px] w-80 z-20"
>
<div class="flex flex-col">
<div class="flex gap-2 m-2 sticky top-2">
<input
ref="searchbar"
v-model="search"
type="text"
class="focus:box-shadow-blue dark:focus:box-shadow-dark !mb-0 !h-8 !text-sm"
:placeholder="$t('EMOJI.PLACEHOLDER')"
/>
<NextButton
v-if="showRemoveButton"
faded
sm
slate
class="flex-shrink-0"
:label="$t('EMOJI.REMOVE')"
@click="onClick('')"
/>
</div>
<div v-if="hasNoSearch" ref="emojiItem" class="emoji-item">
<h5
class="text-sm text-n-slate-12 font-medium leading-normal m-0 py-1 px-2 capitalize"
>
{{ selectedKey }}
</h5>
<div class="emoji--row">
<button
v-for="item in filterEmojisByCategory"
:key="item.slug"
v-dompurify-html="item.emoji"
class="emoji--item"
track-by="$index"
@click="onClick(item.emoji)"
/>
</div>
</div>
<div v-else ref="emojiItem" class="emoji-item">
<div v-for="category in filterAllEmojisBySearch" :key="category.slug">
<h5
v-if="category.emojis.length > 0"
class="text-sm text-n-slate-12 font-medium leading-normal m-0 py-1 px-2 capitalize"
>
{{ category.name }}
</h5>
<div v-if="category.emojis.length > 0" class="emoji--row">
<button
v-for="item in category.emojis"
:key="item.slug"
v-dompurify-html="item.emoji"
class="emoji--item"
track-by="$index"
@click="onClick(item.emoji)"
/>
</div>
</div>
<div
v-if="hasEmptySearchResult"
class="items-center flex flex-col h-[13.25rem] justify-center"
>
<div class="text-n-slate-11 mb-2">
<FluentIcon icon="emoji" size="48" />
</div>
<span class="text-n-slate-11 text-sm font-medium">
{{ $t('EMOJI.NOT_FOUND') }}
</span>
</div>
</div>
<div
class="emoji-dialog--footer relative w-full py-0 rounded-b-[0.34rem] px-1 bg-n-slate-3"
role="menu"
>
<ul
class="flex relative left-[2px] rtl:left-[unset] rtl:right-[2px] list-none m-0 overflow-auto py-1 px-0"
>
<li>
<button
class="emoji--item"
:class="{ active: selectedKey === 'Search' }"
@click="changeCategory('Search')"
>
<FluentIcon icon="search" size="16" class="text-n-slate-11" />
</button>
</li>
<li
v-for="category in categories"
:key="category.slug"
@click="changeCategory(category.name)"
>
<button
v-dompurify-html="getFirstEmojiByCategoryName(category.name)"
class="emoji--item"
:class="{ active: selectedKey === category.name }"
@click="changeCategory(category.name)"
/>
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
.box-shadow-blue {
box-shadow:
0 0 0 1px #1f93ff,
0 0 1px 2px #c7e3ff;
}
.box-shadow-dark {
box-shadow:
0 0 0 1px #212222,
0 0 1px 2px #4c5155;
}
}
</style>
<style lang="scss">
.emoji-dialog {
&::before {
@apply absolute -bottom-3 h-3 w-6 bg-n-slate-3 content-[""];
clip-path: polygon(50% 100%, 0% 0%, 100% 0%);
}
}
.emoji--item {
@apply bg-transparent border-0 rounded cursor-pointer text-lg h-6 m-0 py-0 px-1 hover:bg-n-slate-4;
}
.emoji--row {
@apply box-border p-1;
.emoji--item {
@apply h-[1.625rem] w-[1.625rem] leading-normal m-1;
}
}
.emoji-item {
@apply h-[13.25rem] overflow-y-auto;
}
.emoji-dialog--footer {
ul {
> li {
@apply items-center cursor-pointer flex justify-center p-1;
}
li .active {
@apply bg-n-background;
}
.emoji--item {
@apply items-center flex text-sm;
&:hover {
@apply bg-n-slate-2;
}
}
}
}
</style>
@@ -0,0 +1,99 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { Virtualizer } from 'virtua/vue';
import {
buildEmojiSections,
getEmojiTint,
getRecentEmojis,
addRecentEmoji,
} from 'shared/components/emoji/pickerHelper';
const emit = defineEmits(['select']);
const { t } = useI18n();
const emojiSearch = ref('');
const recentEmojis = ref([]);
const searchInput = ref(null);
onMounted(() => {
recentEmojis.value = getRecentEmojis();
searchInput.value?.focus();
});
const emojiSections = computed(() =>
buildEmojiSections(
emojiSearch.value,
recentEmojis.value,
t('EMOJI_ICON_PICKER.FREQUENTLY_USED')
)
);
// Tints an emoji button with a light shade of the emoji's own color on hover.
const applyEmojiTint = (event, emoji) => {
event.currentTarget.style.setProperty('--ep-tint', getEmojiTint(emoji));
};
const selectEmoji = emoji => {
recentEmojis.value = addRecentEmoji(emoji);
emit('select', { type: 'emoji', value: emoji.emoji, emoji: emoji.emoji });
};
</script>
<template>
<div
role="dialog"
class="absolute z-20 flex flex-col overflow-hidden shadow-xl w-[22rem] bg-n-surface-2 backdrop-blur-[100px] rounded-2xl outline outline-1 outline-n-weak dark:outline-n-strong/50"
>
<div class="flex flex-col gap-1.5 pt-2">
<div class="relative px-2">
<span
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-5"
/>
<input
ref="searchInput"
v-model="emojiSearch"
type="text"
class="block w-full h-10 text-sm reset-base outline outline-1 outline-offset-[-1px] outline-n-weak focus:outline-n-brand border-none rounded-lg !ps-9 !pe-3 !py-2.5 bg-transparent text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_EMOJI')"
/>
</div>
<div
v-if="emojiSections.length"
class="h-60 overflow-y-auto px-2 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<Virtualizer v-slot="{ item: section }" :data="emojiSections">
<h5
class="px-1 pt-2 pb-1 m-0 text-xs font-medium tracking-wide uppercase text-n-slate-10"
>
{{ section.name }}
</h5>
<div class="grid grid-cols-10 gap-0.5">
<button
v-for="emoji in section.emojis"
:key="`${section.name}-${emoji.slug}`"
type="button"
:title="emoji.name"
class="flex items-center justify-center !p-0 w-full max-w-[2rem] text-xl transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
@mouseenter="applyEmojiTint($event, emoji.emoji)"
@click="selectEmoji(emoji)"
>
{{ emoji.emoji }}
</button>
</div>
</Virtualizer>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-2 h-60 text-n-slate-10"
>
<span class="i-lucide-smile-plus size-7" />
<span class="text-sm font-medium">
{{ t('EMOJI_ICON_PICKER.NO_EMOJI') }}
</span>
</div>
</div>
</div>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
import emojiGroups from 'shared/components/emoji/emojisGroup.json';
// Recently used emojis persisted in localStorage.
const RECENT_EMOJI_KEY = 'emoji-icon-picker.recent-emojis';
const MAX_RECENT_EMOJIS = 16;
const matchesSearch = (emoji, term) =>
emoji.slug.replaceAll('_', ' ').includes(term) ||
emoji.name.toLowerCase().includes(term);
// Emoji sections for the search term; prepends "Frequently used" when idle.
export const buildEmojiSections = (search, recentEmojis, frequentLabel) => {
const term = search.trim().toLowerCase();
if (term) {
return emojiGroups
.map(({ name, emojis }) => ({
name,
emojis: emojis.filter(e => matchesSearch(e, term)),
}))
.filter(group => group.emojis.length);
}
return [
...(recentEmojis.length
? [{ name: frequentLabel, emojis: recentEmojis }]
: []),
...emojiGroups,
];
};
// Samples an emoji's average color (via canvas) as a translucent hover tint. Cached per emoji.
const emojiTintCache = new Map();
const NEUTRAL_TINT = 'rgb(var(--slate-9) / 0.12)';
const sampleEmojiTint = emoji => {
const canvas = Object.assign(document.createElement('canvas'), {
width: 16,
height: 16,
});
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.font = '14px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(emoji, 8, 9);
const { data } = ctx.getImageData(0, 0, 16, 16);
const pixels = Array.from(
{ length: data.length / 4 },
(_, i) => i * 4
).filter(i => data[i + 3] > 16);
if (!pixels.length) return NEUTRAL_TINT;
const avg = offset =>
Math.round(
pixels.reduce((sum, i) => sum + data[i + offset], 0) / pixels.length
);
return `rgba(${avg(0)}, ${avg(1)}, ${avg(2)}, 0.16)`;
};
export const getEmojiTint = emoji => {
if (!emojiTintCache.has(emoji)) {
let tint = NEUTRAL_TINT;
try {
tint = sampleEmojiTint(emoji);
} catch {
/* canvas unavailable */
}
emojiTintCache.set(emoji, tint);
}
return emojiTintCache.get(emoji);
};
export const getRecentEmojis = () => {
try {
const stored = JSON.parse(localStorage.getItem(RECENT_EMOJI_KEY) ?? '[]');
return Array.isArray(stored) ? stored : [];
} catch {
return [];
}
};
export const addRecentEmoji = emoji => {
const updated = [
emoji,
...getRecentEmojis().filter(item => item.slug !== emoji.slug),
].slice(0, MAX_RECENT_EMOJIS);
try {
localStorage.setItem(RECENT_EMOJI_KEY, JSON.stringify(updated));
} catch {
/* private mode; recents are best-effort */
}
return updated;
};
@@ -272,6 +272,9 @@ blockquote {
--gray-12: 32 32 32;
--background-color: 253 253 253;
--surface-1: 254 254 254;
--surface-2: 255 255 255;
--surface-active: 255 255 255;
--text-blue: 8 109 224;
--border-container: 236 236 236;
--border-strong: 235 235 235;
@@ -379,6 +382,9 @@ blockquote {
--gray-12: 238 238 238;
--background-color: 18 18 19;
--surface-1: 20 21 23;
--surface-2: 22 23 26;
--surface-active: 53 57 66;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
@@ -1,4 +1,5 @@
<script>
import { defineAsyncComponent } from 'vue';
import { mapGetters } from 'vuex';
import ChatAttachmentButton from 'widget/components/ChatAttachment.vue';
@@ -7,14 +8,16 @@ import { useAttachments } from '../composables/useAttachments';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import EmojiInput from 'shared/components/emoji/EmojiInput.vue';
const EmojiPicker = defineAsyncComponent(
() => import('shared/components/emoji/EmojiPicker.vue')
);
export default {
name: 'ChatInputWrap',
components: {
ChatAttachmentButton,
ChatSendButton,
EmojiInput,
EmojiPicker,
FluentIcon,
ResizableTextArea,
},
@@ -110,6 +113,9 @@ export default {
emojiOnClick(emoji) {
this.userInput = `${this.userInput}${emoji} `;
},
onSelectEmoji({ value }) {
this.emojiOnClick(value);
},
onTypingOff() {
this.toggleTyping('off');
},
@@ -148,7 +154,7 @@ export default {
@focus="onFocus"
@blur="onBlur"
/>
<div class="flex items-center ltr:pl-2 rtl:pr-2">
<div class="relative flex items-center ltr:pl-2 rtl:pr-2">
<ChatAttachmentButton
v-if="showAttachment"
class="text-n-slate-12"
@@ -169,10 +175,11 @@ export default {
}"
/>
</button>
<EmojiInput
<EmojiPicker
v-if="shouldShowEmojiPicker && showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:on-click="emojiOnClick"
class="!bottom-full end-0 mb-2 max-w-[calc(100vw-3rem)]"
@select="onSelectEmoji"
@keydown.esc="hideEmojiPicker"
/>
<ChatSendButton
@@ -185,10 +192,6 @@ export default {
</template>
<style scoped lang="scss">
.emoji-dialog {
@apply max-w-full ltr:right-5 rtl:right-[unset] rtl:left-5 -top-[302px] before:ltr:right-2.5 before:rtl:right-[unset] before:rtl:left-2.5;
}
.user-message-input {
@apply border-none outline-none w-full placeholder:text-n-slate-10 resize-none h-8 min-h-8 max-h-60 py-1 px-0 my-2 bg-n-background text-n-slate-12 transition-all duration-200;
}
@@ -106,6 +106,11 @@
"NOT_FOUND": "No emoji match your search",
"ARIA_LABEL": "Emoji picker"
},
"EMOJI_ICON_PICKER": {
"SEARCH_EMOJI": "Search emoji…",
"FREQUENTLY_USED": "Frequently used",
"NO_EMOJI": "No emoji match your search"
},
"CSAT": {
"TITLE": "Rate your conversation",
"SUBMITTED_TITLE": "Thank you for submitting the rating",
+3 -1
View File
@@ -148,7 +148,7 @@ class Contact < ApplicationRecord
end
def push_event_data
{
data = {
additional_attributes: additional_attributes,
custom_attributes: custom_attributes,
email: email,
@@ -160,6 +160,8 @@ class Contact < ApplicationRecord
blocked: blocked,
type: 'contact'
}
data[:company_id] = company_id if account.feature_enabled?('companies')
data
end
def webhook_data
@@ -1,6 +1,7 @@
class Twilio::IncomingMessageService
include ::FileTypeHelper
include ::Twilio::WhatsappIdentifierHelper
include ::Twilio::ReferralParamsHelper
pattr_initialize [:params!]
@@ -15,7 +16,8 @@ class Twilio::IncomingMessageService
inbox_id: @inbox.id,
message_type: :incoming,
sender: @contact,
source_id: params[:SmsSid]
source_id: params[:SmsSid],
content_attributes: message_content_attributes
)
attach_files
attach_location if location_message?
@@ -0,0 +1,28 @@
module Twilio::ReferralParamsHelper
REFERRAL_PARAM_MAPPING = {
source_id: :ReferralSourceId,
source_type: :ReferralSourceType,
source_url: :ReferralSourceUrl,
headline: :ReferralHeadline,
body: :ReferralBody,
media_id: :ReferralMediaId,
media_content_type: :ReferralMediaContentType,
media_url: :ReferralMediaUrl,
num_media: :ReferralNumMedia,
ctwa_clid: :ReferralCtwaClid
}.freeze
def message_content_attributes
referral_attributes.present? ? { referral: referral_attributes } : {}
end
def referral_attributes
return {} unless twilio_channel.whatsapp?
return {} if params[:ReferralSourceId].blank?
REFERRAL_PARAM_MAPPING.each_with_object({}) do |(attribute, param_key), result|
value = params[param_key]
result[attribute] = value if value.present?
end
end
end
@@ -91,7 +91,7 @@ class Whatsapp::IncomingMessageBaseService
def create_contact_messages(message)
message['contacts'].each do |contact|
# Pass source_id from parent message since contact objects don't have :id
create_message(contact, source_id: message[:id])
create_message(contact, source_id: message[:id], content_attributes_source: message)
attach_contact(contact)
@message.save!
end
@@ -158,10 +158,7 @@ class Whatsapp::IncomingMessageBaseService
)
end
def create_message(message, source_id: nil)
content_attrs = outgoing_echo ? { external_echo: true } : {}
content_attrs[:in_reply_to_external_id] = @in_reply_to_external_id if @in_reply_to_external_id.present?
def create_message(message, source_id: nil, content_attributes_source: message)
@message = @conversation.messages.build(
content: message_content(message),
account_id: @inbox.account_id,
@@ -171,10 +168,18 @@ class Whatsapp::IncomingMessageBaseService
status: outgoing_echo ? :delivered : :sent,
sender: outgoing_echo ? nil : @contact,
source_id: (source_id || message[:id]).to_s,
content_attributes: content_attrs
content_attributes: message_content_attributes(content_attributes_source)
)
end
def message_content_attributes(message)
content_attrs = outgoing_echo ? { external_echo: true } : {}
content_attrs[:in_reply_to_external_id] = @in_reply_to_external_id if @in_reply_to_external_id.present?
referral_content_attrs = referral_attributes(message)
content_attrs[:referral] = referral_content_attrs if referral_content_attrs.present?
content_attrs
end
def attach_contact(contact)
phones = contact[:phones]
phones = [{ phone: 'Phone number is not available' }] if phones.blank?
@@ -71,6 +71,12 @@ module Whatsapp::IncomingMessageServiceHelpers
@in_reply_to_external_id = message['context']&.[]('id')
end
def referral_attributes(message)
return {} if outgoing_echo
message[:referral]&.to_h&.deep_stringify_keys || {}
end
def find_message_by_source_id(source_id)
return unless source_id
@@ -14,6 +14,8 @@ json.category do
json.name article.category&.name
json.slug article.category&.slug
json.locale article.category&.locale
json.icon article.category&.icon
json.icon_color article.category&.icon_color
end
json.views article.views
@@ -6,6 +6,7 @@ json.description category.description
json.position category.position
json.account_id category.account_id
json.icon category.icon
json.icon_color category.icon_color
json.related_categories do
if category.related_categories.any?
@@ -6,6 +6,7 @@ json.name resource.name
json.phone_number resource.phone_number
json.blocked resource.blocked
json.identifier resource.identifier
json.company_id resource.company_id if Current.account&.feature_enabled?('companies')
json.thumbnail resource.avatar_url
json.custom_attributes resource.custom_attributes
json.last_activity_at resource.last_activity_at.to_i if resource[:last_activity_at].present?
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="<%= I18n.locale %>">
<html lang="<%= html_lang_attribute(I18n.locale) %>">
<head>
<%= render 'layouts/portal_head' %>
</head>
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="<%= I18n.locale %>">
<html lang="<%= html_lang_attribute(I18n.locale) %>">
<head>
<%= render 'layouts/portal_head' %>
</head>
@@ -13,7 +13,7 @@
<div class="flex flex-col items-start gap-1">
<div class="flex flex-row items-center gap-2 <%= !@is_plain_layout_enabled && 'px-1' %>">
<% if category.icon.present? %>
<span class="text-lg rounded-md cursor-pointer <%= !@is_plain_layout_enabled && 'pl-1' %>"><%= category.icon %></span>
<span class="text-lg rounded-md cursor-pointer inline-flex <%= !@is_plain_layout_enabled && 'pl-1' %>"><%= render_emoji_or_icon(category.icon, category.icon_color) %></span>
<% end %>
<h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : '' %> <%= category.icon.blank? && !@is_plain_layout_enabled ? 'pl-1' : '' %>">
<a href="<%= generate_category_link(category_link_params) %>">
@@ -12,7 +12,7 @@
<div class="flex justify-start flex-col items-start w-full gap-2">
<div class="flex flex-col gap-2">
<% if category.icon.present? %>
<span class="text-4xl"><%= category.icon %></span>
<span class="text-4xl"><%= render_emoji_or_icon(category.icon, category.icon_color) %></span>
<% end %>
<h1 class="text-3xl font-bold tracking-wide leading-[52.5px] text-slate-900 dark:text-white">
<%= category.name %>
@@ -3,7 +3,7 @@
class="group flex flex-col p-5 rounded-xl border border-solid border-n-weak hover:border-n-portal hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
<% if show_category && article.category %>
<span class="inline-flex items-center gap-1 self-start text-xs font-medium text-n-slate-11 mb-3">
<% if article.category.icon.present? %><span><%= article.category.icon %></span><% end %>
<% if article.category.icon.present? %><span><%= render_emoji_or_icon(article.category.icon, article.category.icon_color) %></span><% end %>
<%= article.category.name %>
</span>
<% end %>
@@ -14,12 +14,12 @@
href="<%= public_portal_category_path(portal.slug, category.locale, category.slug) %>"
class="inline-flex items-center gap-1.5 hover:text-n-slate-12 transition"
>
<% if category.icon.present? %><span><%= category.icon %></span><% end %>
<% if category.icon.present? %><span><%= render_emoji_or_icon(category.icon, category.icon_color) %></span><% end %>
<%= category.name %>
</a>
<% else %>
<span class="inline-flex items-center gap-1.5 text-n-slate-12 font-medium truncate">
<% if category.icon.present? %><span><%= category.icon %></span><% end %>
<% if category.icon.present? %><span class="inline-flex flex-shrink-0"><%= render_emoji_or_icon(category.icon, category.icon_color) %></span><% end %>
<%= category.name %>
</span>
<% end %>
@@ -3,7 +3,7 @@
<a href="<%= public_portal_category_path(portal.slug, category.locale, category.slug) %>"
class="group relative flex flex-col p-5 rounded-xl border border-solid border-n-weak hover:border-n-portal hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
<span class="flex-shrink-0 w-11 h-11 flex items-center justify-center text-2xl rounded-xl bg-n-slate-2 border border-solid border-n-weak">
<%= category.icon.presence || '📁' %>
<% if category.icon.present? %><%= render_emoji_or_icon(category.icon, category.icon_color) %><% else %>📁<% end %>
</span>
<h3 class="mt-4 text-lg font-620 tracking-tight text-n-slate-12 group-hover:text-n-portal transition"><%= category.name %></h3>
<% if category.description.present? %>
@@ -21,7 +21,7 @@
<% popular_topics.each do |category| %>
<a href="<%= public_portal_category_path(portal.slug, category.locale, category.slug) %>"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-white/60 dark:bg-n-slate-1/60 backdrop-blur border border-solid border-n-weak text-n-slate-11 hover:text-n-portal hover:border-n-portal transition">
<% if category.icon.present? %><span class="text-xs"><%= category.icon %></span><% end %>
<% if category.icon.present? %><span class="inline-flex items-center text-xs leading-none"><%= render_emoji_or_icon(category.icon, category.icon_color) %></span><% end %>
<%= category.name %>
</a>
<% end %>
@@ -39,7 +39,7 @@
<% end %>
<summary class="flex items-center gap-2 px-3 py-2 text-sm rounded-md cursor-pointer list-none select-none <%= is_active ? 'text-n-portal font-semibold' : 'text-n-slate-12 font-medium' %> hover:bg-n-alpha-2 transition">
<% if sidebar_category.icon.present? %>
<span class="text-base leading-none w-4 flex items-center justify-center"><%= sidebar_category.icon %></span>
<span class="text-base leading-none w-4 flex items-center justify-center"><%= render_emoji_or_icon(sidebar_category.icon, sidebar_category.icon_color) %></span>
<% else %>
<span class="i-lucide-folder size-4 text-n-slate-10" aria-hidden="true"></span>
<% end %>
@@ -15,7 +15,7 @@
<header class="mb-8">
<div class="flex items-center gap-4">
<% if category.icon.present? %>
<span class="shrink-0 flex items-center justify-center w-14 h-14 text-3xl rounded-2xl bg-n-slate-1 border border-solid border-n-weak shadow-sm"><%= category.icon %></span>
<span class="shrink-0 flex items-center justify-center w-14 h-14 text-3xl rounded-2xl bg-n-slate-1 border border-solid border-n-weak shadow-sm"><%= render_emoji_or_icon(category.icon, category.icon_color) %></span>
<% end %>
<div class="min-w-0">
<h1 class="text-3xl leading-snug font-620 tracking-tight text-n-slate-12 text-balance"><%= category.name %></h1>
@@ -0,0 +1,5 @@
class AddIconColorToCategories < ActiveRecord::Migration[7.1]
def change
add_column :categories, :icon_color, :string, default: ''
end
end
+1
View File
@@ -428,6 +428,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
t.bigint "parent_category_id"
t.bigint "associated_category_id"
t.string "icon", default: ""
t.string "icon_color", default: ""
t.index ["associated_category_id"], name: "index_categories_on_associated_category_id"
t.index ["locale", "account_id"], name: "index_categories_on_locale_and_account_id"
t.index ["locale"], name: "index_categories_on_locale"
@@ -0,0 +1,17 @@
module Enterprise::Api::V1::Accounts::ContactsController
private
def permitted_params
params_with_company_id = super
return params_with_company_id unless Current.account.feature_enabled?('companies')
return params_with_company_id unless params.key?(:company_id)
params_with_company_id.merge(company_id: permitted_company_id)
end
def permitted_company_id
return nil if params[:company_id].blank?
Current.account.companies.find(params[:company_id]).id
end
end
@@ -6,6 +6,7 @@ module Enterprise::Concerns::Contact
after_commit :associate_company_from_email,
on: [:create, :update],
if: :should_associate_company?
before_save :sync_company_name_from_company, if: :will_save_change_to_company_id?
after_update_commit :record_company_activity, if: :saved_change_to_last_activity_at?
end
@@ -33,4 +34,14 @@ module Enterprise::Concerns::Contact
def record_company_activity
company&.record_activity_at!(last_activity_at) if last_activity_at.present?
end
def sync_company_name_from_company
self.additional_attributes ||= {}
if company_id.present?
additional_attributes['company_name'] = company&.name
else
additional_attributes.delete('company_name')
end
end
end
@@ -6,7 +6,7 @@ class Contacts::CompanyAssociationService
if company
# rubocop:disable Rails/SkipsModelValidations
# Using update_column and increment_counter to avoid triggering callbacks while maintaining counter cache
contact.update_column(:company_id, company.id)
contact.update_columns(company_id: company.id, additional_attributes: contact_attributes_with_company_name(contact, company))
Company.increment_counter(:contacts_count, company.id)
# rubocop:enable Rails/SkipsModelValidations
company.record_activity_at!(contact.last_activity_at) if contact.last_activity_at.present?
@@ -45,4 +45,8 @@ class Contacts::CompanyAssociationService
def derive_company_name(contact, domain)
contact.additional_attributes&.dig('company_name') || domain.split('.').first.tr('-_', ' ').titleize
end
def contact_attributes_with_company_name(contact, company)
(contact.additional_attributes || {}).merge('company_name' => company.name)
end
end
@@ -112,15 +112,15 @@ RSpec.describe 'Contacts API', type: :request do
end
it 'returns all contacts with company name asc order with null values at last' do
contact_3
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=-company_name",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].first['email']).to eq(contact_1.email)
expect(response_body['payload'].first['id']).to eq(contact_1.id)
expect(response_body['payload'].last['email']).to eq(contact_4.email)
expect(response_body['payload'].first(2).pluck('id')).to contain_exactly(contact.id, contact_1.id)
expect(response_body['payload'].last(2).pluck('id')).to contain_exactly(contact_3.id, contact_4.id)
end
it 'returns all contacts with country name desc order with null values at last' do
@@ -13,7 +13,15 @@ RSpec.describe 'Twilio::CallbacksController', type: :request do
'SmsSid' => 'SM123',
'ExternalUserId' => 'IN.2081978709342942',
'ParentExternalUserId' => 'IN.ENT.9081726354',
'ProfileUsername' => 'muhsin'
'ProfileUsername' => 'muhsin',
'ReferralCtwaClid' => 'AfjyUDlaIoiweZDnlzmDTEaG',
'ReferralSourceId' => '120237244350960485',
'ReferralSourceUrl' => 'https://fb.me/4tBfhWhjr',
'ReferralSourceType' => 'ad',
'ReferralHeadline' => 'German citizenship lawyer',
'ReferralBody' => 'Fast-track your German citizenship',
'ReferralMediaId' => '',
'ReferralNumMedia' => '0'
}
end
@@ -3,7 +3,10 @@ require 'rails_helper'
RSpec.describe 'Company contacts API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, name: 'Acme', account: account) }
let(:company) do
create(:company, name: 'Acme', domain: 'acme.com', description: 'Primary account', account: account,
custom_attributes: { 'industry' => 'Manufacturing' })
end
before { account.enable_features!(:companies) }
@@ -19,8 +22,16 @@ RSpec.describe 'Company contacts API', type: :request do
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].pluck('id')).to eq([linked_contact.id])
expect(response_body['payload'].first['company_id']).to eq(company.id)
expect(response_body['payload'].first['linked_to_current_company']).to be true
contact_payload = response_body['payload'].first
expect(contact_payload['company_id']).to eq(company.id)
expect(contact_payload['linked_to_current_company']).to be true
expect(contact_payload['company']).to include(
'id' => company.id,
'name' => 'Acme',
'domain' => 'acme.com',
'description' => 'Primary account',
'custom_attributes' => { 'industry' => 'Manufacturing' }
)
expect(response_body['meta']['total_count']).to eq(1)
end
end
@@ -38,9 +49,14 @@ RSpec.describe 'Company contacts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
contact_ids = response.parsed_body['payload'].pluck('id')
response_payload = response.parsed_body['payload']
contact_ids = response_payload.pluck('id')
expect(contact_ids).to contain_exactly(available_contact.id, assigned_contact.id)
expect(contact_ids).not_to include(linked_contact.id)
expect(response_payload.find { |contact| contact['id'] == assigned_contact.id }['company']).to include(
'id' => other_company.id,
'name' => 'Other Company'
)
end
end
@@ -56,7 +72,7 @@ RSpec.describe 'Company contacts API', type: :request do
expect(response).to have_http_status(:success)
expect(contact.reload.company_id).to eq(company.id)
expect(contact.additional_attributes).to eq('city' => 'Berlin')
expect(contact.additional_attributes).to eq('city' => 'Berlin', 'company_name' => 'Acme')
expect(response.parsed_body['payload']['company_id']).to eq(company.id)
expect(response.parsed_body['payload']['linked_to_current_company']).to be true
expect(company.reload.last_activity_at).to be_within(1.second).of(contact.last_activity_at)
@@ -74,7 +90,7 @@ RSpec.describe 'Company contacts API', type: :request do
expect(response).to have_http_status(:ok)
expect(contact.reload.company_id).to be_nil
expect(contact.additional_attributes).to eq('company_name' => 'Acme', 'city' => 'Berlin')
expect(contact.additional_attributes).to eq('city' => 'Berlin')
end
end
end
@@ -0,0 +1,24 @@
require 'rails_helper'
RSpec.describe 'Enterprise Contacts API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
before { account.enable_features!(:companies) }
describe 'PATCH /api/v1/accounts/{account.id}/contacts/:id' do
it 'updates company association' do
company = create(:company, account: account, name: 'Acme')
contact = create(:contact, account: account)
patch "/api/v1/accounts/#{account.id}/contacts/#{contact.id}",
headers: admin.create_new_auth_token,
params: { company_id: company.id },
as: :json
expect(response).to have_http_status(:success)
expect(contact.reload.company).to eq(company)
expect(contact.additional_attributes['company_name']).to eq('Acme')
end
end
end
@@ -67,4 +67,20 @@ RSpec.describe Contact, type: :model do
end
end
end
describe '#push_event_data' do
let(:account) { create(:account) }
let(:company) { create(:company, account: account) }
let(:contact) { create(:contact, account: account, company: company) }
it 'includes company_id when companies feature is enabled' do
account.enable_features!(:companies)
expect(contact.push_event_data[:company_id]).to eq(company.id)
end
it 'does not include company_id when companies feature is disabled' do
expect(contact.push_event_data).not_to have_key(:company_id)
end
end
end
@@ -25,6 +25,7 @@ RSpec.describe Contacts::CompanyAssociationService, type: :service do
expect(contact.company).to be_present
expect(contact.company.domain).to eq('acme.com')
expect(contact.company.name).to eq('Acme')
expect(contact.additional_attributes['company_name']).to eq('Acme')
end
it 'reuses existing company with same domain' do
+14 -21
View File
@@ -1,31 +1,24 @@
require 'rails_helper'
describe PortalHelper do
describe '#generate_portal_bg_color' do
context 'when theme is dark' do
it 'returns the correct color mix with black' do
expect(helper.generate_portal_bg_color('#ff0000', 'dark')).to eq(
'color-mix(in srgb, #ff0000 20%, black)'
)
end
describe '#html_lang_attribute' do
it 'returns the locale unchanged when it has no region suffix' do
expect(helper.html_lang_attribute('en')).to eq('en')
expect(helper.html_lang_attribute(:fr)).to eq('fr')
end
context 'when theme is not dark' do
it 'returns the correct color mix with white' do
expect(helper.generate_portal_bg_color('#ff0000', 'light')).to eq(
'color-mix(in srgb, #ff0000 20%, white)'
)
end
it 'converts underscores to hyphens for BCP 47 compliance' do
expect(helper.html_lang_attribute('pt_BR')).to eq('pt-BR')
expect(helper.html_lang_attribute(:zh_CN)).to eq('zh-CN')
expect(helper.html_lang_attribute('zh_TW')).to eq('zh-TW')
end
context 'when provided with various colors' do
it 'adjusts the color mix appropriately' do
expect(helper.generate_portal_bg_color('#00ff00', 'dark')).to eq(
'color-mix(in srgb, #00ff00 20%, black)'
)
expect(helper.generate_portal_bg_color('#0000ff', 'light')).to eq(
'color-mix(in srgb, #0000ff 20%, white)'
)
it 'produces a valid BCP 47 lang attribute for every language shown in the UI' do
bcp47 = /\A[a-z]{2,3}(-[A-Z]{2})?\z/
LANGUAGES_CONFIG.each_value do |lang|
locale = lang[:iso_639_1_code]
expect(helper.html_lang_attribute(locale)).to match(bcp47)
end
end
end
+3 -1
View File
@@ -74,8 +74,10 @@ RSpec.describe SlackUnfurlJob do
end
context 'when another account URL is shared' do
let(:another_account) { create(:account) }
before do
link_shared[:event][:links][0][:url] = 'https://qa.chatwoot.com/app/accounts/123/conversations/123'
link_shared[:event][:links][0][:url] = "https://qa.chatwoot.com/app/accounts/#{another_account.id}/conversations/123"
end
it 'does not unfurl' do
@@ -511,6 +511,60 @@ describe Twilio::IncomingMessageService do
end
end
describe 'When the incoming WhatsApp message has CTWA referral parameters' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'stores normalized referral attributes on the message' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+491741763110',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Hallo! Kann ich hierzu mehr Informationen erhalten?',
ReferralCtwaClid: 'AfjyUDlaIoiweZDnlzmDTEaG',
ReferralSourceId: '120237244350960485',
ReferralSourceUrl: 'https://fb.me/4tBfhWhjr',
ReferralSourceType: 'ad',
ReferralHeadline: 'German citizenship lawyer',
ReferralBody: 'Fast-track your German citizenship',
ReferralMediaId: '',
ReferralNumMedia: '0'
}
described_class.new(params: params).perform
message = whatsapp_twilio_channel.inbox.messages.last
expect(message.content_attributes['referral']).to eq(
'ctwa_clid' => 'AfjyUDlaIoiweZDnlzmDTEaG',
'source_id' => '120237244350960485',
'source_url' => 'https://fb.me/4tBfhWhjr',
'source_type' => 'ad',
'headline' => 'German citizenship lawyer',
'body' => 'Fast-track your German citizenship',
'num_media' => '0'
)
end
it 'does not add referral attributes when ReferralSourceId is absent' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+491741763110',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Regular WhatsApp message',
ReferralCtwaClid: 'AfjyUDlaIoiweZDnlzmDTEaG'
}
described_class.new(params: params).perform
message = whatsapp_twilio_channel.inbox.messages.last
expect(message.content_attributes).not_to have_key('referral')
end
end
describe 'When the incoming number is a Brazilian number in new format with 9 included' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
@@ -234,6 +234,88 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
end
end
context 'when message contains referral data' do
let(:referral_params) do
{
phone_number: whatsapp_channel.phone_number,
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
contacts: [{ profile: { name: 'Mom' }, wa_id: '255718573302', user_id: 'TZ.1040042605869930' }],
messages: [{
referral: {
source_url: 'https://fb.me/3TYpooaRT',
source_id: '52558118838064',
source_type: 'ad',
body: 'washa data tu',
headline: 'Diana Digital',
media_type: 'video',
video_url: 'https://www.facebook.com/reel/1438165771395493/',
thumbnail_url: 'https://scontent.xx.fbcdn.net/sample.jpg',
ctwa_clid: 'AfhcQdP2E4A8wWpeb1FqUzUi',
welcome_message: {
text: 'Hi! Please let us know how we can help you.'
}
},
from: '255718573302',
from_user_id: 'TZ.1040042605869930',
id: 'wamid.CTWA_REFERRAL_MESSAGE',
timestamp: '1780649766',
text: { body: 'Hello nielekeze' },
type: 'text'
}]
}
}]
}]
}.with_indifferent_access
end
it 'stores the referral payload in message content attributes' do
described_class.new(inbox: whatsapp_channel.inbox, params: referral_params).perform
message = whatsapp_channel.inbox.messages.last
expect(message.content).to eq('Hello nielekeze')
expect(message.content_attributes['referral']).to include(
'source_url' => 'https://fb.me/3TYpooaRT',
'source_id' => '52558118838064',
'source_type' => 'ad',
'body' => 'washa data tu',
'headline' => 'Diana Digital',
'media_type' => 'video',
'video_url' => 'https://www.facebook.com/reel/1438165771395493/',
'thumbnail_url' => 'https://scontent.xx.fbcdn.net/sample.jpg',
'ctwa_clid' => 'AfhcQdP2E4A8wWpeb1FqUzUi',
'welcome_message' => { 'text' => 'Hi! Please let us know how we can help you.' }
)
end
it 'preserves the referral payload when the message contains contacts' do
contacts_referral_params = referral_params.deep_dup
parent_message = contacts_referral_params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
parent_message[:type] = 'contacts'
parent_message.delete(:text)
parent_message[:contacts] = [{
name: {
formatted_name: 'Diana Digital',
first_name: 'Diana',
last_name: 'Digital'
},
phones: [{ phone: '+255718573302' }]
}]
described_class.new(inbox: whatsapp_channel.inbox, params: contacts_referral_params).perform
message = whatsapp_channel.inbox.messages.last
expect(message.content).to eq('Diana Digital')
expect(message.content_attributes['referral']).to include(
'source_id' => '52558118838064',
'headline' => 'Diana Digital',
'ctwa_clid' => 'AfhcQdP2E4A8wWpeb1FqUzUi'
)
end
end
context 'when message is a reply (has context)' do
let(:reply_params) do
{