Compare commits

...
Author SHA1 Message Date
Sony Mathew 2d6768c218 feat(imports): retry stalled Intercom imports 2026-07-17 14:31:39 +05:30
Sivin VargheseandGitHub 5af26e45a9 chore: Cleanup help center layouts (#14812) 2026-07-17 14:07:52 +05:30
f948b0b8d9 test(onboarding): fix inbox channel dialog spec (#15048)
Restores the Inbox Channels dialog’s Facebook-gating test coverage by
isolating account feature dependencies that are unrelated to these
cases. This keeps the onboarding checks focused on whether Facebook is
configured and avoids initializing router and account-store state.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-17 13:57:34 +05:30
081e08c7c6 fix(whatsapp): skip template sync for suspended accounts (#15047)
Suspended accounts no longer participate in scheduled WhatsApp template
syncs. This avoids unnecessary external API calls while keeping the
existing refresh behavior unchanged for active accounts.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-17 11:57:07 +04:00
Sivin VargheseandGitHub dc3a4814aa fix: resolve {{conversation.id}} to display_id (#15041) 2026-07-17 12:55:48 +05:30
53 changed files with 913 additions and 327 deletions
@@ -4,7 +4,7 @@ class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseControll
DATA_IMPORT_FEATURE = 'data_import'.freeze
before_action :ensure_data_import_feature_enabled
before_action :set_data_import, only: [:show, :start, :abandon, :error_logs, :skip_logs]
before_action :set_data_import, only: [:show, :start, :retry_import, :abandon, :error_logs, :skip_logs]
before_action :check_authorization
def index
@@ -59,6 +59,24 @@ class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseControll
render_show
end
def retry_import
retry_service = DataImports::Intercom::RetryService.new(account: Current.account, data_import: @data_import)
retry_result = retry_service.perform
@data_import = retry_service.data_import
case retry_result
when :enqueue
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id)
render_show
when :not_stalled
render json: { message: 'This Intercom import is no longer stalled.' }, status: :unprocessable_entity
when :active_import_exists
render json: { message: 'Another Intercom import is already in progress.' }, status: :unprocessable_entity
when :access_token_missing
render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity
end
end
def abandon
@data_import.abandon!
render_show
@@ -24,7 +24,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def set_view_variant
request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
request.variant = if @is_plain_layout_enabled
:plain
elsif @portal_layout == 'documentation'
:documentation
end
end
def portal
@@ -65,6 +69,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
def render_404
portal
# set_locale can render_404 before the child's set_view_variant runs; set it here so plain 404s stay chrome-less
set_view_variant
render 'public/api/v1/portals/error/404', status: :not_found
end
+4
View File
@@ -1,6 +1,10 @@
class ConversationDrop < BaseDrop
include MessageFormatHelper
def id
@obj.try(:display_id)
end
def display_id
@obj.try(:display_id)
end
@@ -11,6 +11,10 @@ class DataImportsAPI extends ApiClient {
return axios.post(`${this.url}/${id}/start`);
}
retry(id) {
return axios.post(`${this.url}/${id}/retry`);
}
abandon(id) {
return axios.post(`${this.url}/${id}/abandon`);
}
@@ -461,6 +461,7 @@
"STATUS": "Status",
"IMPORTED": "Imported",
"CREATED": "Created",
"RETRY": "Retry",
"ABANDON": "Abandon"
},
"DETAIL": {
@@ -507,6 +508,8 @@
},
"ALERTS": {
"IMPORT_STARTED": "Intercom import has started.",
"IMPORT_RETRIED": "Intercom import has been queued to resume.",
"IMPORT_RETRY_FAILED": "Could not retry the Intercom import.",
"IMPORT_ABANDONED": "Intercom import has been abandoned.",
"IMPORT_FAILED": "Could not start the Intercom import."
}
@@ -6,6 +6,9 @@ vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
vi.mock('dashboard/composables/store', () => ({
useMapGetter: () => ({ value: {} }),
}));
vi.mock('dashboard/composables/useAccount', () => ({
useAccount: () => ({ isCloudFeatureEnabled: () => false }),
}));
vi.mock('../../inbox-setup/useChannelConnect', () => ({
useChannelConnect: () => ({
connectViaOAuth: vi.fn(),
@@ -26,6 +26,7 @@ const dataImport = ref(null);
const isLoading = ref(true);
const isRefreshing = ref(false);
const isPolling = ref(false);
const isRetrying = ref(false);
const isAbandoning = ref(false);
const isDownloadingErrorLogs = ref(false);
const isDownloadingSkipLogs = ref(false);
@@ -117,6 +118,19 @@ const abandonImport = async () => {
}
};
const retryImport = async () => {
isRetrying.value = true;
try {
const response = await DataImportsAPI.retry(dataImport.value.id);
dataImport.value = response.data;
useAlert(t('DATA_IMPORTS.ALERTS.IMPORT_RETRIED'));
} catch {
useAlert(t('DATA_IMPORTS.ALERTS.IMPORT_RETRY_FAILED'));
} finally {
isRetrying.value = false;
}
};
const downloadCsv = (response, filename) => {
const url = window.URL.createObjectURL(
new Blob([response.data], { type: 'text/csv' })
@@ -197,9 +211,11 @@ onBeforeUnmount(() => {
<ImportDetailHeader
:data-import="dataImport"
:is-refreshing="isRefreshing"
:is-retrying="isRetrying"
:is-abandoning="isAbandoning"
:is-polling="isPolling"
@refresh="fetchImport({ manual: true })"
@retry="retryImport"
@abandon="abandonImport"
/>
</template>
@@ -21,6 +21,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
isRetrying: {
type: Boolean,
default: false,
},
isAbandoning: {
type: Boolean,
default: false,
@@ -31,7 +35,7 @@ const props = defineProps({
},
});
defineEmits(['refresh', 'abandon']);
defineEmits(['refresh', 'retry', 'abandon']);
const { t } = useI18n();
@@ -90,11 +94,23 @@ const canAbandonImport = computed(() => isAbandonableImport(props.dataImport));
:title="$t('DATA_IMPORTS.MONITOR.REFRESH')"
@click="$emit('refresh')"
/>
<Button
v-if="dataImport?.stalled"
outline
slate
size="sm"
icon="i-lucide-rotate-ccw"
:is-loading="isRetrying"
:disabled="isAbandoning"
:label="$t('DATA_IMPORTS.TABLE.RETRY')"
@click="$emit('retry')"
/>
<Button
v-if="canAbandonImport"
ruby
size="sm"
:is-loading="isAbandoning"
:disabled="isRetrying"
:label="$t('DATA_IMPORTS.TABLE.ABANDON')"
@click="$emit('abandon')"
/>
@@ -0,0 +1,85 @@
import { mount } from '@vue/test-utils';
import ImportDetailHeader from '../components/ImportDetailHeader.vue';
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
const ButtonStub = {
name: 'Button',
props: {
label: { type: String, default: '' },
icon: { type: String, default: '' },
isLoading: { type: Boolean, default: false },
},
emits: ['click'],
template: `
<button
:data-label="label"
:data-icon="icon"
:data-loading="isLoading"
@click="$emit('click')"
>
{{ label }}
</button>
`,
};
const BaseSettingsHeaderStub = {
template: `
<section>
<slot name="title" />
<slot name="description" />
</section>
`,
};
const mountHeader = props =>
mount(ImportDetailHeader, {
props,
global: {
stubs: {
Button: ButtonStub,
BaseSettingsHeader: BaseSettingsHeaderStub,
},
mocks: {
$t: key => key,
},
},
});
describe('ImportDetailHeader', () => {
const activeImport = {
id: 1,
name: 'Intercom import',
data_type: 'intercom',
source_provider: 'intercom',
status: 'processing',
stalled: true,
};
it('shows Retry between Refresh and Abandon for stalled imports', async () => {
const wrapper = mountHeader({ dataImport: activeImport });
const buttons = wrapper.findAll('button');
expect(buttons.map(button => button.attributes('data-label'))).toEqual([
'',
'DATA_IMPORTS.TABLE.RETRY',
'DATA_IMPORTS.TABLE.ABANDON',
]);
await buttons[1].trigger('click');
expect(wrapper.emitted('retry')).toHaveLength(1);
});
it('hides Retry when the server does not report the import as stalled', () => {
const wrapper = mountHeader({
dataImport: { ...activeImport, stalled: false },
});
expect(
wrapper.find('[data-label="DATA_IMPORTS.TABLE.RETRY"]').exists()
).toBe(false);
});
});
@@ -0,0 +1,104 @@
import { flushPromises, mount } from '@vue/test-utils';
import { KeepAlive, defineComponent, h, nextTick } from 'vue';
import { useAlert } from 'dashboard/composables';
import DataImportsAPI from 'dashboard/api/dataImports';
import Show from '../Show.vue';
vi.mock('dashboard/api/dataImports', () => ({
default: {
show: vi.fn(),
retry: vi.fn(),
},
}));
vi.mock('dashboard/composables', () => ({
useAlert: vi.fn(),
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
vi.mock('vue-router', async importOriginal => ({
...(await importOriginal()),
useRoute: () => ({ params: { dataImportId: 1 } }),
}));
const SettingsLayoutStub = {
template: `
<main>
<slot name="header" />
<slot name="body" />
</main>
`,
};
const ImportDetailHeaderStub = {
name: 'ImportDetailHeader',
emits: ['retry'],
template: '<button data-test="retry" @click="$emit(\'retry\')" />',
};
const mountShow = () => {
const Host = defineComponent({
render() {
return h(KeepAlive, null, { default: () => h(Show) });
},
});
return mount(Host, {
global: {
stubs: {
SettingsLayout: SettingsLayoutStub,
ImportDetailHeader: ImportDetailHeaderStub,
ImportSummaryTiles: true,
ImportProgress: true,
ImportErrorsSection: true,
ImportSkipLogsSection: true,
},
mocks: {
$t: key => key,
},
},
});
};
describe('data import detail actions', () => {
beforeEach(() => {
vi.useFakeTimers();
DataImportsAPI.show.mockResolvedValue({
data: {
id: 1,
status: 'processing',
stalled: true,
skip_logs_filters: {},
},
});
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it('retries a stalled import and replaces the page state', async () => {
DataImportsAPI.retry.mockResolvedValue({
data: {
id: 1,
status: 'pending',
stalled: false,
skip_logs_filters: {},
},
});
const wrapper = mountShow();
await nextTick();
await flushPromises();
await wrapper.find('[data-test="retry"]').trigger('click');
await flushPromises();
expect(DataImportsAPI.retry).toHaveBeenCalledWith(1);
expect(useAlert).toHaveBeenCalledWith('DATA_IMPORTS.ALERTS.IMPORT_RETRIED');
wrapper.unmount();
});
});
@@ -2,7 +2,9 @@ class Channels::Whatsapp::TemplatesSyncSchedulerJob < ApplicationJob
queue_as :low
def perform
Channel::Whatsapp.order(Arel.sql('message_templates_last_updated IS NULL DESC, message_templates_last_updated ASC'))
Channel::Whatsapp.joins(:account)
.merge(Account.active)
.order(Arel.sql('message_templates_last_updated IS NULL DESC, message_templates_last_updated ASC'))
.where('message_templates_last_updated <= ? OR message_templates_last_updated IS NULL', 3.hours.ago)
.limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT)
.each do |channel|
+5
View File
@@ -33,6 +33,7 @@
#
class DataImport < ApplicationRecord
ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY = 'active_intercom_import_run_id'.freeze
INTERCOM_STALLED_AFTER = 10.minutes
LEGACY_DATA_TYPES = ['contacts'].freeze
INTEGRATION_DATA_TYPES = ['intercom'].freeze
IMPORT_TYPES = %w[contacts conversations].freeze
@@ -71,6 +72,10 @@ class DataImport < ApplicationRecord
failed? || abandoned?
end
def stalled?
intercom_import? && (pending? || processing?) && updated_at <= INTERCOM_STALLED_AFTER.ago
end
def abandonable?
intercom_import? && (pending? || processing?)
end
+4
View File
@@ -19,6 +19,10 @@ class DataImportPolicy < ApplicationPolicy
show?
end
def retry_import?
show?
end
def abandon?
show?
end
@@ -0,0 +1,27 @@
class DataImports::Intercom::RetryService
attr_reader :data_import
def initialize(account:, data_import:)
@account = account
@data_import = data_import
end
def perform
@account.with_lock do
@data_import.reload
next :not_stalled unless @data_import.stalled?
next :active_import_exists if another_active_import?
next :access_token_missing if @data_import.access_token.blank?
@data_import.assign_active_intercom_import_run_id
@data_import.update!(status: :pending)
:enqueue
end
end
private
def another_active_import?
@account.data_imports.active_intercom.where.not(id: @data_import.id).exists?
end
end
@@ -5,6 +5,7 @@ json.source_type data_import.source_type
json.source_provider data_import.source_provider
json.import_types data_import.import_types
json.status data_import.status
json.stalled data_import.stalled?
json.total_records data_import.total_records
json.processed_records data_import.processed_records
json.stats data_import.stats
@@ -3,9 +3,9 @@
<head>
<%= render 'layouts/portal_head' %>
</head>
<body class="font-inter">
<body class="font-inter bg-white dark:bg-slate-900">
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
<main class="flex flex-col min-h-screen main-content" role="main">
<%= render 'public/api/v1/portals/documentation_layout/topbar',
portal: @portal, locale: @locale, article: @article, category: @category %>
<div class="bg-white dark:bg-n-slate-2 flex-1 flex font-inter tracking-normal [font-optical-sizing:auto]">
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="<%= html_lang_attribute(I18n.locale) %>">
<head>
<%= render 'layouts/portal_head' %>
</head>
<body class="font-default bg-white dark:bg-slate-900">
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen main-content" role="main">
<%= yield %>
</main>
</div>
<%= render 'layouts/portal_scripts' %>
</body>
</html>
+2 -2
View File
@@ -6,9 +6,9 @@
<body class="font-default bg-white dark:bg-slate-900">
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen main-content" role="main">
<%= render 'public/api/v1/portals/header', portal: @portal unless @is_plain_layout_enabled %>
<%= render 'public/api/v1/portals/header', portal: @portal %>
<%= yield %>
<%= render 'public/api/v1/portals/footer' unless @is_plain_layout_enabled || @portal.account.feature_enabled?('disable_branding') %>
<%= render 'public/api/v1/portals/footer' unless @portal.account.feature_enabled?('disable_branding') %>
</main>
</div>
<%= render 'layouts/portal_scripts' %>
@@ -0,0 +1,61 @@
<%# locals: (category:, portal:) %>
<% category_link_params = {
portal_slug: portal.slug,
category_locale: category.locale,
category_slug: category.slug,
theme: @theme_from_params,
is_plain_layout_enabled: true
}
%>
<section class="flex flex-col w-full h-full lg:container">
<div class="flex flex-col gap-8 h-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">
<% if category.icon.present? %>
<span class="text-lg rounded-md cursor-pointer inline-flex"><%= render_emoji_or_icon(category.icon, category.icon_color) %></span>
<% end %>
<h3 class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer hover:underline">
<a href="<%= generate_category_link(category_link_params) %>">
<%= category.name %>
</a>
</h3>
</div>
<% if category.description.present? %>
<span class="text-base text-slate-600 dark:text-slate-400"><%= category.description %></span>
<% end %>
</div>
</div>
<div class="flex flex-col gap-2 flex-grow <%= category.description.blank? && '-mt-4' %>">
<% if category.articles.published.size==0 %>
<div class="flex items-center justify-center h-full mb-4 bg-slate-50 dark:bg-slate-800 rounded-xl">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
</div>
<% else %>
<% category.articles.published.order(position: :asc).take(5).each do |article| %>
<a class="leading-7 text-slate-700 dark:text-slate-100" href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, true) %>">
<div class="flex justify-between hover:cursor-pointer items-start py-1 rounded-lg gap-6 hover:underline">
<%= article.title %>
<span class="flex items-center font-normal mt-1.5">
<%= render partial: 'icons/chevron-right' %>
</span>
</div>
</a>
<% end %>
<% end %>
</div>
<div class="flex justify-between flex-row items-center">
<div class="flex flex-row items-center gap-1">
<%= render "public/api/v1/portals/authors", category: category, show_expanded: false %>
<span class="text-slate-600 dark:text-slate-400">•</span>
<span class="text-sm font-medium text-slate-600 dark:text-slate-400"><%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.order(position: :asc).size %></span>
</div>
<div>
<a href="<%= generate_category_link(category_link_params) %>" class="flex flex-row items-center text-sm font-medium text-slate-600 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-100">
<%= I18n.t('public_portal.common.view_all_articles') %>
</a>
</div>
</div>
</div>
</section>
@@ -1,28 +1,29 @@
<%# locals: (category:, portal:) %>
<% category_link_params = {
portal_slug: portal.slug,
category_locale: category.locale,
category_slug: category.slug,
theme: @theme_from_params,
is_plain_layout_enabled: @is_plain_layout_enabled
is_plain_layout_enabled: false
}
%>
<section class="flex flex-col w-full h-full lg:container">
<div id="<%= !@is_plain_layout_enabled ? 'category-block' : '' %>" class="flex flex-col gap-8 h-full <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 py-5 px-3 rounded-lg' : '' %>">
<div id="category-block" class="flex flex-col gap-8 h-full border border-solid border-slate-100 dark:border-slate-800 py-5 px-3 rounded-lg">
<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 <%= !@is_plain_layout_enabled && 'px-1' %>">
<div class="flex flex-row items-center gap-2 px-1">
<% if category.icon.present? %>
<span class="text-lg rounded-md cursor-pointer inline-flex <%= !@is_plain_layout_enabled && 'pl-1' %>"><%= render_emoji_or_icon(category.icon, category.icon_color) %></span>
<span class="text-lg rounded-md cursor-pointer inline-flex pl-1"><%= render_emoji_or_icon(category.icon, category.icon_color) %></span>
<% end %>
<h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : '' %> <%= category.icon.blank? && !@is_plain_layout_enabled ? 'pl-1' : '' %>">
<h3 id="category-name" class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= category.icon.blank? ? 'pl-1' : '' %>">
<a href="<%= generate_category_link(category_link_params) %>">
<%= category.name %>
</a>
</h3>
</div>
<% if category.description.present? %>
<span class="text-base text-slate-600 dark:text-slate-400 <%= !@is_plain_layout_enabled && 'px-2' %>"><%= category.description %></span>
<span class="text-base text-slate-600 dark:text-slate-400 px-2"><%= category.description %></span>
<% end %>
</div>
</div>
@@ -33,8 +34,8 @@
</div>
<% else %>
<% category.articles.published.order(position: :asc).take(5).each do |article| %>
<a class="leading-7 text-slate-700 dark:text-slate-100" href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
<div id="<%= !@is_plain_layout_enabled ? 'category-item' : '' %>" class="flex justify-between hover:cursor-pointer items-start py-1 rounded-lg gap-6 <%= !@is_plain_layout_enabled ? 'px-2' : 'hover:underline' %>">
<a class="leading-7 text-slate-700 dark:text-slate-100" href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, false) %>">
<div id="category-item" class="flex justify-between hover:cursor-pointer items-start py-1 rounded-lg gap-6 px-2">
<%= article.title %>
<span class="flex items-center font-normal mt-1.5">
<%= render partial: 'icons/chevron-right' %>
@@ -44,7 +45,7 @@
<% end %>
<% end %>
</div>
<div class="flex justify-between flex-row items-center <%= !@is_plain_layout_enabled && 'px-2' %>">
<div class="flex justify-between flex-row items-center px-2">
<div class="flex flex-row items-center gap-1">
<%= render "public/api/v1/portals/authors", category: category, show_expanded: false %>
<span class="text-slate-600 dark:text-slate-400">•</span>
@@ -13,7 +13,7 @@
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 gap-x-2 gap-y-2">
<% featured_articles.each do |article| %>
<a class="leading-7 text-slate-700 dark:text-slate-100" href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
<a class="leading-7 text-slate-700 dark:text-slate-100" href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, false) %>">
<div id="category-item" class="flex items-start justify-between gap-6 px-2 py-1 rounded-lg">
<%= article.title %>
<span class="flex items-center font-normal mt-1.5">
@@ -1,7 +1,7 @@
<header class="sticky top-0 z-50 w-full bg-white shadow-sm dark:bg-slate-900">
<nav class="hidden sm:flex max-w-5xl px-4 mx-auto md:px-8" aria-label="Top">
<div class="flex items-center w-full py-5 overflow-hidden">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center min-w-0 h-10">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, false) %>" class="flex items-center min-w-0 h-10">
<% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
<% end %>
@@ -102,7 +102,7 @@
<nav class="flex sm:hidden max-w-5xl px-4 mx-auto" aria-label="Mobile Top">
<div class="flex items-center justify-between w-full py-5">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center min-w-0 h-10 text-lg font-semibold text-slate-900 dark:text-white">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, false) %>" class="flex items-center min-w-0 h-10 text-lg font-semibold text-slate-900 dark:text-white">
<% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
<% end %>
@@ -1,4 +1,3 @@
<% if !@is_plain_layout_enabled %>
<% content_for :head do %>
<title><%= @portal.display_title(@locale) %></title>
<meta name="title" content="<%= @portal.display_title(@locale) %>">
@@ -22,4 +21,3 @@
</div>
</div>
</section>
<% end %>
@@ -0,0 +1,13 @@
<%# locals: (portal:, locale:) %>
<%# Categories with articles %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-6">
<% portal.categories.where(locale: locale).joins(:articles).where(articles: { status: :published }).order(position: :asc).group('categories.id').each do |category| %>
<%= render "public/api/v1/portals/category-block", category: category, portal: portal %>
<% end %>
</div>
<%# Uncategorized articles %>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-6">
<% if portal.articles.where(status: :published, category_id: nil, locale: locale).count > 0 %>
<%= render "public/api/v1/portals/uncategorized-block", portal: portal %>
<% end %>
</div>
@@ -0,0 +1,28 @@
<%# locals: (portal:) %>
<section class="flex flex-col w-full h-full lg:container">
<div class="flex flex-col gap-8 h-full">
<div class="flex justify-between items-center w-full">
<h3 class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer hover:underline">
<%= I18n.t('public_portal.header.uncategorized') %>
</h3>
</div>
<div class="-mt-4">
<% portal.articles.published.where(category_id: nil, locale: @locale).order(position: :asc).take(5).each do |article| %>
<a
class="leading-7 text-slate-700 dark:text-slate-100"
href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, true) %>"
>
<div class="flex justify-between hover:cursor-pointer items-center py-1 rounded-lg gap-3 hover:underline">
<%= article.title %>
<span class="flex items-center font-normal">
<%= render partial: 'icons/chevron-right' %>
</span>
</div>
</a>
<% end %>
</div>
<div class="flex justify-between flex-row items-center">
<span class="text-sm font-medium text-slate-600 dark:text-slate-400"><%= render 'public/api/v1/portals/article_count', article_count: portal.articles.published.where(category_id: nil, locale: @locale).size %></span>
</div>
</div>
</section>
@@ -1,7 +1,8 @@
<%# locals: (portal:) %>
<section class="flex flex-col w-full h-full lg:container">
<div id="<%= !@is_plain_layout_enabled ? 'category-block' : '' %>" class="flex flex-col gap-8 h-full <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 py-5 px-3 rounded-lg' : '' %>">
<div class="flex justify-between items-center w-full <%= !@is_plain_layout_enabled ? 'px-1' : '' %>">
<h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : 'pl-1' %>">
<div id="category-block" class="flex flex-col gap-8 h-full border border-solid border-slate-100 dark:border-slate-800 py-5 px-3 rounded-lg">
<div class="flex justify-between items-center w-full px-1">
<h3 id="category-name" class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer pl-1">
<%= I18n.t('public_portal.header.uncategorized') %>
</h3>
</div>
@@ -9,9 +10,9 @@
<% portal.articles.published.where(category_id: nil, locale: @locale).order(position: :asc).take(5).each do |article| %>
<a
class="leading-7 text-slate-700 dark:text-slate-100"
href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, false) %>"
>
<div id="<%= !@is_plain_layout_enabled ? 'category-item' : '' %>" class="flex justify-between hover:cursor-pointer items-center py-1 rounded-lg gap-3 <%= !@is_plain_layout_enabled ? 'px-2' : 'hover:underline' %>">
<div id="category-item" class="flex justify-between hover:cursor-pointer items-center py-1 rounded-lg gap-3 px-2">
<%= article.title %>
<span class="flex items-center font-normal">
<%= render partial: 'icons/chevron-right' %>
@@ -20,7 +21,7 @@
</a>
<% end %>
</div>
<div class="flex justify-between flex-row items-center <%= !@is_plain_layout_enabled && 'px-2' %>">
<div class="flex justify-between flex-row items-center px-2">
<span class="text-sm font-medium text-slate-600 dark:text-slate-400"><%= render 'public/api/v1/portals/article_count', article_count: portal.articles.published.where(category_id: nil, locale: @locale).size %></span>
</div>
</div>
@@ -0,0 +1,8 @@
<%# locals: (parsed_content:, content_padding: 'pt-8 pb-12') %>
<%# Classic/Plain width wrapper. Body itself is the shared articles/content partial. %>
<div class="flex max-w-5xl w-full px-4 md:px-8 mx-auto <%= content_padding %>">
<div class="flex-grow flex-2 max-w-3xl mx-auto w-full min-w-0 font-inter tracking-normal">
<%= render 'public/api/v1/portals/articles/content', parsed_content: parsed_content %>
</div>
<div class="flex-1" id="cw-hc-toc"></div>
</div>
@@ -1,22 +1,23 @@
<%# locals: (article:, plain: false) %>
<% category_link_params = {
portal_slug: @portal.slug,
category_locale: @article.category&.locale,
category_slug: @article.category&.slug,
theme: @theme_from_params,
is_plain_layout_enabled: @is_plain_layout_enabled
is_plain_layout_enabled: plain
}
%>
<div class="flex flex-row items-center gap-px mb-6">
<a
class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, @article.category&.locale, @theme_from_params, @is_plain_layout_enabled) %>"
class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer <%= plain && 'hover:underline' %> leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, @locale, @theme_from_params, plain) %>"
>
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300"><%= render partial: 'icons/chevron-right' %></span>
<% if @article.category %>
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 whitespace-nowrap hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold" href="<%= generate_category_link(category_link_params) %>">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 whitespace-nowrap hover:cursor-pointer <%= plain && 'hover:underline' %> leading-8 font-semibold" href="<%= generate_category_link(category_link_params) %>">
<%= @article.category&.name %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300"><%= render partial: 'icons/chevron-right' %></span>
@@ -0,0 +1,19 @@
<%# locals: (parsed_content:, link_class: 'prose-a:underline') %>
<%# Shared article body (id="cw-article-content"). Same rendering in all 3 layouts;
each layout controls its own width via the wrapping element.
link_class is layout-specific: classic/plain default to always-underlined default-color
links; documentation overrides with the portal-colored, underline-on-hover style. %>
<div id="cw-article-content"
class="prose dark:prose-invert max-w-none break-words
prose-headings:font-620
prose-p:font-420 prose-li:font-420 prose-blockquote:font-420 prose-td:font-420
<%= link_class %>
[&_li>p]:m-0
[&_.tableWrapper]:overflow-x-auto [&_.tableWrapper]:mb-4 [&_.tableWrapper]:rounded-lg [&_.tableWrapper]:border [&_.tableWrapper]:border-solid [&_.tableWrapper]:border-n-weak
[&_table]:!my-0 [&_table]:!min-w-full [&_table]:!border-separate [&_table]:!border-spacing-0
[&_th]:!bg-n-slate-2 [&_th]:!text-n-slate-12 [&_th]:!font-semibold [&_th]:!text-start [&_th]:!px-3 [&_th]:!py-2 [&_th]:!border-0 [&_th]:!border-b [&_th]:!border-solid [&_th]:!border-n-weak
[&_td]:!text-n-slate-11 [&_td]:!align-top [&_td]:!text-start [&_td]:!px-3 [&_td]:!py-2 [&_td]:!border-0 [&_td]:!border-b [&_td]:!border-solid [&_td]:!border-n-weak
[&_th:not(:last-child)]:!border-e [&_td:not(:last-child)]:!border-e
[&_tr:last-child_td]:!border-b-0">
<%= parsed_content %>
</div>
@@ -0,0 +1,21 @@
<%# locals: (article:, portal:, locale:, og_image_url: nil) %>
<title><%= article.title %> | <%= portal.display_title(locale) %></title>
<% if article.meta["title"].present? %>
<meta name="title" content="<%= article.meta["title"] %>">
<meta property="og:title" content="<%= article.meta["title"] %>">
<meta name="twitter:title" content="<%= article.meta["title"] %>">
<% end %>
<% if article.meta["description"].present? %>
<meta name="description" content="<%= article.meta["description"] %>">
<meta property="og:description" content="<%= article.meta["description"] %>">
<meta name="twitter:description" content="<%= article.meta["description"] %>">
<% end %>
<% if article.meta["tags"].present? %>
<meta name="tags" content="<%= article.meta["tags"].join(',') %>">
<% end %>
<% if og_image_url.present? %>
<meta name="twitter:card" content="summary_large_image">
<meta name="og:image" content="<%= og_image_url.html_safe %>">
<meta property="og:image" content="<%= og_image_url.html_safe %>">
<meta name="twitter:image" content="<%= og_image_url.html_safe %>">
<% end %>
@@ -1,36 +1,36 @@
<div class="bg-slate-50 dark:bg-slate-800">
<div class="w-full max-w-4xl px-6 py-16 mx-auto space-y-12">
<div class="space-y-4">
<div>
<a
class="leading-8 text-slate-800 hover:underline"
href="<%= generate_home_link(@portal.slug, @category.present? ? @category.slug : '', @theme_from_params, @is_plain_layout_enabled) %>"
>
<%= @portal.localized_value('name', @locale) %> <%= I18n.t('public_portal.common.home') %>
</a>
<span>/</span>
<span>/</span>
</div>
<% @articles.each do |article| %>
<h1 class="text-4xl font-semibold leading-snug md:tracking-normal md:text-5xl text-slate-900 dark:text-white">
<%= article.title %></h1>
<div class="flex flex-col items-start justify-between w-full pt-2 md:flex-row md:items-center">
<div class="flex items-center space-x-2">
<img src="<%= article.author.avatar_url %>" alt="" class="w-12 border rounded-full h-812">
<div>
<h5 class="mb-2 text-base font-medium text-slate-900 dark:text-white"><%= article.author.name %></h5>
<p class="text-sm font-normal text-slate-700 dark:text-slate-100">
<%= article.author.updated_at.strftime("%B %d %Y") %></p>
</div>
</div>
</div>
<% end %>
</div>
</div>
<div class="w-full max-w-4xl px-6 py-16 mx-auto space-y-12">
<div class="space-y-4">
<div>
<a
class="leading-8 text-slate-800 hover:underline"
href="<%= generate_home_link(@portal.slug, @category.present? ? @category.slug : '', @theme_from_params, @is_plain_layout_enabled) %>"
>
<%= @portal.localized_value('name', @locale) %> <%= I18n.t('public_portal.common.home') %>
</a>
<span>/</span>
<span>/</span>
</div>
<% @articles.each do |article| %>
<h1 class="text-4xl font-semibold leading-snug md:tracking-normal md:text-5xl text-slate-900 dark:text-white">
<%= article.title %></h1>
<div class="flex flex-col items-start justify-between w-full pt-2 md:flex-row md:items-center">
<div class="flex items-center space-x-2">
<img src="<%= article.author.avatar_url %>" alt="" class="w-12 border rounded-full h-812">
<div>
<h5 class="mb-2 text-base font-medium text-slate-900 dark:text-white"><%= article.author.name %></h5>
<p class="text-sm font-normal text-slate-700 dark:text-slate-100">
<%= article.author.updated_at.strftime("%B %d %Y") %></p>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
<div class="flex-grow w-full max-w-4xl px-8 py-16 mx-auto space-y-12">
<article class="space-y-8">
<div class="max-w-3xl font-sans text-lg leading-8 text-slate-800 dark:text-slate-50 blog-content">
</div>
</article>
<article class="space-y-8">
<div class="max-w-3xl font-sans text-lg leading-8 text-slate-800 dark:text-slate-50 blog-content">
</div>
</article>
</div>
@@ -11,20 +11,9 @@
<%= render 'public/api/v1/portals/documentation_layout/articles/header',
portal: @portal, article: @article %>
<div id="cw-article-content"
class="prose dark:prose-invert max-w-none break-words
prose-headings:font-620
prose-p:font-420 prose-li:font-420 prose-blockquote:font-420 prose-td:font-420
prose-a:text-n-portal prose-a:no-underline hover:prose-a:underline
[&_li>p]:m-0
[&_.tableWrapper]:overflow-x-auto [&_.tableWrapper]:mb-4 [&_.tableWrapper]:rounded-lg [&_.tableWrapper]:border [&_.tableWrapper]:border-solid [&_.tableWrapper]:border-n-weak
[&_table]:!my-0 [&_table]:!min-w-full [&_table]:!border-separate [&_table]:!border-spacing-0
[&_th]:!bg-n-slate-2 [&_th]:!text-n-slate-12 [&_th]:!font-semibold [&_th]:!text-start [&_th]:!px-3 [&_th]:!py-2 [&_th]:!border-0 [&_th]:!border-b [&_th]:!border-solid [&_th]:!border-n-weak
[&_td]:!text-n-slate-11 [&_td]:!align-top [&_td]:!text-start [&_td]:!px-3 [&_td]:!py-2 [&_td]:!border-0 [&_td]:!border-b [&_td]:!border-solid [&_td]:!border-n-weak
[&_th:not(:last-child)]:!border-e [&_td:not(:last-child)]:!border-e
[&_tr:last-child_td]:!border-b-0">
<%= @parsed_content %>
</div>
<%= render 'public/api/v1/portals/articles/content',
parsed_content: @parsed_content,
link_class: 'prose-a:text-n-portal prose-a:no-underline hover:prose-a:underline' %>
<% if @article.category %>
<footer class="mt-20 pt-8 border-t border-n-weak flex items-center justify-between gap-4 flex-wrap">
@@ -0,0 +1,11 @@
<% content_for :head do %>
<%= render 'public/api/v1/portals/articles/meta_head',
article: @article, portal: @portal, locale: @locale, og_image_url: @og_image_url %>
<% end %>
<div class="max-w-5xl mx-auto space-y-4 w-full px-4 md:px-8 py-4">
<%= render "public/api/v1/portals/articles/article_header", article: @article, plain: true %>
</div>
<%= render "public/api/v1/portals/articles/article_body",
parsed_content: @parsed_content, content_padding: 'py-4' %>
@@ -1,27 +1,8 @@
<% content_for :head do %>
<title><%= @article.title %> | <%= @portal.display_title(@locale) %></title>
<% if @article.meta["title"].present? %>
<meta name="title" content="<%= @article.meta["title"] %>">
<meta property="og:title" content="<%= @article.meta["title"] %>">
<meta name="twitter:title" content="<%= @article.meta["title"] %>">
<% end %>
<% if @article.meta["description"].present? %>
<meta name="description" content="<%= @article.meta["description"] %>">
<meta property="og:description" content="<%= @article.meta["description"] %>">
<meta name="twitter:description" content="<%= @article.meta["description"] %>">
<% end %>
<% if @article.meta["tags"].present? %>
<meta name="tags" content="<%= @article.meta["tags"].join(',') %>">
<% end %>
<% if @og_image_url.present? %>
<meta name="twitter:card" content="summary_large_image">
<meta name="og:image" content="<%= @og_image_url.html_safe %>">
<meta property="og:image" content="<%= @og_image_url.html_safe %>">
<meta name="twitter:image" content="<%= @og_image_url.html_safe %>">
<% end %>
<%= render 'public/api/v1/portals/articles/meta_head',
article: @article, portal: @portal, locale: @locale, og_image_url: @og_image_url %>
<% end %>
<% if !@is_plain_layout_enabled %>
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
@@ -29,57 +10,5 @@
</div>
</div>
</div>
<% else %>
<div class="max-w-5xl mx-auto space-y-4 w-full px-4 md:px-8 <%= @is_plain_layout_enabled ? 'py-4' : 'py-8' %>">
<%= render "public/api/v1/portals/articles/article_header", article: @article %>
</div>
<% end %>
<div class="flex max-w-5xl w-full px-4 md:px-8 mx-auto">
<article id="cw-article-content" class="article-content flex-grow flex-2 mx-auto text-slate-800 dark:text-slate-50 text-lg max-w-3xl prose-h1:text-2xl prose-h2:text-xl prose-h2:mt-0 prose-h3:text-lg prose-code:[&>p]:p-1 prose-code:[&>p]:rounded-sm prose-code:[&>p]:bg-black-100 dark:prose-code:[&>p]:bg-black-600 prose-code:after:content-none prose-code:before:content-none prose dark:prose-invert break-words w-full [&_table]:!border-slate-200 dark:[&_table]:!border-slate-800 [&_th]:!border-slate-200 dark:[&_th]:!border-slate-800 [&_td]:!border-slate-200 dark:[&_td]:!border-slate-800 [&_th]:!bg-slate-50 dark:[&_th]:!bg-slate-800/50 <%= @is_plain_layout_enabled ? 'py-4' : 'pt-8 pb-12' %>">
<%= @parsed_content %>
</article>
<div class="flex-1" id="cw-hc-toc"></div>
</div>
<style>
.article-content li > p {
margin: 0;
}
.article-content .tableWrapper {
overflow-x: auto;
margin-bottom: 1rem;
}
.article-content table {
min-width: 100%;
border: 1px solid;
border-radius: 8px;
border-spacing: 0;
border-collapse: separate;
margin: 0;
}
.article-content th,
.article-content td {
border-bottom: 1px solid;
border-inline-end: 1px solid;
border-color: inherit;
padding: 0.5rem 0.75rem;
text-align: start;
vertical-align: top;
}
.article-content th:last-child,
.article-content td:last-child {
border-inline-end: none;
}
.article-content th {
font-weight: 600;
}
.article-content th:first-child {
border-start-start-radius: 7px;
}
.article-content th:last-child {
border-start-end-radius: 7px;
}
.article-content tr:last-child td {
border-bottom: none;
}
</style>
<%= render "public/api/v1/portals/articles/article_body", parsed_content: @parsed_content %>
@@ -0,0 +1,27 @@
<%# locals: (category:, portal:) %>
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
<div class="w-full flex flex-col gap-6 flex-grow">
<% if category.articles.published.size == 0 %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
</div>
<% else %>
<% category.articles.published.order(:position).each do |article| %>
<div class="group">
<a
class="px-0 py-1 text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, true) %>"
>
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-1">
<h3 class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold group-hover:underline"><%= article.title %></h3>
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-1 break-all"><%= render_category_content(article.content) %></p>
</div>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
</div>
</a>
</div>
<% end %>
<% end %>
</div>
</section>
@@ -0,0 +1,27 @@
<%# locals: (category:, portal:) %>
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
<div class="w-full flex flex-col gap-6 flex-grow">
<% if category.articles.published.size == 0 %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
</div>
<% else %>
<% category.articles.published.order(:position).each do |article| %>
<div id="category-block" class="border border-solid border-slate-100 dark:border-slate-800 rounded-lg">
<a
class="p-4 text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, false) %>"
>
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-1">
<h3 id="category-name" class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold"><%= article.title %></h3>
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-1 break-all"><%= render_category_content(article.content) %></p>
</div>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
</div>
</a>
</div>
<% end %>
<% end %>
</div>
</section>
@@ -4,7 +4,7 @@
category_locale: category.locale,
category_slug: category.slug,
theme: @theme_from_params,
is_plain_layout_enabled: @is_plain_layout_enabled
is_plain_layout_enabled: false
}
%>
@@ -29,7 +29,7 @@
<div class="flex content-center justify-between h-8 my-1">
<a
class="leading-8 text-slate-800 dark:text-slate-50 hover:underline"
href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, false) %>"
>
<%= article.title %>
</a>
@@ -1,8 +1,9 @@
<div class="flex flex-col px-4 md:px-8 max-w-5xl w-full mx-auto gap-6 <%= @is_plain_layout_enabled && 'py-4' %>">
<%# locals: (category:, portal:, plain: false) %>
<div class="flex flex-col px-4 md:px-8 max-w-5xl w-full mx-auto gap-6 <%= plain ? 'py-4' : '' %>">
<div class="flex items-center flex-row">
<a
class="text-slate-500 dark:text-slate-200 text-sm gap-1 <%= @is_plain_layout_enabled && 'hover:underline' %> hover:cursor-pointer leading-8 font-semibold"
href="<%= generate_home_link(portal.slug, category.locale, @theme_from_params, @is_plain_layout_enabled) %>"
class="text-slate-500 dark:text-slate-200 text-sm gap-1 <%= plain ? 'hover:underline' : '' %> hover:cursor-pointer leading-8 font-semibold"
href="<%= generate_home_link(portal.slug, category.locale, @theme_from_params, plain) %>"
>
<%= I18n.t('public_portal.common.home') %>
</a>
@@ -0,0 +1,14 @@
<%# locals: (category:, portal:, locale:, og_image_url: nil) %>
<title><%= category.name %> | <%= portal.display_title(locale) %></title>
<meta name="title" content="<%= category.name %> | <%= portal.display_title(locale) %>">
<% if category.description.present? %>
<meta name="description" content="<%= category.description %>">
<meta property="og:description" content="<%= category.description %>">
<meta name="twitter:description" content="<%= category.description %>">
<% end %>
<% if og_image_url.present? %>
<meta name="twitter:card" content="summary_large_image">
<meta name="og:image" content="<%= og_image_url.html_safe %>">
<meta property="og:image" content="<%= og_image_url.html_safe %>">
<meta name="twitter:image" content="<%= og_image_url.html_safe %>">
<% end %>
@@ -0,0 +1,7 @@
<% content_for :head do %>
<%= render 'public/api/v1/portals/categories/meta_head',
category: @category, portal: @portal, locale: @locale, og_image_url: @og_image_url %>
<% end %>
<%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal, plain: true %>
<%= render 'public/api/v1/portals/categories/article_list', category: @category, portal: @portal %>
@@ -1,51 +1,11 @@
<% content_for :head do %>
<title><%= @category.name %> | <%= @portal.display_title(@locale) %></title>
<meta name="title" content="<%= @category.name %> | <%= @portal.display_title(@locale) %>">
<% if @category.description.present? %>
<meta name="description" content="<%= @category.description %>">
<meta property="og:description" content="<%= @category.description %>">
<meta name="twitter:description" content="<%= @category.description %>">
<% end %>
<% if @og_image_url.present? %>
<meta name="twitter:card" content="summary_large_image">
<meta name="og:image" content="<%= @og_image_url.html_safe %>">
<meta property="og:image" content="<%= @og_image_url.html_safe %>">
<meta name="twitter:image" content="<%= @og_image_url.html_safe %>">
<% end %>
<%= render 'public/api/v1/portals/categories/meta_head',
category: @category, portal: @portal, locale: @locale, og_image_url: @og_image_url %>
<% end %>
<% if !@is_plain_layout_enabled %>
<div id="portal-bg" class="bg-white dark:bg-slate-900">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %>
</div>
</div>
<% else %>
<%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %>
<% end %>
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
<div class="w-full flex flex-col gap-6 flex-grow">
<% if @category.articles.published.size == 0 %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
</div>
<% else %>
<% @category.articles.published.order(:position).each do |article| %>
<div id="<%= !@is_plain_layout_enabled ? 'category-block' : '' %>" class="<%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 rounded-lg' : 'group' %>">
<a
class="<%= !@is_plain_layout_enabled ? 'p-4' : 'px-0 py-1' %> text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
href="<%= generate_article_link(@portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
>
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-1">
<h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold <%= @is_plain_layout_enabled ? 'group-hover:underline' : '' %>"><%= article.title %></h3>
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-1 break-all"><%= render_category_content(article.content) %></p>
</div>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
</div>
</a>
</div>
<% end %>
<% end %>
</div>
</section>
<%= render 'public/api/v1/portals/categories/article_list', category: @category, portal: @portal %>
@@ -1,10 +1,10 @@
<%# locals: (input_class:) %>
<%# locals: (input_class:, plain: false) %>
<form class="w-full my-4" action="<%= request.path %>" method="GET" data-search-form>
<input type="hidden" name="locale" value="<%= params[:locale] %>">
<% if @theme_from_params.present? %>
<input type="hidden" name="theme" value="<%= @theme_from_params %>">
<% end %>
<% if @is_plain_layout_enabled %>
<% if plain %>
<input type="hidden" name="show_plain_layout" value="true">
<% end %>
<input type="text"
@@ -0,0 +1,65 @@
<%# locals: (plain: false) %>
<% pagination_params = params.permit(:query, :locale, :theme, :show_plain_layout) %>
<%= render 'public/api/v1/portals/search/search_handler' %>
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
<div class="w-full flex flex-col gap-6 flex-grow">
<% if @articles.empty? %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.search.no_results', query: @query) %></p>
</div>
<% else %>
<p class="text-sm text-slate-600 dark:text-slate-400">
<%= I18n.t('public_portal.search.found_results', count: @articles.total_count) %>
</p>
<% @articles.each do |article| %>
<div class="border border-solid border-slate-100 dark:border-slate-800 rounded-lg">
<a class="p-4 text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
href="<%= generate_article_link(@portal.slug, article.slug, @theme_from_params, plain) %>">
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-1">
<h3 class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold">
<%= article.title %>
</h3>
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-2 break-all">
<%= render_category_content(article.content) %>
</p>
</div>
<div class="flex flex-row items-center gap-2">
<% if article.category.present? %>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
<%= article.category.name %>
</span>
<span class="text-slate-600 dark:text-slate-400">•</span>
<% end %>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
</span>
</div>
</div>
</a>
</div>
<% end %>
<% if @articles.respond_to?(:total_pages) && @articles.total_pages > 1 %>
<div class="flex justify-center mt-6">
<nav class="inline-flex">
<% if @articles.prev_page %>
<a href="<%= url_for(pagination_params.merge(page: @articles.prev_page)) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-l-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
<%= I18n.t('public_portal.common.previous') %>
</a>
<% end %>
<% if @articles.next_page %>
<a href="<%= url_for(pagination_params.merge(page: @articles.next_page)) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-r-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
<%= I18n.t('public_portal.common.next') %>
</a>
<% end %>
</nav>
</div>
<% end %>
<% end %>
</div>
</section>
@@ -0,0 +1,19 @@
<%# locals: (search_input_class:, plain: false) %>
<div class="flex flex-row items-center gap-px mb-6">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, plain) %>">
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
<%= render partial: 'icons/chevron-right' %>
</span>
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
<%= I18n.t('public_portal.search.results') %>
</span>
</div>
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= I18n.t('public_portal.search.results_for', query: @query) %>
</h1>
<%= render 'public/api/v1/portals/search/form', input_class: search_input_class, plain: plain %>
@@ -0,0 +1,11 @@
<% content_for :head do %>
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %></title>
<% end %>
<% search_input_class = 'w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' %>
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col py-4">
<%= render 'public/api/v1/portals/search/results_header', search_input_class: search_input_class, plain: true %>
</div>
<%= render 'public/api/v1/portals/search/results', plain: true %>
@@ -4,115 +4,12 @@
<% search_input_class = 'w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' %>
<% if !@is_plain_layout_enabled %>
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
<div class="flex flex-row items-center gap-px mb-6">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
<%= render partial: 'icons/chevron-right' %>
</span>
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
<%= I18n.t('public_portal.search.results') %>
</span>
</div>
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= I18n.t('public_portal.search.results_for', query: @query) %>
</h1>
<%= render 'public/api/v1/portals/search/form', input_class: search_input_class %>
</div>
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
<%= render 'public/api/v1/portals/search/results_header', search_input_class: search_input_class %>
</div>
</div>
<% else %>
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col py-4">
<div class="flex flex-row items-center gap-px mb-6">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
<%= render partial: 'icons/chevron-right' %>
</span>
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
<%= I18n.t('public_portal.search.results') %>
</span>
</div>
</div>
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= I18n.t('public_portal.search.results_for', query: @query) %>
</h1>
<%= render 'public/api/v1/portals/search/form', input_class: search_input_class %>
</div>
<% end %>
<% pagination_params = params.permit(:query, :locale, :theme, :show_plain_layout) %>
<%= render 'public/api/v1/portals/search/search_handler' %>
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
<div class="w-full flex flex-col gap-6 flex-grow">
<% if @articles.empty? %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.search.no_results', query: @query) %></p>
</div>
<% else %>
<p class="text-sm text-slate-600 dark:text-slate-400">
<%= I18n.t('public_portal.search.found_results', count: @articles.total_count) %>
</p>
<% @articles.each do |article| %>
<div class="border border-solid border-slate-100 dark:border-slate-800 rounded-lg">
<a class="p-4 text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
href="<%= generate_article_link(@portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-1">
<h3 class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold">
<%= article.title %>
</h3>
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-2 break-all">
<%= render_category_content(article.content) %>
</p>
</div>
<div class="flex flex-row items-center gap-2">
<% if article.category.present? %>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
<%= article.category.name %>
</span>
<span class="text-slate-600 dark:text-slate-400">•</span>
<% end %>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
</span>
</div>
</div>
</a>
</div>
<% end %>
<% if @articles.respond_to?(:total_pages) && @articles.total_pages > 1 %>
<div class="flex justify-center mt-6">
<nav class="inline-flex">
<% if @articles.prev_page %>
<a href="<%= url_for(pagination_params.merge(page: @articles.prev_page)) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-l-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
<%= I18n.t('public_portal.common.previous') %>
</a>
<% end %>
<% if @articles.next_page %>
<a href="<%= url_for(pagination_params.merge(page: @articles.next_page)) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-r-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
<%= I18n.t('public_portal.common.next') %>
</a>
<% end %>
</nav>
</div>
<% end %>
<% end %>
</div>
</section>
<%= render 'public/api/v1/portals/search/results' %>
@@ -0,0 +1,3 @@
<div class="max-w-5xl w-full flex flex-col flex-grow mx-auto py-8 px-4 md:px-8 gap-6">
<%= render "public/api/v1/portals/home_categories", portal: @portal, locale: @locale %>
</div>
+2 -16
View File
@@ -1,19 +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">
<%# Featured Articles %>
<% if !@is_plain_layout_enabled %>
<div><%= render "public/api/v1/portals/featured_articles", articles: @portal.articles, categories: @portal.categories.where(locale: @locale), portal: @portal %></div>
<% end %>
<%# Categories with articles %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-6">
<% @portal.categories.where(locale: @locale).joins(:articles).where(articles:{ status: :published }).order(position: :asc).group('categories.id').each do |category| %>
<%= render "public/api/v1/portals/category-block", category: category, portal: @portal %>
<% end %>
</div>
<%# Uncategorized articles %>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-6">
<% if @portal.articles.where(status: :published, category_id: nil, locale: @locale).count > 0 %>
<%= render "public/api/v1/portals/uncategorized-block", category: "Uncategorized", portal: @portal %>
<% end %>
</div>
<div><%= render "public/api/v1/portals/featured_articles", articles: @portal.articles, categories: @portal.categories.where(locale: @locale), portal: @portal %></div>
<%= render "public/api/v1/portals/home_categories", portal: @portal, locale: @locale %>
</div>
+1
View File
@@ -228,6 +228,7 @@ Rails.application.routes.draw do
end
member do
post :start
post :retry, action: :retry_import
post :abandon
get :error_logs
get :skip_logs
@@ -24,6 +24,23 @@ RSpec.describe Channels::Whatsapp::TemplatesSyncSchedulerJob do
)
end
it 'does not schedule templates_sync_jobs for suspended accounts' do
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
suspended_account = create(:account, status: :suspended)
suspended_channel = create(
:channel_whatsapp,
account: suspended_account,
sync_templates: false,
message_templates_last_updated: nil
)
described_class.perform_now
expect(Channels::Whatsapp::TemplatesSyncJob).not_to(
have_been_enqueued.with(suspended_channel).on_queue('low')
)
end
it 'schedules templates_sync_job for oldest synced channels first' do
stub_const('Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT', 2)
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
+32
View File
@@ -64,4 +64,36 @@ RSpec.describe DataImport do
expect(data_import.abandoned_at).to be_nil
end
end
describe '#stalled?' do
let(:account) { create(:account) }
it 'identifies active Intercom imports without updates for ten minutes', :aggregate_failures do
freeze_time do
processing_import = create(:data_import, :intercom, account: account, status: :processing)
pending_import = create(:data_import, :intercom, account: account, status: :pending)
processing_import.update!(updated_at: 10.minutes.ago)
pending_import.update!(updated_at: 10.minutes.ago)
expect(processing_import.reload).to be_stalled
expect(pending_import.reload).to be_stalled
end
end
it 'does not identify recent or terminal Intercom imports as stalled', :aggregate_failures do
recent_import = create(:data_import, :intercom, account: account, status: :processing)
completed_import = create(:data_import, :intercom, account: account, status: :completed)
completed_import.update!(updated_at: 1.hour.ago)
expect(recent_import).not_to be_stalled
expect(completed_import.reload).not_to be_stalled
end
it 'does not identify legacy imports as stalled' do
legacy_import = create(:data_import, account: account, status: :processing)
legacy_import.update!(updated_at: 1.hour.ago)
expect(legacy_import.reload).not_to be_stalled
end
end
end
@@ -209,6 +209,63 @@ RSpec.describe 'Data Imports API', type: :request do
end
end
describe 'POST /api/v1/accounts/:account_id/data_imports/:id/retry' do
let(:data_import) { create(:data_import, :intercom, account: account, status: :processing, started_at: 2.hours.ago) }
it 'resumes a stalled import with a new run identifier while preserving progress', :aggregate_failures do
started_at = data_import.started_at
data_import.update!(
cursor: { 'conversations' => { 'starting_after' => 'cursor-1' } },
stats: { 'conversations' => { 'imported' => 20 } },
source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'previous-run' },
updated_at: 11.minutes.ago
)
expect do
post retry_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
headers: admin.create_new_auth_token,
as: :json
end.to have_enqueued_job(DataImports::Intercom::ImportJob).with(data_import, a_kind_of(String))
expect(response).to have_http_status(:ok)
expect(response.parsed_body).to include('status' => 'pending', 'stalled' => false)
expect(data_import.reload.started_at).to eq(started_at)
expect(data_import.cursor.dig('conversations', 'starting_after')).to eq('cursor-1')
expect(data_import.stats.dig('conversations', 'imported')).to eq(20)
expect(data_import.active_intercom_import_run_id).not_to eq('previous-run')
end
it 'rejects duplicate retries after the import becomes active again' do
data_import.update!(updated_at: 11.minutes.ago)
post retry_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
headers: admin.create_new_auth_token,
as: :json
expect do
post retry_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(DataImports::Intercom::ImportJob)
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to eq('This Intercom import is no longer stalled.')
end
it 'rejects retry while another Intercom import is active' do
data_import.update!(updated_at: 11.minutes.ago)
create(:data_import, :intercom, account: account, status: :processing)
expect do
post retry_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(DataImports::Intercom::ImportJob)
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to eq('Another Intercom import is already in progress.')
end
end
describe 'POST /api/v1/accounts/:account_id/data_imports/:id/abandon' do
let(:data_import) { create(:data_import, :intercom, account: account) }
@@ -283,6 +340,7 @@ RSpec.describe 'Data Imports API', type: :request do
'id' => data_import.id,
'name' => 'July Intercom migration',
'source_provider' => 'intercom',
'stalled' => false,
'import_errors_count' => 1,
'skip_logs_count' => 1
)
@@ -0,0 +1,69 @@
require 'rails_helper'
RSpec.describe DataImports::Intercom::RetryService do
let(:account) { create(:account) }
let(:data_import) { create(:data_import, :intercom, account: account, status: :processing, started_at: 2.hours.ago) }
before do
account.enable_features!('data_import')
end
it 'prepares a stalled import for another run without clearing progress', :aggregate_failures do
original_cursor = { 'contacts' => { 'completed' => true }, 'conversations' => { 'starting_after' => 'cursor-1' } }
original_stats = { 'contacts' => { 'imported' => 10 }, 'conversations' => { 'imported' => 5 } }
started_at = data_import.started_at
error = data_import.import_errors.create!(error_code: 'MessageFailed', message: 'Timed out')
data_import.update!(
cursor: original_cursor,
stats: original_stats,
source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'previous-run' },
updated_at: 11.minutes.ago
)
result = described_class.new(account: account, data_import: data_import).perform
expect(result).to eq(:enqueue)
expect(data_import.reload).to be_pending
expect(data_import.started_at).to eq(started_at)
expect(data_import.cursor).to eq(original_cursor)
expect(data_import.stats).to eq(original_stats)
expect(data_import.import_errors).to contain_exactly(error)
expect(data_import.active_intercom_import_run_id).not_to eq('previous-run')
end
it 'does not retry an import that is still receiving updates' do
result = described_class.new(account: account, data_import: data_import).perform
expect(result).to eq(:not_stalled)
expect(data_import.reload).to be_processing
end
it 'does not retry while another Intercom import is active' do
data_import.update!(updated_at: 11.minutes.ago)
create(:data_import, :intercom, account: account, status: :processing)
result = described_class.new(account: account, data_import: data_import).perform
expect(result).to eq(:active_import_exists)
expect(data_import.reload).to be_processing
end
it 'allows an active legacy import to continue alongside the retry' do
data_import.update!(updated_at: 11.minutes.ago)
create(:data_import, account: account, status: :processing)
result = described_class.new(account: account, data_import: data_import).perform
expect(result).to eq(:enqueue)
expect(data_import.reload).to be_pending
end
it 'does not retry when the stored access key is unavailable' do
data_import.update!(access_token: nil, updated_at: 11.minutes.ago)
result = described_class.new(account: account, data_import: data_import).perform
expect(result).to eq(:access_token_missing)
expect(data_import.reload).to be_processing
end
end