Merge branch 'develop' into feature/#7907-script-to-change-local-storage-to-aws
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
+16
-10
@@ -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
|
||||
|
||||
+21
-13
@@ -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,
|
||||
}))
|
||||
);
|
||||
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+44
-13
@@ -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>
|
||||
|
||||
+1
@@ -106,6 +106,7 @@ const breadcrumbItems = computed(() => {
|
||||
}
|
||||
),
|
||||
emoji: selectedCategoryEmoji.value,
|
||||
iconColor: selectedCategory.value?.icon_color,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
|
||||
+1
@@ -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"
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+6
-4
@@ -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 => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user