Merge branch 'develop' into codex/cw-7519-intercom-stalled-retry-15m

This commit is contained in:
Sony Mathew
2026-07-17 15:27:31 +05:30
committed by GitHub
54 changed files with 2231 additions and 55 deletions
@@ -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)
+38 -2
View File
@@ -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?
+2 -4
View File
@@ -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
+14
View File
@@ -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>
@@ -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>
@@ -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.",
+18
View File
@@ -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' => [],
+94 -2
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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
+1
View File
@@ -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
@@ -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
View File
@@ -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|
+2
View File
@@ -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
+7
View File
@@ -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
+126
View File
@@ -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