Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d6768c218 | ||
|
|
5af26e45a9 | ||
|
|
f948b0b8d9 | ||
|
|
081e08c7c6 | ||
|
|
dc3a4814aa |
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
+3
@@ -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>
|
||||
|
||||
+17
-1
@@ -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')"
|
||||
/>
|
||||
|
||||
+85
@@ -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|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user