Merge branch 'develop' into codex/cw-7519-intercom-stalled-retry-15m
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
|
||||
def show
|
||||
set_branded_email_layout
|
||||
end
|
||||
|
||||
def update
|
||||
unless Current.account.feature_enabled?(:branded_email_templates)
|
||||
render_could_not_create_error('Branded email templates feature is not enabled')
|
||||
return
|
||||
end
|
||||
|
||||
branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
|
||||
EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout)
|
||||
set_branded_email_layout
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.record.errors.full_messages.join(', '))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_branded_email_layout
|
||||
@branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController')
|
||||
@@ -45,11 +45,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def update
|
||||
inbox_params = permitted_params.except(:channel, :csat_config)
|
||||
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
|
||||
@inbox.update!(inbox_params)
|
||||
update_inbox_working_hours
|
||||
update_channel if channel_update_required?
|
||||
continue_update = false
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
continue_update = update_branded_email_layout
|
||||
raise ActiveRecord::Rollback unless continue_update
|
||||
|
||||
inbox_params = permitted_params.except(:channel, :csat_config)
|
||||
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
|
||||
@inbox.update!(inbox_params)
|
||||
update_inbox_working_hours
|
||||
update_channel if channel_update_required?
|
||||
end
|
||||
|
||||
return unless continue_update
|
||||
end
|
||||
|
||||
def agent_bot
|
||||
@@ -155,6 +164,34 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
formatted['template'] = config['template'] if config['template'].present?
|
||||
end
|
||||
|
||||
def update_branded_email_layout
|
||||
return true unless params.key?(:branded_email_layout)
|
||||
|
||||
branded_email_layout = normalized_branded_email_layout
|
||||
|
||||
unless Current.account.feature_enabled?(:branded_email_templates)
|
||||
return true if branded_email_layout.blank?
|
||||
|
||||
render_could_not_create_error('Branded email templates feature is not enabled')
|
||||
return false
|
||||
end
|
||||
|
||||
unless @inbox.email?
|
||||
return true if branded_email_layout.blank?
|
||||
|
||||
render_could_not_create_error('Branded email layout is only supported for email inboxes')
|
||||
return false
|
||||
end
|
||||
|
||||
@inbox.update_branded_email_layout!(branded_email_layout)
|
||||
true
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.record.errors.full_messages.join(', '))
|
||||
false
|
||||
end
|
||||
|
||||
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
|
||||
|
||||
def inbox_attributes
|
||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||
|
||||
@@ -81,7 +81,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
:name, :page_title, :slug, :archived,
|
||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
|
||||
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
|
||||
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
|
||||
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
|
||||
)
|
||||
end
|
||||
|
||||
@@ -89,6 +90,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
params.dig(:portal, :config, :locale_translations)&.keys || []
|
||||
end
|
||||
|
||||
def popular_content_keys
|
||||
params.dig(:portal, :config, :popular_content)&.keys || []
|
||||
end
|
||||
|
||||
def live_chat_widget_params
|
||||
permitted_params = params.permit(:inbox_id)
|
||||
return {} unless permitted_params.key?(:inbox_id)
|
||||
|
||||
@@ -4,17 +4,53 @@ module PortalHomeData
|
||||
private
|
||||
|
||||
def load_home_data
|
||||
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
|
||||
load_recommended_content
|
||||
# The classic hero only needs the recommendations above; the rest is
|
||||
# documentation-layout home data (also used on custom-domain home pages).
|
||||
return unless @portal.layout == 'documentation'
|
||||
|
||||
@visible_categories = @portal.categories
|
||||
.where(locale: @locale)
|
||||
.joins(:articles).where(articles: { status: :published })
|
||||
.order(position: :asc)
|
||||
.group('categories.id')
|
||||
@popular_topics = @visible_categories.first(3)
|
||||
@popular_topics = @recommended_categories.presence || @visible_categories.first(3)
|
||||
@featured = base_articles.order_by_views.limit(6)
|
||||
@category_contributors = build_category_contributors(@visible_categories)
|
||||
end
|
||||
|
||||
def load_recommended_content
|
||||
@recommended_categories = recommended_categories
|
||||
@recommended_articles = recommended_articles
|
||||
end
|
||||
|
||||
def base_articles
|
||||
@base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category)
|
||||
end
|
||||
|
||||
# Admin-recommended categories for the locale, in the chosen order. Unlike the
|
||||
# position-based fallback, published articles aren't required: the admin's pick wins.
|
||||
def recommended_categories
|
||||
ids = @portal.popular_category_ids(@locale)
|
||||
ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids)
|
||||
end
|
||||
|
||||
# Admin-recommended articles for the locale, in the chosen order, limited to
|
||||
# published articles that still exist.
|
||||
def recommended_articles
|
||||
ids = @portal.popular_article_ids(@locale)
|
||||
ordered_by_ids(base_articles.where(id: ids), ids)
|
||||
end
|
||||
|
||||
# Loads the scope and returns its records ordered to match `ids`, dropping any
|
||||
# that no longer exist. Skips the query entirely when `ids` is blank.
|
||||
def ordered_by_ids(scope, ids)
|
||||
return [] if ids.blank?
|
||||
|
||||
by_id = scope.index_by(&:id)
|
||||
ids.filter_map { |id| by_id[id] }
|
||||
end
|
||||
|
||||
def build_category_contributors(categories)
|
||||
category_ids = categories.map(&:id)
|
||||
return {} if category_ids.empty?
|
||||
|
||||
@@ -64,10 +64,8 @@ class DashboardController < ActionController::Base
|
||||
return unless @portal
|
||||
|
||||
@locale = @portal.default_locale
|
||||
if @portal.layout == 'documentation'
|
||||
request.variant = :documentation
|
||||
load_home_data
|
||||
end
|
||||
request.variant = :documentation if @portal.layout == 'documentation'
|
||||
load_home_data
|
||||
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
|
||||
end
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
||||
before_action :set_portal_layout
|
||||
before_action :set_view_variant
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
|
||||
before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled }
|
||||
layout 'portal'
|
||||
|
||||
def show
|
||||
|
||||
@@ -2,4 +2,18 @@ class InboxDrop < BaseDrop
|
||||
def name
|
||||
@obj.try(:name)
|
||||
end
|
||||
|
||||
def business_name
|
||||
@obj.try(:sanitized_business_name)
|
||||
end
|
||||
|
||||
def avatar_url
|
||||
@obj.try(:avatar_url)
|
||||
end
|
||||
|
||||
def email
|
||||
return unless @obj.try(:email?)
|
||||
|
||||
@obj.try(:email_address).presence || @obj.try(:channel).try(:email)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,6 +17,7 @@ class ArticlesAPI extends PortalsAPI {
|
||||
categorySlug,
|
||||
sort,
|
||||
query,
|
||||
signal,
|
||||
}) {
|
||||
const url = getArticleSearchURL({
|
||||
pageNumber,
|
||||
@@ -30,7 +31,7 @@ class ArticlesAPI extends PortalsAPI {
|
||||
host: this.url,
|
||||
});
|
||||
|
||||
return axios.get(url);
|
||||
return axios.get(url, { signal });
|
||||
}
|
||||
|
||||
searchArticles({ portalSlug, query }) {
|
||||
|
||||
@@ -37,7 +37,8 @@ describe('#PortalAPI', () => {
|
||||
authorId: '1',
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
|
||||
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1',
|
||||
{ signal: undefined }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,9 @@ const localeMenuLabels = computed(() => ({
|
||||
'customize-content': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
|
||||
),
|
||||
'select-popular-content': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT'
|
||||
),
|
||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue';
|
||||
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
|
||||
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
|
||||
import PopularContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -26,6 +27,7 @@ const route = useRoute();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const contentDialogRef = ref(null);
|
||||
const popularContentDialogRef = ref(null);
|
||||
|
||||
const isLocaleDefault = code => {
|
||||
return props.portal?.meta?.default_locale === code;
|
||||
@@ -154,6 +156,8 @@ const handleAction = ({ action }, localeCode) => {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'customize-content') {
|
||||
contentDialogRef.value.openForLocale(localeCode);
|
||||
} else if (action === 'select-popular-content') {
|
||||
popularContentDialogRef.value.openForLocale(localeCode);
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -174,5 +178,6 @@ const handleAction = ({ action }, localeCode) => {
|
||||
@action="handleAction($event, locale.code)"
|
||||
/>
|
||||
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
|
||||
<PopularContentDialog ref="popularContentDialogRef" :portal="portal" />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import categoriesAPI from 'dashboard/api/helpCenter/categories';
|
||||
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ReorderableMultiSelect from 'dashboard/components-next/combobox/ReorderableMultiSelect.vue';
|
||||
|
||||
const props = defineProps({
|
||||
portal: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_CATEGORIES = 3;
|
||||
const MAX_ARTICLES = 6;
|
||||
const KEY = 'HELP_CENTER.LOCALES_PAGE.POPULAR_CONTENT_DIALOG';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const activeLocale = ref('');
|
||||
// One flag per picker so each shows its skeleton until its own data loads.
|
||||
const categoriesLoading = ref(false);
|
||||
const { run: runArticleRequest, isPending: articlesLoading } =
|
||||
useAbortableRequest();
|
||||
|
||||
const categoryOptions = ref([]);
|
||||
const selectedCategoryIds = ref([]);
|
||||
|
||||
// Articles are searched server-side, so cache options for selected items that
|
||||
// fall outside the current results.
|
||||
const articleResults = ref([]);
|
||||
const articleOptionById = ref({});
|
||||
const selectedArticleIds = ref([]);
|
||||
|
||||
const popularContent = computed(
|
||||
() => props.portal?.config?.popular_content || {}
|
||||
);
|
||||
|
||||
const articleOptions = computed(() => {
|
||||
const options = new Map();
|
||||
selectedArticleIds.value.forEach(id => {
|
||||
if (articleOptionById.value[id])
|
||||
options.set(id, articleOptionById.value[id]);
|
||||
});
|
||||
articleResults.value.forEach(option => options.set(option.value, option));
|
||||
return [...options.values()];
|
||||
});
|
||||
|
||||
const toArticleOption = article => ({
|
||||
value: article.id,
|
||||
label: article.title,
|
||||
subtitle: t(`${KEY}.ARTICLES.IN_CATEGORY`, {
|
||||
category: article.category?.name || t(`${KEY}.ARTICLES.UNCATEGORIZED`),
|
||||
}),
|
||||
});
|
||||
|
||||
const fetchCategories = async localeCode => {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await categoriesAPI.get({
|
||||
portalSlug: props.portal?.slug,
|
||||
locale: localeCode,
|
||||
});
|
||||
return payload.map(category => ({
|
||||
value: category.id,
|
||||
label: category.name,
|
||||
subtitle: t(`${KEY}.CATEGORIES.ARTICLES_COUNT`, {
|
||||
count: category.meta?.articles_count || 0,
|
||||
}),
|
||||
icon: category.icon,
|
||||
iconColor: category.icon_color,
|
||||
}));
|
||||
};
|
||||
|
||||
const requestArticles = async (query, signal) => {
|
||||
const { data } = await articlesAPI.getArticles({
|
||||
pageNumber: 1,
|
||||
portalSlug: props.portal?.slug,
|
||||
locale: activeLocale.value,
|
||||
status: 'published',
|
||||
query,
|
||||
signal,
|
||||
});
|
||||
articleResults.value = data.payload.map(article => {
|
||||
const option = toArticleOption(article);
|
||||
articleOptionById.value[article.id] = option;
|
||||
return option;
|
||||
});
|
||||
};
|
||||
|
||||
const searchArticles = (query = '') =>
|
||||
runArticleRequest(signal => requestArticles(query, signal));
|
||||
|
||||
const onArticleSearch = useDebounceFn(searchArticles, 300);
|
||||
|
||||
// Resolve options for pre-selected articles that aren't in the current results.
|
||||
const cacheSelectedArticleOptions = async () => {
|
||||
const unknownIds = selectedArticleIds.value.filter(
|
||||
id => !articleOptionById.value[id]
|
||||
);
|
||||
await Promise.all(
|
||||
unknownIds.map(async id => {
|
||||
try {
|
||||
const { data } = await articlesAPI.getArticle({
|
||||
id,
|
||||
portalSlug: props.portal?.slug,
|
||||
});
|
||||
articleOptionById.value[id] = toArticleOption(data.payload);
|
||||
} catch {
|
||||
// Deleted since it was picked; leave it for the id fallback.
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const loadCategories = async localeCode => {
|
||||
categoriesLoading.value = true;
|
||||
try {
|
||||
const options = await fetchCategories(localeCode);
|
||||
// Reopened for another locale mid-flight; drop the stale response.
|
||||
if (localeCode !== activeLocale.value) return;
|
||||
categoryOptions.value = options;
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||
} finally {
|
||||
if (localeCode === activeLocale.value) categoriesLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadArticles = async () => {
|
||||
try {
|
||||
await runArticleRequest(signal =>
|
||||
Promise.all([cacheSelectedArticleOptions(), requestArticles('', signal)])
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
const openForLocale = localeCode => {
|
||||
const existing = popularContent.value[localeCode] || {};
|
||||
activeLocale.value = localeCode;
|
||||
selectedCategoryIds.value = [...(existing.category_ids || [])];
|
||||
selectedArticleIds.value = [...(existing.article_ids || [])];
|
||||
categoryOptions.value = [];
|
||||
articleResults.value = [];
|
||||
articleOptionById.value = {};
|
||||
dialogRef.value?.open();
|
||||
|
||||
loadCategories(localeCode);
|
||||
loadArticles();
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
const updated = { ...popularContent.value };
|
||||
const entry = {
|
||||
category_ids: selectedCategoryIds.value,
|
||||
article_ids: selectedArticleIds.value,
|
||||
};
|
||||
|
||||
if (entry.category_ids.length || entry.article_ids.length) {
|
||||
updated[activeLocale.value] = entry;
|
||||
} else {
|
||||
delete updated[activeLocale.value];
|
||||
}
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal?.slug,
|
||||
config: { popular_content: updated },
|
||||
});
|
||||
dialogRef.value?.close();
|
||||
useAlert(t(`${KEY}.API.SUCCESS_MESSAGE`));
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ openForLocale });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t(`${KEY}.TITLE`)"
|
||||
:description="t(`${KEY}.DESCRIPTION`)"
|
||||
:confirm-button-label="t(`${KEY}.CONFIRM`)"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
<ReorderableMultiSelect
|
||||
v-model="selectedCategoryIds"
|
||||
:options="categoryOptions"
|
||||
:max="MAX_CATEGORIES"
|
||||
:label="t(`${KEY}.CATEGORIES.LABEL`)"
|
||||
:add-label="t(`${KEY}.ADD_ANOTHER`)"
|
||||
:search-placeholder="t(`${KEY}.SEARCH`)"
|
||||
:empty-state="t(`${KEY}.EMPTY`)"
|
||||
fallback-icon="i-lucide-folder"
|
||||
:loading="categoriesLoading"
|
||||
>
|
||||
<template #counter="{ remaining }">
|
||||
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
|
||||
</template>
|
||||
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
|
||||
</ReorderableMultiSelect>
|
||||
<ReorderableMultiSelect
|
||||
v-model="selectedArticleIds"
|
||||
server-search
|
||||
:options="articleOptions"
|
||||
:max="MAX_ARTICLES"
|
||||
:label="t(`${KEY}.ARTICLES.LABEL`)"
|
||||
:add-label="t(`${KEY}.ADD_ANOTHER`)"
|
||||
:search-placeholder="t(`${KEY}.SEARCH`)"
|
||||
:empty-state="t(`${KEY}.EMPTY`)"
|
||||
:loading="articlesLoading"
|
||||
@search="onArticleSearch"
|
||||
>
|
||||
<template #counter="{ remaining }">
|
||||
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
|
||||
</template>
|
||||
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
|
||||
</ReorderableMultiSelect>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
@@ -27,6 +29,10 @@ const props = defineProps({
|
||||
type: [String, Number, Array],
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'search']);
|
||||
@@ -63,13 +69,22 @@ defineExpose({
|
||||
class="absolute z-50 w-full mt-1 transition-opacity duration-200 border rounded-md shadow-lg bg-n-solid-1 border-n-strong"
|
||||
>
|
||||
<div class="relative border-b border-n-strong">
|
||||
<span class="absolute i-lucide-search top-2.5 size-4 left-3" />
|
||||
<Spinner
|
||||
v-if="loading"
|
||||
:size="16"
|
||||
class="absolute top-2.5 start-3 text-n-slate-11"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-2.5 size-4 start-3"
|
||||
/>
|
||||
<input
|
||||
ref="searchInput"
|
||||
:value="searchValue"
|
||||
type="search"
|
||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
class="reset-base w-full py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-t-md bg-n-solid-1 text-n-slate-12"
|
||||
class="reset-base w-full py-2 !ps-10 !pe-2 text-sm focus:outline-none border-none rounded-t-md bg-n-solid-1 text-n-slate-12"
|
||||
@input="onInputSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
|
||||
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
// Drag-reorderable multi-select capped at `max`. The model is the ordered list
|
||||
// of selected values.
|
||||
const props = defineProps({
|
||||
// { value, label, subtitle?, icon?, iconColor? } — icon is an emoji or an
|
||||
// icon-picker value, and falls back to `fallbackIcon`.
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
addLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
emptyState: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
fallbackIcon: {
|
||||
type: String,
|
||||
default: 'i-lucide-file-text',
|
||||
},
|
||||
// Show skeleton rows while options load, so the row height stays stable.
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Let the parent filter `options` (via the `search` event) instead of locally.
|
||||
serverSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search']);
|
||||
|
||||
const selectedIds = defineModel({ type: Array, default: () => [] });
|
||||
|
||||
const isOpen = ref(false);
|
||||
const searchQuery = ref('');
|
||||
const dragIndex = ref(null);
|
||||
const dropdownRef = ref(null);
|
||||
|
||||
const optionsByValue = computed(
|
||||
() => new Map(props.options.map(option => [option.value, option]))
|
||||
);
|
||||
|
||||
const selectedRows = computed(() =>
|
||||
selectedIds.value.map(
|
||||
id => optionsByValue.value.get(id) || { value: id, label: String(id) }
|
||||
)
|
||||
);
|
||||
|
||||
const remaining = computed(() => props.max - selectedIds.value.length);
|
||||
|
||||
const canAddMore = computed(() => selectedIds.value.length < props.max);
|
||||
|
||||
const dropdownOptions = computed(() => {
|
||||
const available = props.options.filter(
|
||||
option => !selectedIds.value.includes(option.value)
|
||||
);
|
||||
if (props.serverSearch) return available;
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return available.filter(option =>
|
||||
option.label?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const onSearch = value => {
|
||||
searchQuery.value = value;
|
||||
emit('search', value);
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
if (isOpen.value) {
|
||||
onSearch('');
|
||||
nextTick(() => dropdownRef.value?.focus());
|
||||
}
|
||||
};
|
||||
|
||||
const onSelect = option => {
|
||||
if (!canAddMore.value) return;
|
||||
selectedIds.value = [...selectedIds.value, option.value];
|
||||
if (!canAddMore.value) isOpen.value = false;
|
||||
};
|
||||
|
||||
const removeItem = id => {
|
||||
selectedIds.value = selectedIds.value.filter(value => value !== id);
|
||||
};
|
||||
|
||||
const onDragStart = index => {
|
||||
dragIndex.value = index;
|
||||
};
|
||||
|
||||
const onDragOver = index => {
|
||||
if (dragIndex.value === null || dragIndex.value === index) return;
|
||||
const ids = [...selectedIds.value];
|
||||
const [moved] = ids.splice(dragIndex.value, 1);
|
||||
ids.splice(index, 0, moved);
|
||||
dragIndex.value = index;
|
||||
selectedIds.value = ids;
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
dragIndex.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="label || $slots.counter"
|
||||
class="flex items-center justify-between mb-1.5"
|
||||
>
|
||||
<label class="text-sm font-medium text-n-slate-12">{{ label }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-n-slate-10">
|
||||
<slot name="counter" :remaining="remaining" :max="max" />
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
v-for="slot in max"
|
||||
:key="slot"
|
||||
class="w-4 h-1 rounded-full"
|
||||
:class="slot <= selectedIds.length ? 'bg-n-brand' : 'bg-n-slate-4'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OnClickOutside @trigger="isOpen = false">
|
||||
<div
|
||||
class="flex flex-col gap-1 p-1 border rounded-xl border-n-weak bg-n-background"
|
||||
:class="{ 'opacity-50 pointer-events-none': disabled }"
|
||||
>
|
||||
<div
|
||||
v-if="loading && selectedIds.length && !isOpen"
|
||||
class="flex flex-col gap-1 overflow-y-auto max-h-[216px]"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div
|
||||
v-for="n in selectedIds.length"
|
||||
:key="n"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-n-alpha-2"
|
||||
>
|
||||
<span
|
||||
class="flex-shrink-0 opacity-40 i-lucide-grip-vertical size-4 text-n-slate-9"
|
||||
/>
|
||||
<div class="flex-shrink-0 rounded-md size-6 bg-n-alpha-3" />
|
||||
<div class="flex-grow min-w-0">
|
||||
<p class="mb-0 text-sm">
|
||||
<span
|
||||
class="inline-block w-32 h-2.5 align-middle rounded bg-n-alpha-3 animate-pulse"
|
||||
/>
|
||||
</p>
|
||||
<p class="mb-0 text-xs">
|
||||
<span
|
||||
class="inline-block w-20 h-2 align-middle rounded bg-n-alpha-3 animate-pulse"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<span class="flex-shrink-0 size-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="selectedRows.length"
|
||||
class="flex flex-col gap-1 overflow-y-auto max-h-[216px]"
|
||||
>
|
||||
<div
|
||||
v-for="(row, index) in selectedRows"
|
||||
:key="row.value"
|
||||
draggable="true"
|
||||
class="flex items-center gap-2 px-2 py-1.5 transition-colors rounded-lg cursor-grab group/row"
|
||||
:class="
|
||||
index === dragIndex
|
||||
? 'opacity-40 bg-n-alpha-3 ring-1 ring-inset ring-n-brand'
|
||||
: 'bg-n-alpha-2 hover:bg-n-alpha-3'
|
||||
"
|
||||
@dragstart="onDragStart(index)"
|
||||
@dragover.prevent="onDragOver(index)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<span
|
||||
class="flex-shrink-0 transition-colors i-lucide-grip-vertical size-4 text-n-slate-9 group-hover/row:text-n-slate-11"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center justify-center flex-shrink-0 text-sm rounded-md size-6 bg-n-alpha-3 text-n-slate-11"
|
||||
>
|
||||
<EmojiIcon
|
||||
v-if="row.icon"
|
||||
:value="row.icon"
|
||||
:color="row.iconColor"
|
||||
class="shrink-0 size-4"
|
||||
/>
|
||||
<span v-else class="size-4" :class="fallbackIcon" />
|
||||
</div>
|
||||
<div class="flex-grow min-w-0">
|
||||
<p class="mb-0 text-sm truncate text-n-slate-12">
|
||||
{{ row.label }}
|
||||
</p>
|
||||
<p
|
||||
v-if="row.subtitle"
|
||||
class="mb-0 text-xs truncate text-n-slate-10"
|
||||
>
|
||||
{{ row.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
no-animation
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0"
|
||||
@click="removeItem(row.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="canAddMore" class="relative">
|
||||
<Button
|
||||
type="button"
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
no-animation
|
||||
justify="start"
|
||||
:label="addLabel"
|
||||
:disabled="loading && !isOpen"
|
||||
class="w-full"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<template #icon>
|
||||
<Spinner
|
||||
v-if="loading && !isOpen"
|
||||
:size="16"
|
||||
class="text-n-slate-11"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-lucide-search"
|
||||
class="flex-shrink-0 size-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<ComboBoxDropdown
|
||||
ref="dropdownRef"
|
||||
:open="isOpen"
|
||||
:options="dropdownOptions"
|
||||
:search-value="searchQuery"
|
||||
:search-placeholder="searchPlaceholder"
|
||||
:empty-state="emptyState"
|
||||
:loading="loading"
|
||||
@update:search-value="onSearch"
|
||||
@select="onSelect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</OnClickOutside>
|
||||
|
||||
<p
|
||||
v-if="selectedIds.length && $slots.note"
|
||||
class="flex items-center gap-1.5 mt-1.5 mb-0 text-xs text-n-slate-11"
|
||||
>
|
||||
<span class="rounded-full size-1.5 bg-n-teal-9" />
|
||||
<slot name="note" />
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { h } from 'vue';
|
||||
import ReorderableMultiSelect from '../ReorderableMultiSelect.vue';
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 1, label: 'Getting started', subtitle: 'Guides' },
|
||||
{ value: 2, label: 'Billing', subtitle: 'Payments' },
|
||||
{ value: 3, label: 'Security' },
|
||||
{ value: 4, label: 'API', icon: '🔌', iconColor: '#000' },
|
||||
];
|
||||
|
||||
// A findable dropdown stub that exposes the `focus()` the component calls on open.
|
||||
const ComboBoxDropdownStub = {
|
||||
name: 'ComboBoxDropdown',
|
||||
props: [
|
||||
'open',
|
||||
'options',
|
||||
'searchValue',
|
||||
'searchPlaceholder',
|
||||
'emptyState',
|
||||
'loading',
|
||||
],
|
||||
emits: ['select', 'update:searchValue'],
|
||||
methods: { focus() {} },
|
||||
template: '<div class="combo-dropdown" />',
|
||||
};
|
||||
|
||||
// Renders a real <button> so clicks reach the parent handlers; `data-icon`
|
||||
// lets specs tell the remove buttons (icon="i-lucide-x") from the add trigger.
|
||||
const ButtonStub = {
|
||||
name: 'Button',
|
||||
props: ['label', 'icon', 'disabled'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button :data-icon="icon" :disabled="disabled" @click="$emit(\'click\')"><slot name="icon" />{{ label }}</button>',
|
||||
};
|
||||
|
||||
const mountSelect = (props = {}, slots = {}) =>
|
||||
mount(ReorderableMultiSelect, {
|
||||
props: { options: OPTIONS, max: 3, ...props },
|
||||
slots,
|
||||
global: {
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
ComboBoxDropdown: ComboBoxDropdownStub,
|
||||
Spinner: true,
|
||||
Icon: true,
|
||||
EmojiIcon: true,
|
||||
OnClickOutside: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dropdown = wrapper => wrapper.findComponent(ComboBoxDropdownStub);
|
||||
const addTrigger = wrapper =>
|
||||
wrapper.findAll('button').find(button => !button.attributes('data-icon'));
|
||||
const removeButtons = wrapper =>
|
||||
wrapper.findAll('button[data-icon="i-lucide-x"]');
|
||||
const rows = wrapper => wrapper.findAll('[draggable="true"]');
|
||||
const lastModel = wrapper => wrapper.emitted('update:modelValue')?.at(-1)?.[0];
|
||||
|
||||
describe('ReorderableMultiSelect', () => {
|
||||
describe('rendering selected rows', () => {
|
||||
it('renders rows in model order with labels resolved from options', () => {
|
||||
const wrapper = mountSelect({ modelValue: [2, 1] });
|
||||
|
||||
const labels = rows(wrapper).map(row => row.find('p').text());
|
||||
expect(labels).toEqual(['Billing', 'Getting started']);
|
||||
});
|
||||
|
||||
it('falls back to the stringified id when an option is unknown', () => {
|
||||
const wrapper = mountSelect({ modelValue: [99] });
|
||||
|
||||
expect(rows(wrapper)[0].find('p').text()).toBe('99');
|
||||
});
|
||||
|
||||
it('renders the progress dots filled up to the selection count', () => {
|
||||
const wrapper = mountSelect({
|
||||
modelValue: [1, 2],
|
||||
max: 3,
|
||||
label: 'Tags',
|
||||
});
|
||||
|
||||
const filled = wrapper.findAll('.bg-n-brand').length;
|
||||
expect(filled).toBe(2);
|
||||
});
|
||||
|
||||
it('exposes remaining and max to the counter slot', () => {
|
||||
const wrapper = mountSelect(
|
||||
{ modelValue: [1], max: 3 },
|
||||
{ counter: ({ remaining, max }) => h('span', `${remaining}/${max}`) }
|
||||
);
|
||||
|
||||
expect(wrapper.text()).toContain('2/3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('adding options', () => {
|
||||
it('appends the chosen option to the model', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1] });
|
||||
|
||||
dropdown(wrapper).vm.$emit('select', OPTIONS[1]);
|
||||
|
||||
expect(lastModel(wrapper)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('hides the add trigger once the model reaches max', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1, 2], max: 2 });
|
||||
|
||||
expect(addTrigger(wrapper)).toBeUndefined();
|
||||
expect(dropdown(wrapper).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('closes the dropdown when the last slot is filled', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [1], max: 2 });
|
||||
await addTrigger(wrapper).trigger('click');
|
||||
expect(dropdown(wrapper).props('open')).toBe(true);
|
||||
|
||||
dropdown(wrapper).vm.$emit('select', OPTIONS[1]);
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// Reaching max removes the trigger (and its dropdown) entirely.
|
||||
expect(dropdown(wrapper).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes already-selected options from the dropdown', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1] });
|
||||
|
||||
const values = dropdown(wrapper)
|
||||
.props('options')
|
||||
.map(option => option.value);
|
||||
expect(values).toEqual([2, 3, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removing options', () => {
|
||||
it('removes the clicked item from the model', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [1, 2, 3] });
|
||||
|
||||
await removeButtons(wrapper)[1].trigger('click');
|
||||
|
||||
expect(lastModel(wrapper)).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searching', () => {
|
||||
it('filters options locally by label', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [] });
|
||||
|
||||
dropdown(wrapper).vm.$emit('update:searchValue', 'bill');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const values = dropdown(wrapper)
|
||||
.props('options')
|
||||
.map(option => option.value);
|
||||
expect(values).toEqual([2]);
|
||||
});
|
||||
|
||||
it('emits search and skips local filtering when serverSearch is set', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [], serverSearch: true });
|
||||
|
||||
dropdown(wrapper).vm.$emit('update:searchValue', 'bill');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.emitted('search').at(-1)).toEqual(['bill']);
|
||||
// All unselected options remain; the parent owns filtering.
|
||||
expect(dropdown(wrapper).props('options')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('emits an empty search when the trigger opens', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [1] });
|
||||
|
||||
await addTrigger(wrapper).trigger('click');
|
||||
|
||||
expect(wrapper.emitted('search').at(-1)).toEqual(['']);
|
||||
expect(dropdown(wrapper).props('open')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reordering', () => {
|
||||
it('moves a row to the dropped position within the model', async () => {
|
||||
const wrapper = mountSelect({ modelValue: [1, 2, 3] });
|
||||
|
||||
await rows(wrapper)[0].trigger('dragstart');
|
||||
await rows(wrapper)[2].trigger('dragover');
|
||||
|
||||
expect(lastModel(wrapper)).toEqual([2, 3, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows skeleton rows when loading a non-empty, closed selection', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1, 2], loading: true });
|
||||
|
||||
const skeleton = wrapper.find('[aria-busy="true"]');
|
||||
expect(skeleton.exists()).toBe(true);
|
||||
expect(skeleton.findAll('.animate-pulse').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not show skeletons when the selection is empty', () => {
|
||||
const wrapper = mountSelect({ modelValue: [], loading: true });
|
||||
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the real rows, not skeletons, while searching in an open dropdown', async () => {
|
||||
// Open first (trigger is enabled), then a live search turns loading on.
|
||||
const wrapper = mountSelect({ modelValue: [1, 2] });
|
||||
await addTrigger(wrapper).trigger('click');
|
||||
|
||||
await wrapper.setProps({ loading: true });
|
||||
|
||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false);
|
||||
expect(rows(wrapper)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('forwards loading to the dropdown and disables the closed trigger', () => {
|
||||
const wrapper = mountSelect({ modelValue: [1], loading: true });
|
||||
|
||||
expect(dropdown(wrapper).props('loading')).toBe(true);
|
||||
expect(addTrigger(wrapper).attributes('disabled')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { effectScope } from 'vue';
|
||||
import { useAbortableRequest } from '../useAbortableRequest';
|
||||
|
||||
// Resolves when the request "completes", rejects like axios does when the
|
||||
// signal is aborted mid-flight.
|
||||
const abortableRunner =
|
||||
(value, { fail = false } = {}) =>
|
||||
signal =>
|
||||
new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
const error = new Error('canceled');
|
||||
error.name = 'CanceledError';
|
||||
reject(error);
|
||||
});
|
||||
// Defer so a follow-up `run`/`abort` can supersede this one first.
|
||||
Promise.resolve().then(() => {
|
||||
if (signal.aborted) return;
|
||||
if (fail) {
|
||||
reject(new Error('boom'));
|
||||
return;
|
||||
}
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAbortableRequest', () => {
|
||||
it('passes a fresh signal to the runner and returns its result', async () => {
|
||||
const { run } = useAbortableRequest();
|
||||
let received = null;
|
||||
|
||||
const result = await run(signal => {
|
||||
received = signal;
|
||||
return Promise.resolve('ok');
|
||||
});
|
||||
|
||||
expect(received).toBeInstanceOf(AbortSignal);
|
||||
expect(received.aborted).toBe(false);
|
||||
expect(result).toBe('ok');
|
||||
});
|
||||
|
||||
it('toggles isPending around the request', async () => {
|
||||
const { run, isPending } = useAbortableRequest();
|
||||
expect(isPending.value).toBe(false);
|
||||
|
||||
const pending = run(() => Promise.resolve('done'));
|
||||
expect(isPending.value).toBe(true);
|
||||
|
||||
await pending;
|
||||
expect(isPending.value).toBe(false);
|
||||
});
|
||||
|
||||
it('aborts the previous request when a new one starts', async () => {
|
||||
const { run } = useAbortableRequest();
|
||||
|
||||
const first = run(abortableRunner('first'));
|
||||
const second = run(abortableRunner('second'));
|
||||
|
||||
await expect(first).resolves.toBeUndefined();
|
||||
await expect(second).resolves.toBe('second');
|
||||
});
|
||||
|
||||
it('returns the onAbort value when a request is superseded', async () => {
|
||||
const { run } = useAbortableRequest();
|
||||
|
||||
const first = run(abortableRunner('first'), { onAbort: null });
|
||||
const second = run(abortableRunner('second'));
|
||||
|
||||
await expect(first).resolves.toBeNull();
|
||||
await expect(second).resolves.toBe('second');
|
||||
});
|
||||
|
||||
it('abort cancels the in-flight request and clears isPending', async () => {
|
||||
const { run, abort, isPending } = useAbortableRequest();
|
||||
|
||||
const pending = run(abortableRunner('value'));
|
||||
expect(isPending.value).toBe(true);
|
||||
|
||||
abort();
|
||||
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
expect(isPending.value).toBe(false);
|
||||
});
|
||||
|
||||
it('rethrows non-abort errors and clears isPending', async () => {
|
||||
const { run, isPending } = useAbortableRequest();
|
||||
|
||||
await expect(run(abortableRunner(null, { fail: true }))).rejects.toThrow(
|
||||
'boom'
|
||||
);
|
||||
expect(isPending.value).toBe(false);
|
||||
});
|
||||
|
||||
it('aborts the in-flight request when its scope is disposed', async () => {
|
||||
const scope = effectScope();
|
||||
let request;
|
||||
scope.run(() => {
|
||||
request = useAbortableRequest();
|
||||
});
|
||||
|
||||
const pending = request.run(abortableRunner('value'));
|
||||
expect(request.isPending.value).toBe(true);
|
||||
|
||||
scope.stop();
|
||||
|
||||
await expect(pending).resolves.toBeUndefined();
|
||||
expect(request.isPending.value).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps separate controllers per instance', async () => {
|
||||
const a = useAbortableRequest();
|
||||
const b = useAbortableRequest();
|
||||
|
||||
const first = a.run(abortableRunner('a'));
|
||||
// Starting b's request must not abort a's.
|
||||
const second = b.run(abortableRunner('b'));
|
||||
|
||||
await expect(first).resolves.toBe('a');
|
||||
await expect(second).resolves.toBe('b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getCurrentScope, onScopeDispose, ref } from 'vue';
|
||||
|
||||
export const isAbortError = error =>
|
||||
error?.name === 'AbortError' ||
|
||||
error?.name === 'CanceledError' ||
|
||||
error?.code === 'ERR_CANCELED';
|
||||
|
||||
/**
|
||||
* Keeps only the latest request alive. Starting a new `run` (or calling
|
||||
* `abort`) cancels the previous request through its `AbortSignal`, so
|
||||
* out-of-order responses can never overwrite fresher data.
|
||||
*
|
||||
* @example
|
||||
* const { run, abort, isPending } = useAbortableRequest();
|
||||
* const results = await run(signal => api.search(query, { signal }));
|
||||
*
|
||||
* @returns {{
|
||||
* run: (runner: (signal: AbortSignal) => Promise<any>, options?: { onAbort?: any }) => Promise<any>,
|
||||
* abort: () => void,
|
||||
* isPending: import('vue').Ref<boolean>,
|
||||
* }}
|
||||
* `run` resolves with the runner's value, or `options.onAbort` (default
|
||||
* `undefined`) when the request was superseded. Non-abort errors are rethrown.
|
||||
*/
|
||||
export function useAbortableRequest() {
|
||||
const isPending = ref(false);
|
||||
let controller = null;
|
||||
|
||||
const abort = () => {
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
isPending.value = false;
|
||||
};
|
||||
|
||||
const run = async (runner, { onAbort } = {}) => {
|
||||
controller?.abort();
|
||||
const currentController = new AbortController();
|
||||
controller = currentController;
|
||||
isPending.value = true;
|
||||
|
||||
try {
|
||||
return await runner(currentController.signal);
|
||||
} catch (error) {
|
||||
if (currentController.signal.aborted || isAbortError(error))
|
||||
return onAbort;
|
||||
throw error;
|
||||
} finally {
|
||||
// Only the latest run owns the shared state; a superseded run leaves it
|
||||
// for the run that replaced it.
|
||||
if (controller === currentController) {
|
||||
controller = null;
|
||||
isPending.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel any in-flight request when the owning scope is disposed.
|
||||
// Guarded so the composable can also be used outside an effect scope.
|
||||
if (getCurrentScope()) onScopeDispose(abort);
|
||||
|
||||
return { run, abort, isPending };
|
||||
}
|
||||
@@ -166,6 +166,13 @@ export const LOCALE_MENU_ITEMS = {
|
||||
value: 'customize-content',
|
||||
icon: 'i-lucide-pencil',
|
||||
},
|
||||
selectPopularContent: {
|
||||
label:
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT',
|
||||
action: 'select-popular-content',
|
||||
value: 'select-popular-content',
|
||||
icon: 'i-lucide-sparkles',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
@@ -185,6 +192,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
]),
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.selectPopularContent,
|
||||
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
|
||||
];
|
||||
}
|
||||
@@ -193,6 +201,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.publishLocale,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.selectPopularContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
}
|
||||
@@ -201,6 +210,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.selectPopularContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -74,26 +74,34 @@ describe('PortalHelper', () => {
|
||||
});
|
||||
|
||||
describe('buildLocaleMenuItems', () => {
|
||||
it('disables other actions but keeps customize enabled for the default locale', () => {
|
||||
it('disables other actions but keeps content actions enabled for the default locale', () => {
|
||||
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
|
||||
const customize = items.find(item => item.action === 'customize-content');
|
||||
const enabledActions = ['customize-content', 'select-popular-content'];
|
||||
|
||||
expect(customize).toBeTruthy();
|
||||
expect(customize.disabled).toBeFalsy();
|
||||
enabledActions.forEach(action => {
|
||||
expect(
|
||||
items.find(item => item.action === action)?.disabled
|
||||
).toBeFalsy();
|
||||
});
|
||||
expect(
|
||||
items
|
||||
.filter(item => item.action !== 'customize-content')
|
||||
.filter(item => !enabledActions.includes(item.action))
|
||||
.every(item => item.disabled)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns publish, customize, and delete actions for draft locales', () => {
|
||||
it('returns publish, customize, popular content, and delete actions for draft locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['publish-locale', 'customize-content', 'delete']);
|
||||
).toEqual([
|
||||
'publish-locale',
|
||||
'customize-content',
|
||||
'select-popular-content',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns default, draft, customize, and delete actions for live locales', () => {
|
||||
@@ -106,6 +114,7 @@ describe('PortalHelper', () => {
|
||||
'change-default',
|
||||
'move-to-draft',
|
||||
'customize-content',
|
||||
'select-popular-content',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -720,9 +720,33 @@
|
||||
"MOVE_TO_DRAFT": "Move to draft",
|
||||
"PUBLISH_LOCALE": "Publish locale",
|
||||
"CUSTOMIZE_CONTENT": "Localize content",
|
||||
"SELECT_POPULAR_CONTENT": "Select recommended content",
|
||||
"DELETE": "Delete"
|
||||
}
|
||||
},
|
||||
"POPULAR_CONTENT_DIALOG": {
|
||||
"TITLE": "Recommended content",
|
||||
"DESCRIPTION": "Pick up to 3 categories and 6 articles to feature on this locale's help center home page. Drag them into the order you want visitors to see.",
|
||||
"SEARCH": "Search...",
|
||||
"EMPTY": "No matching results",
|
||||
"ADD_ANOTHER": "Add another...",
|
||||
"SLOTS_LEFT": "{count} slots left",
|
||||
"OVERRIDING_DEFAULTS": "Overriding defaults for this locale",
|
||||
"CONFIRM": "Save recommendations",
|
||||
"CATEGORIES": {
|
||||
"LABEL": "Recommended categories",
|
||||
"ARTICLES_COUNT": "No articles | {count} article | {count} articles"
|
||||
},
|
||||
"ARTICLES": {
|
||||
"LABEL": "Recommended articles",
|
||||
"IN_CATEGORY": "in {category}",
|
||||
"UNCATEGORIZED": "Uncategorized"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Recommended content updated successfully",
|
||||
"ERROR_MESSAGE": "Unable to update recommended content. Try again."
|
||||
}
|
||||
},
|
||||
"CONTENT_DIALOG": {
|
||||
"TITLE": "Localize content",
|
||||
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
|
||||
|
||||
@@ -69,6 +69,8 @@ class ConversationReplyMailer < ApplicationMailer
|
||||
@agent = @conversation.assignee
|
||||
@inbox = @conversation.inbox
|
||||
@channel = @inbox.channel
|
||||
Current.account = @account
|
||||
Current.inbox = @inbox
|
||||
end
|
||||
|
||||
def should_use_conversation_email_address?
|
||||
@@ -200,8 +202,24 @@ class ConversationReplyMailer < ApplicationMailer
|
||||
end
|
||||
|
||||
def choose_layout
|
||||
return 'mailer/base' if branded_email_layout_action?
|
||||
return false if action_name == 'reply_without_summary' || action_name == 'email_reply'
|
||||
|
||||
'mailer/base'
|
||||
end
|
||||
|
||||
def branded_email_layout_action?
|
||||
return false unless action_name.in?(%w[email_reply reply_without_summary])
|
||||
return @inbox.branded_email_layout_available? if @inbox&.email?
|
||||
|
||||
@account&.feature_enabled?(:branded_email_templates) && EmailTemplate.account_branded_layout_template_for(@account).present?
|
||||
end
|
||||
|
||||
def liquid_droppables
|
||||
super.merge({
|
||||
agent: current_message&.sender || @agent,
|
||||
contact: @contact,
|
||||
message: @message || @messages&.last
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module InboxBrandedEmailLayoutable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def branded_email_layout
|
||||
branded_email_layout_template&.body
|
||||
end
|
||||
|
||||
def branded_email_layout_template
|
||||
email_templates.find_by(name: EmailTemplate::BRANDED_LAYOUT_NAME, template_type: :layout, locale: EmailTemplate::DEFAULT_LOCALE)
|
||||
end
|
||||
|
||||
def effective_branded_email_layout_template(locale = I18n.locale)
|
||||
EmailTemplate.branded_layout_for(inbox: self, account: account, locale: locale)
|
||||
end
|
||||
|
||||
def branded_email_layout_available?
|
||||
email? && account.feature_enabled?(:branded_email_templates) && effective_branded_email_layout_template.present?
|
||||
end
|
||||
|
||||
def update_branded_email_layout!(body)
|
||||
if body.blank?
|
||||
branded_email_layout_template&.destroy!
|
||||
return
|
||||
end
|
||||
|
||||
template = branded_email_layout_template || email_templates.new(
|
||||
name: EmailTemplate::BRANDED_LAYOUT_NAME,
|
||||
template_type: :layout,
|
||||
locale: EmailTemplate::DEFAULT_LOCALE,
|
||||
account: account
|
||||
)
|
||||
template.update!(body: body)
|
||||
end
|
||||
end
|
||||
@@ -14,6 +14,18 @@ module PortalConfigSchema
|
||||
'additionalProperties' => false
|
||||
}.freeze
|
||||
|
||||
# Per-locale recommended content for the portal home page: an ordered list of
|
||||
# `category_ids` (the hero's "Recommended topics" pills) and `article_ids` (the
|
||||
# "Recommended" articles section). When empty, the portal uses its defaults.
|
||||
POPULAR_CONTENT_SCHEMA = {
|
||||
'type' => 'object',
|
||||
'properties' => {
|
||||
'category_ids' => { 'type' => %w[array null], 'items' => { 'type' => 'integer' } },
|
||||
'article_ids' => { 'type' => %w[array null], 'items' => { 'type' => 'integer' } }
|
||||
},
|
||||
'additionalProperties' => false
|
||||
}.freeze
|
||||
|
||||
CONFIG_PARAMS_SCHEMA = {
|
||||
'type' => 'object',
|
||||
'properties' => {
|
||||
@@ -27,6 +39,10 @@ module PortalConfigSchema
|
||||
'locale_translations' => {
|
||||
'type' => %w[object null],
|
||||
'additionalProperties' => LOCALE_TRANSLATION_SCHEMA
|
||||
},
|
||||
'popular_content' => {
|
||||
'type' => %w[object null],
|
||||
'additionalProperties' => POPULAR_CONTENT_SCHEMA
|
||||
}
|
||||
},
|
||||
'required' => [],
|
||||
|
||||
@@ -10,19 +10,111 @@
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer
|
||||
# inbox_id :integer
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_email_templates_on_name_and_account_id (name,account_id) UNIQUE
|
||||
# index_email_templates_on_account_scope (account_id,name,template_type,locale) UNIQUE WHERE ((account_id IS NOT NULL) AND (inbox_id IS NULL))
|
||||
# index_email_templates_on_inbox_id (inbox_id)
|
||||
# index_email_templates_on_inbox_scope (inbox_id,name,template_type,locale) UNIQUE WHERE (inbox_id IS NOT NULL)
|
||||
# index_email_templates_on_installation_scope (name,template_type,locale) UNIQUE WHERE ((account_id IS NULL) AND (inbox_id IS NULL))
|
||||
#
|
||||
class EmailTemplate < ApplicationRecord
|
||||
BRANDED_LAYOUT_NAME = 'base'.freeze
|
||||
DEFAULT_LOCALE = 'en'.freeze
|
||||
CONTENT_FOR_LAYOUT_PATTERN = /\{\{\s*content_for_layout\s*\}\}/
|
||||
|
||||
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
|
||||
enum :template_type, { layout: 0, content: 1 }
|
||||
belongs_to :account, optional: true
|
||||
belongs_to :inbox, optional: true
|
||||
|
||||
validates :name, uniqueness: { scope: :account }
|
||||
validates :name,
|
||||
uniqueness: { scope: %i[template_type locale], conditions: -> { where(account_id: nil, inbox_id: nil) } },
|
||||
if: :installation_scoped?
|
||||
validates :name, uniqueness: { scope: %i[account_id template_type locale], conditions: -> { where(inbox_id: nil) } }, if: :account_scoped?
|
||||
validates :name, uniqueness: { scope: %i[inbox_id template_type locale] }, if: :inbox_scoped?
|
||||
validate :validate_inbox_account
|
||||
validate :validate_liquid_body
|
||||
validate :validate_layout_slot, if: :layout?
|
||||
|
||||
def self.resolver(options = {})
|
||||
::EmailTemplates::DbResolverService.using self, options
|
||||
end
|
||||
|
||||
def self.branded_layout_for(inbox:, account:, locale: I18n.locale)
|
||||
layout_template_for_scope(inbox: inbox, account: account, locale: locale)
|
||||
end
|
||||
|
||||
def self.account_branded_layout_template_for(account)
|
||||
find_by(account: account, inbox: nil, name: BRANDED_LAYOUT_NAME, template_type: :layout, locale: DEFAULT_LOCALE)
|
||||
end
|
||||
|
||||
def self.update_account_branded_layout!(account:, body:)
|
||||
if body.blank?
|
||||
account_branded_layout_template_for(account)&.destroy!
|
||||
return
|
||||
end
|
||||
|
||||
template = account_branded_layout_template_for(account) || new(
|
||||
account: account,
|
||||
name: BRANDED_LAYOUT_NAME,
|
||||
template_type: :layout,
|
||||
locale: DEFAULT_LOCALE
|
||||
)
|
||||
template.update!(body: body)
|
||||
end
|
||||
|
||||
def self.locale_candidates(locale)
|
||||
candidate = locale.to_s
|
||||
([candidate] + [DEFAULT_LOCALE]).select { |locale_key| locales.key?(locale_key) }.uniq
|
||||
end
|
||||
|
||||
def self.layout_template_for_scope(inbox:, account:, locale:)
|
||||
scoped_relations = []
|
||||
scoped_relations << where(inbox: inbox) if inbox.present?
|
||||
scoped_relations << where(account: account, inbox: nil) if account.present?
|
||||
scoped_relations << where(account: nil, inbox: nil)
|
||||
|
||||
scoped_relations.each do |relation|
|
||||
locale_candidates(locale).each do |locale_key|
|
||||
template = relation.find_by(name: BRANDED_LAYOUT_NAME, template_type: :layout, locale: locale_key)
|
||||
return template if template.present?
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def installation_scoped?
|
||||
account_id.nil? && inbox_id.nil?
|
||||
end
|
||||
|
||||
def account_scoped?
|
||||
account_id.present? && inbox_id.nil?
|
||||
end
|
||||
|
||||
def inbox_scoped?
|
||||
inbox_id.present?
|
||||
end
|
||||
|
||||
def validate_inbox_account
|
||||
return if inbox.blank? || account.blank?
|
||||
return if inbox.account_id == account_id
|
||||
|
||||
errors.add(:account, 'must match inbox account')
|
||||
end
|
||||
|
||||
def validate_liquid_body
|
||||
Liquid::Template.parse(body.to_s)
|
||||
rescue Liquid::Error => e
|
||||
errors.add(:body, "has invalid Liquid syntax: #{e.message}")
|
||||
end
|
||||
|
||||
def validate_layout_slot
|
||||
return if body.to_s.match?(CONTENT_FOR_LAYOUT_PATTERN)
|
||||
|
||||
errors.add(:body, 'must include {{ content_for_layout }}')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,6 +45,7 @@ class Inbox < ApplicationRecord
|
||||
include OutOfOffisable
|
||||
include AccountCacheRevalidator
|
||||
include InboxAgentAvailability
|
||||
include InboxBrandedEmailLayoutable
|
||||
|
||||
# Not allowing characters:
|
||||
validates :name, presence: true
|
||||
@@ -67,6 +68,7 @@ class Inbox < ApplicationRecord
|
||||
has_many :members, through: :inbox_members, source: :user
|
||||
has_many :conversations, dependent: :destroy_async
|
||||
has_many :messages, dependent: :destroy_async
|
||||
has_many :email_templates, dependent: :destroy_async
|
||||
|
||||
has_one :inbox_assignment_policy, dependent: :destroy
|
||||
has_one :assignment_policy, through: :inbox_assignment_policy
|
||||
|
||||
+14
-1
@@ -53,7 +53,12 @@ class Portal < ApplicationRecord
|
||||
scope :active, -> { where(archived: false) }
|
||||
|
||||
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
|
||||
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations].freeze
|
||||
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations
|
||||
popular_content].freeze
|
||||
|
||||
# Max number of recommended categories/articles shown per locale.
|
||||
POPULAR_CATEGORY_LIMIT = 3
|
||||
POPULAR_ARTICLE_LIMIT = 6
|
||||
|
||||
def file_base_data
|
||||
{
|
||||
@@ -115,6 +120,14 @@ class Portal < ApplicationRecord
|
||||
config_value('layout').presence || 'classic'
|
||||
end
|
||||
|
||||
def popular_category_ids(locale = default_locale)
|
||||
Array(config.dig('popular_content', locale.to_s, 'category_ids')).first(POPULAR_CATEGORY_LIMIT)
|
||||
end
|
||||
|
||||
def popular_article_ids(locale = default_locale)
|
||||
Array(config.dig('popular_content', locale.to_s, 'article_ids')).first(POPULAR_ARTICLE_LIMIT)
|
||||
end
|
||||
|
||||
def social_profiles
|
||||
config_value('social_profiles') || {}
|
||||
end
|
||||
|
||||
@@ -29,12 +29,14 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver
|
||||
end
|
||||
# rubocop:enable Metrics/ParameterLists
|
||||
|
||||
# the function has to accept(name, prefix, partial, _details, _locals = [])
|
||||
# _details contain local info which we can leverage in future
|
||||
# the function has to accept(name, prefix, partial, details, locals = [])
|
||||
# details contain local info which we can leverage in future
|
||||
# cause of codeclimate issue with 4 args, relying on (*args)
|
||||
def find_templates(name, prefix, partial, *_args)
|
||||
def find_templates(name, prefix, partial, *args)
|
||||
@template_name = name
|
||||
@template_type = prefix.include?('layout') ? 'layout' : 'content'
|
||||
@template_type = prefix.to_s.include?('layout') ? 'layout' : 'content'
|
||||
@prefix = prefix
|
||||
@details = args.first if args.first.is_a?(Hash)
|
||||
@db_template = find_db_template
|
||||
|
||||
return [] if @db_template.blank?
|
||||
@@ -54,17 +56,62 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver
|
||||
private
|
||||
|
||||
def find_db_template
|
||||
find_account_template || find_installation_template
|
||||
find_inbox_template || find_account_template || find_installation_template
|
||||
end
|
||||
|
||||
def find_inbox_template
|
||||
return unless email_inbox_layout_lookup? && branded_email_templates_enabled?
|
||||
|
||||
find_template_for(@@model.where(inbox: Current.inbox))
|
||||
end
|
||||
|
||||
def find_account_template
|
||||
return unless Current.account
|
||||
return if account_layout_lookup? && !branded_email_templates_enabled?
|
||||
|
||||
@@model.find_by(name: @template_name, template_type: @template_type, account: Current.account)
|
||||
find_template_for(@@model.where(account: Current.account, inbox: nil))
|
||||
end
|
||||
|
||||
def find_installation_template
|
||||
@@model.find_by(name: @template_name, template_type: @template_type, account: nil)
|
||||
find_template_for(@@model.where(account: nil, inbox: nil))
|
||||
end
|
||||
|
||||
def account_layout_lookup?
|
||||
@template_type == 'layout' && Current.account.present?
|
||||
end
|
||||
|
||||
def email_inbox_layout_lookup?
|
||||
account_layout_lookup? && Current.inbox&.email?
|
||||
end
|
||||
|
||||
def branded_email_templates_enabled?
|
||||
Current.account&.feature_enabled?(:branded_email_templates)
|
||||
end
|
||||
|
||||
def find_template_for(relation)
|
||||
locale_candidates.each do |locale|
|
||||
template_names.each do |name|
|
||||
template = relation.find_by(name: name, template_type: @template_type, locale: locale)
|
||||
return template if template.present?
|
||||
end
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def locale_candidates
|
||||
locale = Array(@details&.dig(:locale)).first
|
||||
EmailTemplate.locale_candidates(locale.presence || EmailTemplate::DEFAULT_LOCALE)
|
||||
end
|
||||
|
||||
def template_names
|
||||
[db_template_name, @template_name].uniq
|
||||
end
|
||||
|
||||
def db_template_name
|
||||
return @template_name if @template_type == 'layout'
|
||||
|
||||
build_path(@prefix)
|
||||
end
|
||||
|
||||
# Build path with eventual prefix
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.branded_email_layout @branded_email_layout
|
||||
@@ -0,0 +1 @@
|
||||
json.branded_email_layout @branded_email_layout
|
||||
@@ -1 +1 @@
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox, with_branded_email_layout: true
|
||||
|
||||
@@ -1 +1 @@
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox, with_branded_email_layout: true
|
||||
|
||||
@@ -19,6 +19,7 @@ json.config do
|
||||
json.layout portal.layout
|
||||
json.social_profiles portal.social_profiles
|
||||
json.locale_translations portal.config['locale_translations'] || {}
|
||||
json.popular_content portal.config['popular_content'] || {}
|
||||
end
|
||||
|
||||
if portal.channel_web_widget
|
||||
|
||||
@@ -81,6 +81,10 @@ if resource.email?
|
||||
json.email resource.channel.try(:email)
|
||||
json.forwarding_enabled ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', '').present?
|
||||
json.forward_to_email resource.channel.try(:forward_to_email) if ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', '').present?
|
||||
if Current.account_user&.administrator? && defined?(with_branded_email_layout) && with_branded_email_layout.present? &&
|
||||
Current.account.feature_enabled?(:branded_email_templates)
|
||||
json.branded_email_layout resource.branded_email_layout
|
||||
end
|
||||
|
||||
## IMAP
|
||||
if Current.account_user&.administrator?
|
||||
|
||||
@@ -67,6 +67,11 @@ html.light {
|
||||
#category-block:hover #category-name {
|
||||
color: var(--dynamic-hover-color);
|
||||
}
|
||||
/* Recommended topic pills in the classic hero */
|
||||
.recommended-pill:hover {
|
||||
border-color: var(--dynamic-hover-color);
|
||||
color: var(--dynamic-hover-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<% featured_articles = articles.where(category_id: categories).search_by_status(:published).order_by_views.limit(6) %>
|
||||
<% if featured_articles.count >= 6 %>
|
||||
<% recommended = local_assigns[:recommended].presence %>
|
||||
<% featured_articles = recommended || articles.where(category_id: categories).search_by_status(:published).order_by_views.select(:id, :title, :slug).limit(6) %>
|
||||
<% if recommended || featured_articles.length >= 6 %>
|
||||
<section class="flex flex-col w-full h-full lg:container">
|
||||
<div class="flex flex-col gap-5 px-3 py-5 border border-solid rounded-lg border-slate-100 dark:border-slate-800">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<div class="flex flex-row items-center gap-2 px-2">
|
||||
<h3 class="text-xl font-semibold leading-relaxed text-slate-800 dark:text-slate-50">
|
||||
<%= I18n.t('public_portal.header.featured_articles') %>
|
||||
<%= recommended ? I18n.t('public_portal.header.recommended') : I18n.t('public_portal.header.featured_articles') %>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,18 @@
|
||||
</h1>
|
||||
<p class="text-slate-600 dark:text-slate-200 text-start text-lg leading-normal pt-2 pb-4"><%= I18n.t('public_portal.hero.sub_title') %></p>
|
||||
<div id="search-wrap" class="w-full relative z-30"></div>
|
||||
<% if @recommended_categories.present? %>
|
||||
<div class="mt-5 flex items-center gap-2 flex-wrap text-sm">
|
||||
<span class="text-slate-500 dark:text-slate-400"><%= I18n.t('public_portal.sidebar.recommended_label') %></span>
|
||||
<% @recommended_categories.each do |category| %>
|
||||
<a href="<%= public_portal_category_path(@portal.slug, category.locale, category.slug) %>"
|
||||
class="recommended-pill inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-white dark:bg-slate-800 border border-solid border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-100 transition">
|
||||
<% 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 %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%# locals: (portal:, popular_topics: []) %>
|
||||
<%# locals: (portal:, popular_topics: [], recommended: false) %>
|
||||
<section class="relative border-b border-solid border-n-weak bg-n-slate-1 dark:bg-n-slate-2 min-h-[50vh] flex items-center">
|
||||
<div class="absolute inset-0 pointer-events-none text-n-portal-faint [background-image:linear-gradient(to_right,currentColor_1px,transparent_1px),linear-gradient(to_bottom,currentColor_1px,transparent_1px)] [background-size:56px_56px] [mask-image:radial-gradient(ellipse_at_50%_30%,black_35%,transparent_80%)] [-webkit-mask-image:radial-gradient(ellipse_at_50%_30%,black_35%,transparent_80%)]" aria-hidden="true"></div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<% if popular_topics.any? %>
|
||||
<div class="mt-5 flex items-center gap-2 flex-wrap text-sm">
|
||||
<span class="text-n-slate-10"><%= I18n.t('public_portal.sidebar.popular_label') %></span>
|
||||
<span class="text-n-slate-10"><%= I18n.t("public_portal.sidebar.#{recommended ? 'recommended_label' : 'popular_label'}") %></span>
|
||||
<% 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">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%= render 'public/api/v1/portals/documentation_layout/hero', portal: @portal, popular_topics: @popular_topics %>
|
||||
<%= render 'public/api/v1/portals/documentation_layout/hero', portal: @portal, popular_topics: @popular_topics, recommended: @recommended_categories.present? %>
|
||||
|
||||
<section class="px-6 md:px-10 py-8 md:py-10">
|
||||
<%= render 'public/api/v1/portals/documentation_layout/section_header',
|
||||
@@ -19,14 +19,15 @@
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<% if @featured.any? %>
|
||||
<% home_articles = @recommended_articles.presence || @featured %>
|
||||
<% if home_articles.any? %>
|
||||
<section class="px-6 md:px-10 py-8 md:py-10 border-t border-n-weak">
|
||||
<%= render 'public/api/v1/portals/documentation_layout/section_header',
|
||||
title: I18n.t('public_portal.sidebar.popular_articles'),
|
||||
subtitle: I18n.t('public_portal.sidebar.popular_articles_subtitle'),
|
||||
title: @recommended_articles.present? ? I18n.t('public_portal.sidebar.recommended') : I18n.t('public_portal.sidebar.popular_articles'),
|
||||
subtitle: @recommended_articles.present? ? I18n.t('public_portal.sidebar.recommended_subtitle') : I18n.t('public_portal.sidebar.popular_articles_subtitle'),
|
||||
subtitle_class: 'mt-1.5 text-base text-n-slate-11' %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<% @featured.each do |article| %>
|
||||
<% home_articles.each do |article| %>
|
||||
<%= render 'public/api/v1/portals/documentation_layout/article_card', portal: @portal, article: article %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<%= render "public/api/v1/portals/hero", portal: @portal %>
|
||||
<div class="max-w-5xl w-full flex flex-col flex-grow mx-auto py-8 px-4 md:px-8 gap-6">
|
||||
<div><%= render "public/api/v1/portals/featured_articles", articles: @portal.articles, categories: @portal.categories.where(locale: @locale), portal: @portal %></div>
|
||||
<div><%= render "public/api/v1/portals/featured_articles", articles: @portal.articles, categories: @portal.categories.where(locale: @locale), portal: @portal, recommended: @recommended_articles %></div>
|
||||
<%= render "public/api/v1/portals/home_categories", portal: @portal, locale: @locale %>
|
||||
</div>
|
||||
|
||||
+2
-3
@@ -120,10 +120,9 @@
|
||||
display_name: Message Reply To
|
||||
enabled: false
|
||||
deprecated: true
|
||||
- name: insert_article_in_reply
|
||||
display_name: Insert Article in Reply
|
||||
- name: branded_email_templates
|
||||
display_name: Branded Email Templates
|
||||
enabled: false
|
||||
deprecated: true
|
||||
- name: inbox_view
|
||||
display_name: Inbox View
|
||||
enabled: false
|
||||
|
||||
@@ -493,7 +493,10 @@ en:
|
||||
browse_by_topic_subtitle: Find guides, tutorials, and answers organised by category.
|
||||
popular_articles: Popular articles
|
||||
popular_articles_subtitle: What other people are reading right now.
|
||||
recommended: Recommended articles
|
||||
recommended_subtitle: Hand-picked articles to get you started.
|
||||
popular_label: 'Popular topics:'
|
||||
recommended_label: 'Recommended topics:'
|
||||
authors_others: "%{names} and %{count} others"
|
||||
primary_nav: Primary
|
||||
hero:
|
||||
@@ -529,6 +532,7 @@ en:
|
||||
light: Light
|
||||
dark: Dark
|
||||
featured_articles: Featured Articles
|
||||
recommended: Recommended articles
|
||||
uncategorized: Uncategorized
|
||||
404:
|
||||
title: Page not found
|
||||
|
||||
@@ -268,6 +268,7 @@ Rails.application.routes.draw do
|
||||
|
||||
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
|
||||
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
|
||||
resource :branded_email_layout, only: [:show, :update]
|
||||
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
|
||||
get :assignable_agents, on: :member
|
||||
get :campaigns, on: :member
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
class AddInboxScopeToEmailTemplates < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
add_column :email_templates, :inbox_id, :integer
|
||||
add_index :email_templates, :inbox_id, name: 'index_email_templates_on_inbox_id'
|
||||
|
||||
remove_index :email_templates, name: 'index_email_templates_on_name_and_account_id'
|
||||
ensure_no_duplicate_installation_templates!
|
||||
add_index :email_templates,
|
||||
[:name, :template_type, :locale],
|
||||
unique: true,
|
||||
where: 'account_id IS NULL AND inbox_id IS NULL',
|
||||
name: 'index_email_templates_on_installation_scope'
|
||||
add_index :email_templates,
|
||||
[:account_id, :name, :template_type, :locale],
|
||||
unique: true,
|
||||
where: 'account_id IS NOT NULL AND inbox_id IS NULL',
|
||||
name: 'index_email_templates_on_account_scope'
|
||||
add_index :email_templates,
|
||||
[:inbox_id, :name, :template_type, :locale],
|
||||
unique: true,
|
||||
where: 'inbox_id IS NOT NULL',
|
||||
name: 'index_email_templates_on_inbox_scope'
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :email_templates, name: 'index_email_templates_on_inbox_scope'
|
||||
remove_index :email_templates, name: 'index_email_templates_on_account_scope'
|
||||
remove_index :email_templates, name: 'index_email_templates_on_installation_scope'
|
||||
remove_index :email_templates, name: 'index_email_templates_on_inbox_id'
|
||||
|
||||
add_index :email_templates, [:name, :account_id], unique: true, name: 'index_email_templates_on_name_and_account_id'
|
||||
remove_column :email_templates, :inbox_id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_no_duplicate_installation_templates!
|
||||
duplicates = select_values <<~SQL.squish
|
||||
SELECT CONCAT(name, '/', template_type, '/', locale)
|
||||
FROM email_templates
|
||||
WHERE account_id IS NULL
|
||||
GROUP BY name, template_type, locale
|
||||
HAVING COUNT(*) > 1
|
||||
SQL
|
||||
return if duplicates.empty?
|
||||
|
||||
raise ActiveRecord::IrreversibleMigration,
|
||||
"Duplicate installation email templates must be resolved before migrating: #{duplicates.join(', ')}"
|
||||
end
|
||||
end
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
class RepurposeInsertArticleInReplyForBrandedEmailTemplates < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
Account.feature_branded_email_templates.find_each(batch_size: 100) do |account|
|
||||
account.disable_features(:branded_email_templates)
|
||||
account.save!(validate: false)
|
||||
end
|
||||
|
||||
remove_stale_default_feature
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_stale_default_feature
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
|
||||
return if config&.value.blank?
|
||||
|
||||
config.value = config.value.reject { |feature| feature['name'] == 'insert_article_in_reply' }
|
||||
config.save!
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
end
|
||||
+5
-1
@@ -987,7 +987,11 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
t.integer "locale", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["name", "account_id"], name: "index_email_templates_on_name_and_account_id", unique: true
|
||||
t.integer "inbox_id"
|
||||
t.index ["account_id", "name", "template_type", "locale"], name: "index_email_templates_on_account_scope", unique: true, where: "(account_id IS NOT NULL) AND (inbox_id IS NULL)"
|
||||
t.index ["inbox_id", "name", "template_type", "locale"], name: "index_email_templates_on_inbox_scope", unique: true, where: "(inbox_id IS NOT NULL)"
|
||||
t.index ["inbox_id"], name: "index_email_templates_on_inbox_id"
|
||||
t.index ["name", "template_type", "locale"], name: "index_email_templates_on_installation_scope", unique: true, where: "(account_id IS NULL) AND (inbox_id IS NULL)"
|
||||
end
|
||||
|
||||
create_table "folders", force: :cascade do |t|
|
||||
|
||||
@@ -4,6 +4,7 @@ module Current
|
||||
thread_mattr_accessor :account_user
|
||||
thread_mattr_accessor :executed_by
|
||||
thread_mattr_accessor :contact
|
||||
thread_mattr_accessor :inbox
|
||||
|
||||
def self.reset
|
||||
Current.user = nil
|
||||
@@ -11,5 +12,6 @@ module Current
|
||||
Current.account_user = nil
|
||||
Current.executed_by = nil
|
||||
Current.contact = nil
|
||||
Current.inbox = nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Branded Email Layout API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:layout) { '<html><body><header>Brand</header>{{ content_for_layout }}</body></html>' }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/branded_email_layout' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/branded_email_layout"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/branded_email_layout",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an administrator' do
|
||||
it 'returns the account-scoped branded email layout' do
|
||||
create(:email_template, :layout, account: account, body: layout)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/branded_email_layout",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['branded_email_layout']).to eq(layout)
|
||||
end
|
||||
|
||||
it 'returns null when no account-scoped layout exists' do
|
||||
get "/api/v1/accounts/#{account.id}/branded_email_layout",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['branded_email_layout']).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/branded_email_layout' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
|
||||
params: { branded_email_layout: layout },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { branded_email_layout: layout },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an administrator' do
|
||||
it 'updates account-scoped branded email layout when feature is enabled' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { branded_email_layout: layout },
|
||||
as: :json
|
||||
|
||||
template = EmailTemplate.account_branded_layout_template_for(account)
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(template.body).to eq(layout)
|
||||
expect(response.parsed_body['branded_email_layout']).to eq(layout)
|
||||
end
|
||||
|
||||
it 'clears account-scoped branded email layout when blank value is passed' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
create(:email_template, :layout, account: account, body: layout)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { branded_email_layout: '' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(EmailTemplate.account_branded_layout_template_for(account)).to be_nil
|
||||
expect(response.parsed_body['branded_email_layout']).to be_nil
|
||||
end
|
||||
|
||||
it 'clears account-scoped branded email layout when null string is passed' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
create(:email_template, :layout, account: account, body: layout)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { branded_email_layout: 'null' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(EmailTemplate.account_branded_layout_template_for(account)).to be_nil
|
||||
expect(response.parsed_body['branded_email_layout']).to be_nil
|
||||
end
|
||||
|
||||
it 'rejects updates when feature is disabled' do
|
||||
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { branded_email_layout: layout },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Branded email templates feature is not enabled')
|
||||
expect(EmailTemplate.account_branded_layout_template_for(account)).to be_nil
|
||||
end
|
||||
|
||||
it 'rejects account-scoped branded email layout without content slot' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { branded_email_layout: '<html>No slot</html>' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to include('must include {{ content_for_layout }}')
|
||||
end
|
||||
|
||||
it 'rejects account-scoped branded email layout with invalid liquid syntax' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { branded_email_layout: '<html>{{ content_for_layout }} {{ broken </html>' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to include('has invalid Liquid syntax')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -36,6 +36,18 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:payload].size).to eq(2)
|
||||
end
|
||||
|
||||
it 'does not include branded email layout in index responses' do
|
||||
email_inbox = create(:inbox, :with_email, account: account)
|
||||
create(:email_template, :layout, account: account, inbox: email_inbox, body: '<html>{{ content_for_layout }} Branded</html>')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/inboxes",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
inbox_data = JSON.parse(response.body, symbolize_names: true)[:payload].find { |item| item[:id] == email_inbox.id }
|
||||
expect(inbox_data).not_to have_key(:branded_email_layout)
|
||||
end
|
||||
|
||||
it 'returns only assigned inboxes of current_account as agent' do
|
||||
get "/api/v1/accounts/#{account.id}/inboxes",
|
||||
headers: agent.create_new_auth_token,
|
||||
@@ -161,8 +173,10 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
end
|
||||
|
||||
it 'returns imap details in inbox when admin' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
email_channel = create(:channel_email, account: account, imap_enabled: true, imap_login: 'test@test.com')
|
||||
email_inbox = create(:inbox, channel: email_channel, account: account)
|
||||
create(:email_template, :layout, account: account, inbox: email_inbox, body: '<html>{{ content_for_layout }} Branded</html>')
|
||||
|
||||
imap_connection = double
|
||||
allow(Mail).to receive(:connection).and_return(imap_connection)
|
||||
@@ -176,6 +190,35 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
|
||||
expect(data[:imap_enabled]).to be_truthy
|
||||
expect(data[:imap_login]).to eq('test@test.com')
|
||||
expect(data[:branded_email_layout]).to eq('<html>{{ content_for_layout }} Branded</html>')
|
||||
end
|
||||
|
||||
it 'does not return saved branded email layout when feature is disabled' do
|
||||
email_channel = create(:channel_email, account: account)
|
||||
email_inbox = create(:inbox, channel: email_channel, account: account)
|
||||
create(:email_template, :layout, account: account, inbox: email_inbox, body: '<html>{{ content_for_layout }} Branded</html>')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body).not_to have_key('branded_email_layout')
|
||||
end
|
||||
|
||||
it 'does not return branded email layout for an agent' do
|
||||
email_channel = create(:channel_email, account: account)
|
||||
email_inbox = create(:inbox, channel: email_channel, account: account)
|
||||
create(:inbox_member, user: agent, inbox: email_inbox)
|
||||
create(:email_template, :layout, account: account, inbox: email_inbox, body: '<html>{{ content_for_layout }} Branded</html>')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
data = JSON.parse(response.body, symbolize_names: true)
|
||||
expect(data[:branded_email_layout]).to be_nil
|
||||
end
|
||||
|
||||
context 'when it is a Twilio inbox' do
|
||||
@@ -577,6 +620,146 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
expect(email_channel.reload.email).to eq('emailtest@email.test')
|
||||
end
|
||||
|
||||
it 'updates branded email layout for email inbox when feature is enabled' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
email_channel = create(:channel_email, account: account)
|
||||
email_inbox = create(:inbox, channel: email_channel, account: account)
|
||||
layout = '<html><body><header>Brand</header>{{ content_for_layout }}</body></html>'
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { branded_email_layout: layout },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(email_inbox.reload.branded_email_layout).to eq(layout)
|
||||
expect(response.parsed_body['branded_email_layout']).to eq(layout)
|
||||
end
|
||||
|
||||
it 'rolls back branded email layout when inbox update fails' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
email_channel = create(:channel_email, account: account)
|
||||
email_inbox = create(:inbox, channel: email_channel, account: account)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { name: '', branded_email_layout: '<html>{{ content_for_layout }} Branded</html>' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(email_inbox.reload.branded_email_layout).to be_nil
|
||||
end
|
||||
|
||||
it 'clears branded email layout when blank value is passed' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
email_channel = create(:channel_email, account: account)
|
||||
email_inbox = create(:inbox, channel: email_channel, account: account)
|
||||
create(:email_template, :layout, account: account, inbox: email_inbox)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { branded_email_layout: '' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(email_inbox.reload.branded_email_layout).to be_nil
|
||||
end
|
||||
|
||||
it 'clears branded email layout when null string value is passed' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
email_channel = create(:channel_email, account: account)
|
||||
email_inbox = create(:inbox, channel: email_channel, account: account)
|
||||
create(:email_template, :layout, account: account, inbox: email_inbox)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { branded_email_layout: 'null' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(email_inbox.reload.branded_email_layout).to be_nil
|
||||
end
|
||||
|
||||
it 'rejects branded email layout when feature is disabled' do
|
||||
email_channel = create(:channel_email, account: account)
|
||||
email_inbox = create(:inbox, channel: email_channel, account: account)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { branded_email_layout: '<html>{{ content_for_layout }}</html>' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Branded email templates feature is not enabled')
|
||||
expect(email_inbox.reload.branded_email_layout).to be_nil
|
||||
end
|
||||
|
||||
it 'ignores blank branded email layout when feature is disabled' do
|
||||
email_channel = create(:channel_email, account: account)
|
||||
email_inbox = create(:inbox, channel: email_channel, account: account)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { name: 'Renamed Email Inbox', branded_email_layout: nil },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(email_inbox.reload.name).to eq('Renamed Email Inbox')
|
||||
expect(email_inbox.branded_email_layout).to be_nil
|
||||
end
|
||||
|
||||
it 'rejects branded email layout for non-email inboxes' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { branded_email_layout: '<html>{{ content_for_layout }}</html>' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Branded email layout is only supported for email inboxes')
|
||||
end
|
||||
|
||||
it 'ignores blank branded email layout for non-email inboxes' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { name: 'Renamed Inbox', branded_email_layout: '' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(inbox.reload.name).to eq('Renamed Inbox')
|
||||
end
|
||||
|
||||
it 'rejects branded email layout without content slot' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
email_channel = create(:channel_email, account: account)
|
||||
email_inbox = create(:inbox, channel: email_channel, account: account)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { branded_email_layout: '<html>No slot</html>' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to include('must include {{ content_for_layout }}')
|
||||
end
|
||||
|
||||
it 'rejects branded email layout with invalid liquid syntax' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
email_channel = create(:channel_email, account: account)
|
||||
email_inbox = create(:inbox, channel: email_channel, account: account)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { branded_email_layout: '<html>{{ content_for_layout }} {{ broken </html>' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to include('has invalid Liquid syntax')
|
||||
end
|
||||
|
||||
it 'updates twilio sms inbox when administrator' do
|
||||
twilio_sms_channel = create(:channel_twilio_sms, account: account)
|
||||
twilio_sms_inbox = create(:inbox, channel: twilio_sms_channel, account: account)
|
||||
|
||||
@@ -174,7 +174,8 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
|
||||
'default_locale' => 'en',
|
||||
'layout' => 'classic',
|
||||
'social_profiles' => {},
|
||||
'locale_translations' => {}
|
||||
'locale_translations' => {},
|
||||
'popular_content' => {}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@@ -106,6 +106,82 @@ RSpec.describe Public::Api::V1::PortalsController, type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /public/api/v1/portals/{portal_slug}/{locale} recommended content' do
|
||||
let(:category_a) { create(:category, portal: portal, account: account, name: 'Getting Started', locale: 'en') }
|
||||
let(:category_b) { create(:category, portal: portal, account: account, name: 'Billing', locale: 'en') }
|
||||
let(:alpha) { create(:article, account: account, author: agent, portal: portal, locale: 'en', status: :published, title: 'Alpha Guide') }
|
||||
let(:beta) { create(:article, account: account, author: agent, portal: portal, locale: 'en', status: :published, title: 'Beta Guide') }
|
||||
let(:gamma) { create(:article, account: account, author: agent, portal: portal, locale: 'en', status: :published, title: 'Gamma Guide') }
|
||||
|
||||
it 'renders recommended articles in the configured order' do
|
||||
portal.update!(config: { allowed_locales: %w[en], default_locale: 'en',
|
||||
popular_content: { 'en' => { 'article_ids' => [gamma.id, alpha.id] } } })
|
||||
|
||||
get "/hc/#{portal.slug}/en"
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include('Recommended articles')
|
||||
expect(response.body.index('Gamma Guide')).to be < response.body.index('Alpha Guide')
|
||||
end
|
||||
|
||||
it 'drops draft and other-locale ids from the recommended articles' do
|
||||
draft = create(:article, account: account, author: agent, portal: portal, locale: 'en', status: :draft, title: 'Draft Secret')
|
||||
spanish = create(:article, account: account, author: agent, portal: portal, locale: 'es', status: :published, title: 'Spanish Only')
|
||||
portal.update!(config: { allowed_locales: %w[en es], default_locale: 'en',
|
||||
popular_content: { 'en' => { 'article_ids' => [alpha.id, draft.id, spanish.id] } } })
|
||||
|
||||
get "/hc/#{portal.slug}/en"
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include('Alpha Guide')
|
||||
expect(response.body).not_to include('Draft Secret')
|
||||
expect(response.body).not_to include('Spanish Only')
|
||||
end
|
||||
|
||||
it 'does not leak one locale\'s recommendations into another' do
|
||||
portal.update!(config: { allowed_locales: %w[en es], default_locale: 'en',
|
||||
popular_content: { 'es' => { 'article_ids' => [alpha.id] } } })
|
||||
|
||||
get "/hc/#{portal.slug}/en"
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).not_to include('Recommended articles')
|
||||
end
|
||||
|
||||
it 'renders recommended categories as hero pills in the configured order' do
|
||||
portal.update!(config: { allowed_locales: %w[en], default_locale: 'en',
|
||||
popular_content: { 'en' => { 'category_ids' => [category_b.id, category_a.id] } } })
|
||||
|
||||
get "/hc/#{portal.slug}/en"
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include('recommended-pill')
|
||||
expect(response.body).to include('Getting Started', 'Billing')
|
||||
expect(response.body.index('Billing')).to be < response.body.index('Getting Started')
|
||||
end
|
||||
|
||||
it 'falls back to featured articles when no recommendations are configured' do
|
||||
create_list(:article, 6, account: account, author: agent, portal: portal, locale: 'en', status: :published, category: category_a)
|
||||
|
||||
get "/hc/#{portal.slug}/en"
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include('Featured Articles')
|
||||
expect(response.body).not_to include('Recommended articles')
|
||||
end
|
||||
|
||||
it 'renders recommended articles in the documentation layout' do
|
||||
portal.update!(config: { allowed_locales: %w[en], default_locale: 'en', layout: 'documentation',
|
||||
popular_content: { 'en' => { 'article_ids' => [alpha.id, beta.id] } } })
|
||||
|
||||
get "/hc/#{portal.slug}/en"
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include('Recommended articles')
|
||||
expect(response.body).to include('Alpha Guide', 'Beta Guide')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /public/api/v1/portals/{portal_slug}/sitemap' do
|
||||
context 'when custom_domain is present' do
|
||||
it 'returns a valid urlset sitemap with the correct namespace' do
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
FactoryBot.define do
|
||||
factory :email_template do
|
||||
name { 'MyString' }
|
||||
body { 'Email template body' }
|
||||
|
||||
trait :layout do
|
||||
name { EmailTemplate::BRANDED_LAYOUT_NAME }
|
||||
template_type { :layout }
|
||||
body { '<html><body>{{ content_for_layout }}</body></html>' }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,10 @@ describe EmailTemplates::DbResolverService do
|
||||
subject(:resolver) { described_class.using(EmailTemplate, {}) }
|
||||
|
||||
describe '#find_templates' do
|
||||
after do
|
||||
Current.reset
|
||||
end
|
||||
|
||||
context 'when template does not exist in db' do
|
||||
it 'return empty array' do
|
||||
expect(resolver.find_templates('test', '', false, [])).to eq([])
|
||||
@@ -53,7 +57,6 @@ describe EmailTemplates::DbResolverService do
|
||||
"DB Template - #{account_template.id}", handler, **template_details
|
||||
).inspect
|
||||
)
|
||||
Current.account = nil
|
||||
end
|
||||
|
||||
it 'return installation template when current account dont have template' do
|
||||
@@ -73,7 +76,69 @@ describe EmailTemplates::DbResolverService do
|
||||
"DB Template - #{installation_template.id}", handler, **template_details
|
||||
).inspect
|
||||
)
|
||||
Current.account = nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox template exists in db' do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, :with_email, account: account) }
|
||||
let!(:inbox_template) { create(:email_template, :layout, account: account, inbox: inbox, body: 'inbox {{ content_for_layout }}') }
|
||||
let!(:installation_template) { create(:email_template, :layout, body: 'global {{ content_for_layout }}') }
|
||||
|
||||
it 'returns inbox template when branded email templates feature is enabled' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
Current.account = account
|
||||
Current.inbox = inbox
|
||||
|
||||
expect(resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first.source).to eq(inbox_template.body)
|
||||
end
|
||||
|
||||
it 'skips account template when branded email templates feature is disabled' do
|
||||
account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}')
|
||||
Current.account = account
|
||||
Current.inbox = inbox
|
||||
|
||||
resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first
|
||||
expect(resolved_template.source).to eq(installation_template.body)
|
||||
expect(resolved_template.source).not_to eq(account_template.body)
|
||||
end
|
||||
|
||||
it 'returns account template when current inbox is not email and feature is enabled' do
|
||||
account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}')
|
||||
account.enable_features!(:branded_email_templates)
|
||||
Current.account = account
|
||||
Current.inbox = create(:inbox, account: account)
|
||||
|
||||
resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first
|
||||
expect(resolved_template.source).to eq(account_template.body)
|
||||
expect(resolved_template.source).not_to eq(installation_template.body)
|
||||
end
|
||||
|
||||
it 'skips account template when current inbox is not email and feature is disabled' do
|
||||
account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}')
|
||||
Current.account = account
|
||||
Current.inbox = create(:inbox, account: account)
|
||||
|
||||
resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first
|
||||
expect(resolved_template.source).to eq(installation_template.body)
|
||||
expect(resolved_template.source).not_to eq(account_template.body)
|
||||
end
|
||||
|
||||
it 'skips account template without an inbox when feature is disabled' do
|
||||
account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}')
|
||||
Current.account = account
|
||||
|
||||
resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first
|
||||
expect(resolved_template.source).to eq(installation_template.body)
|
||||
expect(resolved_template.source).not_to eq(account_template.body)
|
||||
end
|
||||
|
||||
it 'falls back to english when requested locale does not have a template' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
Current.account = account
|
||||
Current.inbox = inbox
|
||||
|
||||
expect(resolver.find_templates('base', 'layouts/mailer', false, { locale: [:fr] }).first.source).to eq(inbox_template.body)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -137,6 +137,34 @@ RSpec.describe ConversationReplyMailer do
|
||||
end
|
||||
end
|
||||
|
||||
context 'without summary for a non-email inbox' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account)) }
|
||||
let(:conversation) { create(:conversation, assignee: agent, account: account, inbox: inbox) }
|
||||
let!(:incoming_email_message) do
|
||||
create(:message, conversation: conversation, account: account, message_type: :incoming, content_type: :incoming_email)
|
||||
end
|
||||
let!(:outgoing_message) do
|
||||
create(:message, conversation: conversation, account: account, message_type: :outgoing, content: 'Outgoing email reply')
|
||||
end
|
||||
let(:mail) { described_class.reply_without_summary(conversation, incoming_email_message.id).deliver_now }
|
||||
|
||||
it 'applies the account branded email layout' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
create(:email_template, :layout, account: account, body: '<html><body>Account Brand {{ content_for_layout }}</body></html>')
|
||||
|
||||
expect(mail.decoded).to include('Account Brand')
|
||||
expect(mail.decoded).to include(outgoing_message.content)
|
||||
end
|
||||
|
||||
it 'does not apply an installation layout without an account override' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
create(:email_template, :layout, body: '<html><body>Installation Brand {{ content_for_layout }}</body></html>')
|
||||
|
||||
expect(mail.decoded).not_to include('Installation Brand')
|
||||
expect(mail.decoded).to include(outgoing_message.content)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with references header' do
|
||||
let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload }
|
||||
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
|
||||
@@ -243,6 +271,73 @@ RSpec.describe ConversationReplyMailer do
|
||||
expect(mail.decoded).to include message.content
|
||||
end
|
||||
|
||||
it 'does not apply branded email layout when feature is disabled' do
|
||||
create(
|
||||
:email_template,
|
||||
:layout,
|
||||
account: account,
|
||||
inbox: conversation.inbox,
|
||||
body: '<html><body>Inbox Brand {{ content_for_layout }}</body></html>'
|
||||
)
|
||||
|
||||
expect(mail.decoded).not_to include('Inbox Brand')
|
||||
expect(mail.decoded).to include(message.content)
|
||||
end
|
||||
|
||||
it 'exposes the reply sender in inbox branded email layouts' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
conversation.inbox.update!(business_name: 'Acme Support')
|
||||
create(
|
||||
:email_template,
|
||||
:layout,
|
||||
account: account,
|
||||
inbox: conversation.inbox,
|
||||
body: [
|
||||
'<html><body><header>{{ inbox.business_name }}</header>',
|
||||
'{{ content_for_layout }}',
|
||||
'<span>{{ agent.email }}</span>',
|
||||
'<footer>{{ message.sender_display_name }}</footer></body></html>'
|
||||
].join
|
||||
)
|
||||
|
||||
expect(mail.decoded).to include('Acme Support')
|
||||
expect(mail.decoded).to include(message.content)
|
||||
expect(message.sender).not_to eq(agent)
|
||||
expect(mail.decoded).to include(message.sender.email)
|
||||
expect(mail.decoded).to include(message.sender.available_name)
|
||||
end
|
||||
|
||||
it 'falls back to account branded email layout when inbox layout is absent' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
create(
|
||||
:email_template,
|
||||
:layout,
|
||||
account: account,
|
||||
body: '<html><body>Account Brand {{ content_for_layout }}</body></html>'
|
||||
)
|
||||
|
||||
expect(mail.decoded).to include('Account Brand')
|
||||
expect(mail.decoded).to include(message.content)
|
||||
end
|
||||
|
||||
it 'applies inbox branded email layout to template messages' do
|
||||
account.enable_features!(:branded_email_templates)
|
||||
create(
|
||||
:email_template,
|
||||
:layout,
|
||||
account: account,
|
||||
inbox: conversation.inbox,
|
||||
body: '<html><body>Template Brand {{ content_for_layout }}</body></html>'
|
||||
)
|
||||
template_message = create(:message, conversation: conversation, account: account, message_type: :template, content_type: :text,
|
||||
content: 'Automation template response', sender: agent)
|
||||
|
||||
template_mail = described_class.email_reply(template_message).deliver_now
|
||||
|
||||
expect(template_mail.decoded).to include('Template Brand')
|
||||
expect(template_mail.decoded).to include('Automation template response')
|
||||
end
|
||||
|
||||
it 'builds messageID properly' do
|
||||
expect(mail.message_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}")
|
||||
end
|
||||
@@ -736,6 +831,22 @@ RSpec.describe ConversationReplyMailer do
|
||||
it 'sets the correct in reply to id' do
|
||||
expect(mail.in_reply_to).to eq("account/#{conversation.account.id}/conversation/#{conversation.uuid}@#{domain}")
|
||||
end
|
||||
|
||||
it 'applies inbox branded email layout to conversation transcript' do
|
||||
new_account.enable_features!(:branded_email_templates)
|
||||
create(
|
||||
:email_template,
|
||||
:layout,
|
||||
account: new_account,
|
||||
inbox: conversation.inbox,
|
||||
body: '<html><body>Transcript Brand {{ content_for_layout }}</body></html>'
|
||||
)
|
||||
|
||||
transcript = described_class.conversation_transcript(conversation, 'customer@example.com').deliver_now
|
||||
|
||||
expect(transcript.decoded).to include('Transcript Brand')
|
||||
expect(transcript.decoded).to include(message.content)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe EmailTemplate do
|
||||
describe 'validations' do
|
||||
it 'allows the same layout name across installation, account, and inbox scopes' do
|
||||
account = create(:account)
|
||||
inbox = create(:inbox, :with_email, account: account)
|
||||
|
||||
create(:email_template, :layout, account: nil)
|
||||
create(:email_template, :layout, account: account)
|
||||
inbox_template = build(:email_template, :layout, account: account, inbox: inbox)
|
||||
|
||||
expect(inbox_template).to be_valid
|
||||
end
|
||||
|
||||
it 'allows an account-scoped layout after an inbox-scoped layout' do
|
||||
account = create(:account)
|
||||
inbox = create(:inbox, :with_email, account: account)
|
||||
create(:email_template, :layout, account: account, inbox: inbox)
|
||||
|
||||
account_template = build(:email_template, :layout, account: account)
|
||||
|
||||
expect(account_template).to be_valid
|
||||
end
|
||||
|
||||
it 'allows an installation-scoped layout after account and inbox-scoped layouts' do
|
||||
account = create(:account)
|
||||
inbox = create(:inbox, :with_email, account: account)
|
||||
create(:email_template, :layout, account: account)
|
||||
create(:email_template, :layout, account: account, inbox: inbox)
|
||||
|
||||
installation_template = build(:email_template, :layout, account: nil)
|
||||
|
||||
expect(installation_template).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects duplicate installation-scoped templates' do
|
||||
create(:email_template)
|
||||
duplicate_template = build(:email_template)
|
||||
|
||||
expect(duplicate_template).not_to be_valid
|
||||
expect(duplicate_template.errors[:name]).to include('has already been taken')
|
||||
end
|
||||
|
||||
it 'rejects duplicate account-scoped templates' do
|
||||
account = create(:account)
|
||||
create(:email_template, account: account)
|
||||
duplicate_template = build(:email_template, account: account)
|
||||
|
||||
expect(duplicate_template).not_to be_valid
|
||||
expect(duplicate_template.errors[:name]).to include('has already been taken')
|
||||
end
|
||||
|
||||
it 'rejects duplicate inbox-scoped templates' do
|
||||
account = create(:account)
|
||||
inbox = create(:inbox, :with_email, account: account)
|
||||
create(:email_template, account: account, inbox: inbox)
|
||||
duplicate_template = build(:email_template, account: account, inbox: inbox)
|
||||
|
||||
expect(duplicate_template).not_to be_valid
|
||||
expect(duplicate_template.errors[:name]).to include('has already been taken')
|
||||
end
|
||||
|
||||
it 'requires branded layouts to include content_for_layout' do
|
||||
template = build(:email_template, name: EmailTemplate::BRANDED_LAYOUT_NAME, template_type: :layout, body: '<html><body>No slot</body></html>')
|
||||
|
||||
expect(template).not_to be_valid
|
||||
expect(template.errors[:body]).to include('must include {{ content_for_layout }}')
|
||||
end
|
||||
|
||||
it 'validates liquid syntax' do
|
||||
template = build(:email_template, body: '{{ broken ')
|
||||
|
||||
expect(template).not_to be_valid
|
||||
expect(template.errors[:body].first).to include('has invalid Liquid syntax')
|
||||
end
|
||||
|
||||
it 'requires account to match inbox account when both are present' do
|
||||
inbox = create(:inbox, :with_email)
|
||||
other_account = create(:account)
|
||||
template = build(:email_template, :layout, account: other_account, inbox: inbox)
|
||||
|
||||
expect(template).not_to be_valid
|
||||
expect(template.errors[:account]).to include('must match inbox account')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.branded_layout_for' do
|
||||
it 'uses inbox, account, then installation fallback order' do
|
||||
account = create(:account)
|
||||
inbox = create(:inbox, :with_email, account: account)
|
||||
create(:email_template, :layout, body: 'Global {{ content_for_layout }}')
|
||||
account_template = create(:email_template, :layout, account: account, body: 'Account {{ content_for_layout }}')
|
||||
|
||||
expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en)).to eq(account_template)
|
||||
|
||||
inbox_template = create(:email_template, :layout, account: account, inbox: inbox, body: 'Inbox {{ content_for_layout }}')
|
||||
|
||||
expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en)).to eq(inbox_template)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.update_account_branded_layout!' do
|
||||
it 'creates and updates the account-scoped branded layout' do
|
||||
account = create(:account)
|
||||
|
||||
described_class.update_account_branded_layout!(account: account, body: 'Account {{ content_for_layout }}')
|
||||
|
||||
template = described_class.account_branded_layout_template_for(account)
|
||||
expect(template.body).to eq('Account {{ content_for_layout }}')
|
||||
|
||||
described_class.update_account_branded_layout!(account: account, body: 'Updated {{ content_for_layout }}')
|
||||
|
||||
expect(template.reload.body).to eq('Updated {{ content_for_layout }}')
|
||||
end
|
||||
|
||||
it 'clears the account-scoped branded layout for blank bodies' do
|
||||
account = create(:account)
|
||||
create(:email_template, :layout, account: account)
|
||||
|
||||
described_class.update_account_branded_layout!(account: account, body: '')
|
||||
|
||||
expect(described_class.account_branded_layout_template_for(account)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user