From 331875cdaa6da6aae51c08b80fc0aea41adfc655 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Fri, 17 Jul 2026 14:43:42 +0530
Subject: [PATCH 01/38] feat: Add popular content per locale (#14939)
---
.../api/v1/accounts/portals_controller.rb | 7 +-
app/controllers/concerns/portal_home_data.rb | 40 ++-
app/controllers/dashboard_controller.rb | 6 +-
.../public/api/v1/portals_controller.rb | 2 +-
.../dashboard/api/helpCenter/articles.js | 3 +-
.../dashboard/api/specs/article.spec.js | 3 +-
.../HelpCenter/LocaleCard/LocaleCard.vue | 3 +
.../Pages/LocalePage/LocaleList.vue | 5 +
.../Pages/LocalePage/PopularContentDialog.vue | 235 ++++++++++++++
.../combobox/ComboBoxDropdown.vue | 19 +-
.../combobox/ReorderableMultiSelect.vue | 297 ++++++++++++++++++
.../specs/ReorderableMultiSelect.spec.js | 224 +++++++++++++
.../spec/useAbortableRequest.spec.js | 120 +++++++
.../composables/useAbortableRequest.js | 62 ++++
.../dashboard/helper/portalHelper.js | 10 +
.../helper/specs/portalHelper.spec.js | 23 +-
.../dashboard/i18n/locale/en/helpCenter.json | 24 ++
app/models/concerns/portal_config_schema.rb | 16 +
app/models/portal.rb | 15 +-
.../v1/accounts/portals/_portal.json.jbuilder | 1 +
app/views/layouts/_portal_scripts.html.erb | 5 +
.../v1/portals/_featured_articles.html.erb | 9 +-
.../public/api/v1/portals/_hero.html.erb | 12 +
.../documentation_layout/_hero.html.erb | 4 +-
.../v1/portals/show.html+documentation.erb | 11 +-
app/views/public/api/v1/portals/show.html.erb | 2 +-
config/locales/en.yml | 4 +
.../v1/accounts/portals_controller_spec.rb | 3 +-
.../public/api/v1/portals_controller_spec.rb | 76 +++++
29 files changed, 1208 insertions(+), 33 deletions(-)
create mode 100644 app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue
create mode 100644 app/javascript/dashboard/components-next/combobox/ReorderableMultiSelect.vue
create mode 100644 app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js
create mode 100644 app/javascript/dashboard/composables/spec/useAbortableRequest.spec.js
create mode 100644 app/javascript/dashboard/composables/useAbortableRequest.js
diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb
index c74c0ecfc..bdaf82f6c 100644
--- a/app/controllers/api/v1/accounts/portals_controller.rb
+++ b/app/controllers/api/v1/accounts/portals_controller.rb
@@ -81,7 +81,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
- { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
+ { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
+ { popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
)
end
@@ -89,6 +90,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
params.dig(:portal, :config, :locale_translations)&.keys || []
end
+ def popular_content_keys
+ params.dig(:portal, :config, :popular_content)&.keys || []
+ end
+
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)
diff --git a/app/controllers/concerns/portal_home_data.rb b/app/controllers/concerns/portal_home_data.rb
index 633071301..71eaad081 100644
--- a/app/controllers/concerns/portal_home_data.rb
+++ b/app/controllers/concerns/portal_home_data.rb
@@ -4,17 +4,53 @@ module PortalHomeData
private
def load_home_data
- base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
+ load_recommended_content
+ # The classic hero only needs the recommendations above; the rest is
+ # documentation-layout home data (also used on custom-domain home pages).
+ return unless @portal.layout == 'documentation'
+
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
- @popular_topics = @visible_categories.first(3)
+ @popular_topics = @recommended_categories.presence || @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
+ def load_recommended_content
+ @recommended_categories = recommended_categories
+ @recommended_articles = recommended_articles
+ end
+
+ def base_articles
+ @base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category)
+ end
+
+ # Admin-recommended categories for the locale, in the chosen order. Unlike the
+ # position-based fallback, published articles aren't required: the admin's pick wins.
+ def recommended_categories
+ ids = @portal.popular_category_ids(@locale)
+ ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids)
+ end
+
+ # Admin-recommended articles for the locale, in the chosen order, limited to
+ # published articles that still exist.
+ def recommended_articles
+ ids = @portal.popular_article_ids(@locale)
+ ordered_by_ids(base_articles.where(id: ids), ids)
+ end
+
+ # Loads the scope and returns its records ordered to match `ids`, dropping any
+ # that no longer exist. Skips the query entirely when `ids` is blank.
+ def ordered_by_ids(scope, ids)
+ return [] if ids.blank?
+
+ by_id = scope.index_by(&:id)
+ ids.filter_map { |id| by_id[id] }
+ end
+
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index a369830b6..a72687b42 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -64,10 +64,8 @@ class DashboardController < ActionController::Base
return unless @portal
@locale = @portal.default_locale
- if @portal.layout == 'documentation'
- request.variant = :documentation
- load_home_data
- end
+ request.variant = :documentation if @portal.layout == 'documentation'
+ load_home_data
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb
index 4982278d7..da7d9e6a5 100644
--- a/app/controllers/public/api/v1/portals_controller.rb
+++ b/app/controllers/public/api/v1/portals_controller.rb
@@ -7,7 +7,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
- before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
+ before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled }
layout 'portal'
def show
diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js
index 55b620d1a..610ba1e57 100644
--- a/app/javascript/dashboard/api/helpCenter/articles.js
+++ b/app/javascript/dashboard/api/helpCenter/articles.js
@@ -17,6 +17,7 @@ class ArticlesAPI extends PortalsAPI {
categorySlug,
sort,
query,
+ signal,
}) {
const url = getArticleSearchURL({
pageNumber,
@@ -30,7 +31,7 @@ class ArticlesAPI extends PortalsAPI {
host: this.url,
});
- return axios.get(url);
+ return axios.get(url, { signal });
}
searchArticles({ portalSlug, query }) {
diff --git a/app/javascript/dashboard/api/specs/article.spec.js b/app/javascript/dashboard/api/specs/article.spec.js
index b40613739..9f7052ed5 100644
--- a/app/javascript/dashboard/api/specs/article.spec.js
+++ b/app/javascript/dashboard/api/specs/article.spec.js
@@ -37,7 +37,8 @@ describe('#PortalAPI', () => {
authorId: '1',
});
expect(axiosMock.get).toHaveBeenCalledWith(
- '/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
+ '/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1',
+ { signal: undefined }
);
});
});
diff --git a/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue
index 259328bec..a27fbdac0 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue
@@ -56,6 +56,9 @@ const localeMenuLabels = computed(() => ({
'customize-content': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
),
+ 'select-popular-content': t(
+ 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT'
+ ),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue
index 66d389ead..ba2ffa22e 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue
@@ -2,6 +2,7 @@
import { ref } from 'vue';
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
+import PopularContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -26,6 +27,7 @@ const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const contentDialogRef = ref(null);
+const popularContentDialogRef = ref(null);
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
@@ -154,6 +156,8 @@ const handleAction = ({ action }, localeCode) => {
publishLocale({ localeCode: localeCode });
} else if (action === 'customize-content') {
contentDialogRef.value.openForLocale(localeCode);
+ } else if (action === 'select-popular-content') {
+ popularContentDialogRef.value.openForLocale(localeCode);
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
@@ -174,5 +178,6 @@ const handleAction = ({ action }, localeCode) => {
@action="handleAction($event, locale.code)"
/>
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue
new file mode 100644
index 000000000..487b657bb
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue
@@ -0,0 +1,235 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue b/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue
index 1ab9e9503..2737cb353 100644
--- a/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue
+++ b/app/javascript/dashboard/components-next/combobox/ComboBoxDropdown.vue
@@ -1,6 +1,8 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.label }}
+
+
+ {{ row.subtitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js b/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js
new file mode 100644
index 000000000..b07868863
--- /dev/null
+++ b/app/javascript/dashboard/components-next/combobox/specs/ReorderableMultiSelect.spec.js
@@ -0,0 +1,224 @@
+import { mount } from '@vue/test-utils';
+import { h } from 'vue';
+import ReorderableMultiSelect from '../ReorderableMultiSelect.vue';
+
+const OPTIONS = [
+ { value: 1, label: 'Getting started', subtitle: 'Guides' },
+ { value: 2, label: 'Billing', subtitle: 'Payments' },
+ { value: 3, label: 'Security' },
+ { value: 4, label: 'API', icon: '🔌', iconColor: '#000' },
+];
+
+// A findable dropdown stub that exposes the `focus()` the component calls on open.
+const ComboBoxDropdownStub = {
+ name: 'ComboBoxDropdown',
+ props: [
+ 'open',
+ 'options',
+ 'searchValue',
+ 'searchPlaceholder',
+ 'emptyState',
+ 'loading',
+ ],
+ emits: ['select', 'update:searchValue'],
+ methods: { focus() {} },
+ template: '
',
+};
+
+// Renders a real