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] 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 @@ + + 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 ', +}; + +const mountSelect = (props = {}, slots = {}) => + mount(ReorderableMultiSelect, { + props: { options: OPTIONS, max: 3, ...props }, + slots, + global: { + stubs: { + Button: ButtonStub, + ComboBoxDropdown: ComboBoxDropdownStub, + Spinner: true, + Icon: true, + EmojiIcon: true, + OnClickOutside: { template: '
' }, + }, + }, + }); + +const dropdown = wrapper => wrapper.findComponent(ComboBoxDropdownStub); +const addTrigger = wrapper => + wrapper.findAll('button').find(button => !button.attributes('data-icon')); +const removeButtons = wrapper => + wrapper.findAll('button[data-icon="i-lucide-x"]'); +const rows = wrapper => wrapper.findAll('[draggable="true"]'); +const lastModel = wrapper => wrapper.emitted('update:modelValue')?.at(-1)?.[0]; + +describe('ReorderableMultiSelect', () => { + describe('rendering selected rows', () => { + it('renders rows in model order with labels resolved from options', () => { + const wrapper = mountSelect({ modelValue: [2, 1] }); + + const labels = rows(wrapper).map(row => row.find('p').text()); + expect(labels).toEqual(['Billing', 'Getting started']); + }); + + it('falls back to the stringified id when an option is unknown', () => { + const wrapper = mountSelect({ modelValue: [99] }); + + expect(rows(wrapper)[0].find('p').text()).toBe('99'); + }); + + it('renders the progress dots filled up to the selection count', () => { + const wrapper = mountSelect({ + modelValue: [1, 2], + max: 3, + label: 'Tags', + }); + + const filled = wrapper.findAll('.bg-n-brand').length; + expect(filled).toBe(2); + }); + + it('exposes remaining and max to the counter slot', () => { + const wrapper = mountSelect( + { modelValue: [1], max: 3 }, + { counter: ({ remaining, max }) => h('span', `${remaining}/${max}`) } + ); + + expect(wrapper.text()).toContain('2/3'); + }); + }); + + describe('adding options', () => { + it('appends the chosen option to the model', () => { + const wrapper = mountSelect({ modelValue: [1] }); + + dropdown(wrapper).vm.$emit('select', OPTIONS[1]); + + expect(lastModel(wrapper)).toEqual([1, 2]); + }); + + it('hides the add trigger once the model reaches max', () => { + const wrapper = mountSelect({ modelValue: [1, 2], max: 2 }); + + expect(addTrigger(wrapper)).toBeUndefined(); + expect(dropdown(wrapper).exists()).toBe(false); + }); + + it('closes the dropdown when the last slot is filled', async () => { + const wrapper = mountSelect({ modelValue: [1], max: 2 }); + await addTrigger(wrapper).trigger('click'); + expect(dropdown(wrapper).props('open')).toBe(true); + + dropdown(wrapper).vm.$emit('select', OPTIONS[1]); + await wrapper.vm.$nextTick(); + + // Reaching max removes the trigger (and its dropdown) entirely. + expect(dropdown(wrapper).exists()).toBe(false); + }); + + it('excludes already-selected options from the dropdown', () => { + const wrapper = mountSelect({ modelValue: [1] }); + + const values = dropdown(wrapper) + .props('options') + .map(option => option.value); + expect(values).toEqual([2, 3, 4]); + }); + }); + + describe('removing options', () => { + it('removes the clicked item from the model', async () => { + const wrapper = mountSelect({ modelValue: [1, 2, 3] }); + + await removeButtons(wrapper)[1].trigger('click'); + + expect(lastModel(wrapper)).toEqual([1, 3]); + }); + }); + + describe('searching', () => { + it('filters options locally by label', async () => { + const wrapper = mountSelect({ modelValue: [] }); + + dropdown(wrapper).vm.$emit('update:searchValue', 'bill'); + await wrapper.vm.$nextTick(); + + const values = dropdown(wrapper) + .props('options') + .map(option => option.value); + expect(values).toEqual([2]); + }); + + it('emits search and skips local filtering when serverSearch is set', async () => { + const wrapper = mountSelect({ modelValue: [], serverSearch: true }); + + dropdown(wrapper).vm.$emit('update:searchValue', 'bill'); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('search').at(-1)).toEqual(['bill']); + // All unselected options remain; the parent owns filtering. + expect(dropdown(wrapper).props('options')).toHaveLength(4); + }); + + it('emits an empty search when the trigger opens', async () => { + const wrapper = mountSelect({ modelValue: [1] }); + + await addTrigger(wrapper).trigger('click'); + + expect(wrapper.emitted('search').at(-1)).toEqual(['']); + expect(dropdown(wrapper).props('open')).toBe(true); + }); + }); + + describe('reordering', () => { + it('moves a row to the dropped position within the model', async () => { + const wrapper = mountSelect({ modelValue: [1, 2, 3] }); + + await rows(wrapper)[0].trigger('dragstart'); + await rows(wrapper)[2].trigger('dragover'); + + expect(lastModel(wrapper)).toEqual([2, 3, 1]); + }); + }); + + describe('loading state', () => { + it('shows skeleton rows when loading a non-empty, closed selection', () => { + const wrapper = mountSelect({ modelValue: [1, 2], loading: true }); + + const skeleton = wrapper.find('[aria-busy="true"]'); + expect(skeleton.exists()).toBe(true); + expect(skeleton.findAll('.animate-pulse').length).toBeGreaterThan(0); + }); + + it('does not show skeletons when the selection is empty', () => { + const wrapper = mountSelect({ modelValue: [], loading: true }); + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false); + }); + + it('shows the real rows, not skeletons, while searching in an open dropdown', async () => { + // Open first (trigger is enabled), then a live search turns loading on. + const wrapper = mountSelect({ modelValue: [1, 2] }); + await addTrigger(wrapper).trigger('click'); + + await wrapper.setProps({ loading: true }); + + expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false); + expect(rows(wrapper)).toHaveLength(2); + }); + + it('forwards loading to the dropdown and disables the closed trigger', () => { + const wrapper = mountSelect({ modelValue: [1], loading: true }); + + expect(dropdown(wrapper).props('loading')).toBe(true); + expect(addTrigger(wrapper).attributes('disabled')).toBeDefined(); + }); + }); +}); diff --git a/app/javascript/dashboard/composables/spec/useAbortableRequest.spec.js b/app/javascript/dashboard/composables/spec/useAbortableRequest.spec.js new file mode 100644 index 000000000..1651f8e6d --- /dev/null +++ b/app/javascript/dashboard/composables/spec/useAbortableRequest.spec.js @@ -0,0 +1,120 @@ +import { effectScope } from 'vue'; +import { useAbortableRequest } from '../useAbortableRequest'; + +// Resolves when the request "completes", rejects like axios does when the +// signal is aborted mid-flight. +const abortableRunner = + (value, { fail = false } = {}) => + signal => + new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + const error = new Error('canceled'); + error.name = 'CanceledError'; + reject(error); + }); + // Defer so a follow-up `run`/`abort` can supersede this one first. + Promise.resolve().then(() => { + if (signal.aborted) return; + if (fail) { + reject(new Error('boom')); + return; + } + resolve(value); + }); + }); + +describe('useAbortableRequest', () => { + it('passes a fresh signal to the runner and returns its result', async () => { + const { run } = useAbortableRequest(); + let received = null; + + const result = await run(signal => { + received = signal; + return Promise.resolve('ok'); + }); + + expect(received).toBeInstanceOf(AbortSignal); + expect(received.aborted).toBe(false); + expect(result).toBe('ok'); + }); + + it('toggles isPending around the request', async () => { + const { run, isPending } = useAbortableRequest(); + expect(isPending.value).toBe(false); + + const pending = run(() => Promise.resolve('done')); + expect(isPending.value).toBe(true); + + await pending; + expect(isPending.value).toBe(false); + }); + + it('aborts the previous request when a new one starts', async () => { + const { run } = useAbortableRequest(); + + const first = run(abortableRunner('first')); + const second = run(abortableRunner('second')); + + await expect(first).resolves.toBeUndefined(); + await expect(second).resolves.toBe('second'); + }); + + it('returns the onAbort value when a request is superseded', async () => { + const { run } = useAbortableRequest(); + + const first = run(abortableRunner('first'), { onAbort: null }); + const second = run(abortableRunner('second')); + + await expect(first).resolves.toBeNull(); + await expect(second).resolves.toBe('second'); + }); + + it('abort cancels the in-flight request and clears isPending', async () => { + const { run, abort, isPending } = useAbortableRequest(); + + const pending = run(abortableRunner('value')); + expect(isPending.value).toBe(true); + + abort(); + + await expect(pending).resolves.toBeUndefined(); + expect(isPending.value).toBe(false); + }); + + it('rethrows non-abort errors and clears isPending', async () => { + const { run, isPending } = useAbortableRequest(); + + await expect(run(abortableRunner(null, { fail: true }))).rejects.toThrow( + 'boom' + ); + expect(isPending.value).toBe(false); + }); + + it('aborts the in-flight request when its scope is disposed', async () => { + const scope = effectScope(); + let request; + scope.run(() => { + request = useAbortableRequest(); + }); + + const pending = request.run(abortableRunner('value')); + expect(request.isPending.value).toBe(true); + + scope.stop(); + + await expect(pending).resolves.toBeUndefined(); + expect(request.isPending.value).toBe(false); + }); + + it('keeps separate controllers per instance', async () => { + const a = useAbortableRequest(); + const b = useAbortableRequest(); + + const first = a.run(abortableRunner('a')); + // Starting b's request must not abort a's. + const second = b.run(abortableRunner('b')); + + await expect(first).resolves.toBe('a'); + await expect(second).resolves.toBe('b'); + }); +}); diff --git a/app/javascript/dashboard/composables/useAbortableRequest.js b/app/javascript/dashboard/composables/useAbortableRequest.js new file mode 100644 index 000000000..c6e33367a --- /dev/null +++ b/app/javascript/dashboard/composables/useAbortableRequest.js @@ -0,0 +1,62 @@ +import { getCurrentScope, onScopeDispose, ref } from 'vue'; + +export const isAbortError = error => + error?.name === 'AbortError' || + error?.name === 'CanceledError' || + error?.code === 'ERR_CANCELED'; + +/** + * Keeps only the latest request alive. Starting a new `run` (or calling + * `abort`) cancels the previous request through its `AbortSignal`, so + * out-of-order responses can never overwrite fresher data. + * + * @example + * const { run, abort, isPending } = useAbortableRequest(); + * const results = await run(signal => api.search(query, { signal })); + * + * @returns {{ + * run: (runner: (signal: AbortSignal) => Promise, options?: { onAbort?: any }) => Promise, + * abort: () => void, + * isPending: import('vue').Ref, + * }} + * `run` resolves with the runner's value, or `options.onAbort` (default + * `undefined`) when the request was superseded. Non-abort errors are rethrown. + */ +export function useAbortableRequest() { + const isPending = ref(false); + let controller = null; + + const abort = () => { + controller?.abort(); + controller = null; + isPending.value = false; + }; + + const run = async (runner, { onAbort } = {}) => { + controller?.abort(); + const currentController = new AbortController(); + controller = currentController; + isPending.value = true; + + try { + return await runner(currentController.signal); + } catch (error) { + if (currentController.signal.aborted || isAbortError(error)) + return onAbort; + throw error; + } finally { + // Only the latest run owns the shared state; a superseded run leaves it + // for the run that replaced it. + if (controller === currentController) { + controller = null; + isPending.value = false; + } + } + }; + + // Cancel any in-flight request when the owning scope is disposed. + // Guarded so the composable can also be used outside an effect scope. + if (getCurrentScope()) onScopeDispose(abort); + + return { run, abort, isPending }; +} diff --git a/app/javascript/dashboard/helper/portalHelper.js b/app/javascript/dashboard/helper/portalHelper.js index 89f13f8cd..2d5272dde 100644 --- a/app/javascript/dashboard/helper/portalHelper.js +++ b/app/javascript/dashboard/helper/portalHelper.js @@ -166,6 +166,13 @@ export const LOCALE_MENU_ITEMS = { value: 'customize-content', icon: 'i-lucide-pencil', }, + selectPopularContent: { + label: + 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT', + action: 'select-popular-content', + value: 'select-popular-content', + icon: 'i-lucide-sparkles', + }, delete: { label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE', action: 'delete', @@ -185,6 +192,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => { LOCALE_MENU_ITEMS.moveToDraft, ]), LOCALE_MENU_ITEMS.customizeContent, + LOCALE_MENU_ITEMS.selectPopularContent, ...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]), ]; } @@ -193,6 +201,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => { return [ LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.customizeContent, + LOCALE_MENU_ITEMS.selectPopularContent, LOCALE_MENU_ITEMS.delete, ]; } @@ -201,6 +210,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => { LOCALE_MENU_ITEMS.makeDefault, LOCALE_MENU_ITEMS.moveToDraft, LOCALE_MENU_ITEMS.customizeContent, + LOCALE_MENU_ITEMS.selectPopularContent, LOCALE_MENU_ITEMS.delete, ]; }; diff --git a/app/javascript/dashboard/helper/specs/portalHelper.spec.js b/app/javascript/dashboard/helper/specs/portalHelper.spec.js index e8d200518..1a6316520 100644 --- a/app/javascript/dashboard/helper/specs/portalHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/portalHelper.spec.js @@ -74,26 +74,34 @@ describe('PortalHelper', () => { }); describe('buildLocaleMenuItems', () => { - it('disables other actions but keeps customize enabled for the default locale', () => { + it('disables other actions but keeps content actions enabled for the default locale', () => { const items = buildLocaleMenuItems({ isDefault: true, isDraft: false }); - const customize = items.find(item => item.action === 'customize-content'); + const enabledActions = ['customize-content', 'select-popular-content']; - expect(customize).toBeTruthy(); - expect(customize.disabled).toBeFalsy(); + enabledActions.forEach(action => { + expect( + items.find(item => item.action === action)?.disabled + ).toBeFalsy(); + }); expect( items - .filter(item => item.action !== 'customize-content') + .filter(item => !enabledActions.includes(item.action)) .every(item => item.disabled) ).toBe(true); }); - it('returns publish, customize, and delete actions for draft locales', () => { + it('returns publish, customize, popular content, and delete actions for draft locales', () => { expect( buildLocaleMenuItems({ isDefault: false, isDraft: true, }).map(({ action }) => action) - ).toEqual(['publish-locale', 'customize-content', 'delete']); + ).toEqual([ + 'publish-locale', + 'customize-content', + 'select-popular-content', + 'delete', + ]); }); it('returns default, draft, customize, and delete actions for live locales', () => { @@ -106,6 +114,7 @@ describe('PortalHelper', () => { 'change-default', 'move-to-draft', 'customize-content', + 'select-popular-content', 'delete', ]); }); diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index 53a745dc2..e69853f7a 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -720,9 +720,33 @@ "MOVE_TO_DRAFT": "Move to draft", "PUBLISH_LOCALE": "Publish locale", "CUSTOMIZE_CONTENT": "Localize content", + "SELECT_POPULAR_CONTENT": "Select recommended content", "DELETE": "Delete" } }, + "POPULAR_CONTENT_DIALOG": { + "TITLE": "Recommended content", + "DESCRIPTION": "Pick up to 3 categories and 6 articles to feature on this locale's help center home page. Drag them into the order you want visitors to see.", + "SEARCH": "Search...", + "EMPTY": "No matching results", + "ADD_ANOTHER": "Add another...", + "SLOTS_LEFT": "{count} slots left", + "OVERRIDING_DEFAULTS": "Overriding defaults for this locale", + "CONFIRM": "Save recommendations", + "CATEGORIES": { + "LABEL": "Recommended categories", + "ARTICLES_COUNT": "No articles | {count} article | {count} articles" + }, + "ARTICLES": { + "LABEL": "Recommended articles", + "IN_CATEGORY": "in {category}", + "UNCATEGORIZED": "Uncategorized" + }, + "API": { + "SUCCESS_MESSAGE": "Recommended content updated successfully", + "ERROR_MESSAGE": "Unable to update recommended content. Try again." + } + }, "CONTENT_DIALOG": { "TITLE": "Localize content", "DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.", diff --git a/app/models/concerns/portal_config_schema.rb b/app/models/concerns/portal_config_schema.rb index de338b830..7e506a076 100644 --- a/app/models/concerns/portal_config_schema.rb +++ b/app/models/concerns/portal_config_schema.rb @@ -14,6 +14,18 @@ module PortalConfigSchema 'additionalProperties' => false }.freeze + # Per-locale recommended content for the portal home page: an ordered list of + # `category_ids` (the hero's "Recommended topics" pills) and `article_ids` (the + # "Recommended" articles section). When empty, the portal uses its defaults. + POPULAR_CONTENT_SCHEMA = { + 'type' => 'object', + 'properties' => { + 'category_ids' => { 'type' => %w[array null], 'items' => { 'type' => 'integer' } }, + 'article_ids' => { 'type' => %w[array null], 'items' => { 'type' => 'integer' } } + }, + 'additionalProperties' => false + }.freeze + CONFIG_PARAMS_SCHEMA = { 'type' => 'object', 'properties' => { @@ -27,6 +39,10 @@ module PortalConfigSchema 'locale_translations' => { 'type' => %w[object null], 'additionalProperties' => LOCALE_TRANSLATION_SCHEMA + }, + 'popular_content' => { + 'type' => %w[object null], + 'additionalProperties' => POPULAR_CONTENT_SCHEMA } }, 'required' => [], diff --git a/app/models/portal.rb b/app/models/portal.rb index 9d2da6965..b1f387b49 100644 --- a/app/models/portal.rb +++ b/app/models/portal.rb @@ -53,7 +53,12 @@ class Portal < ApplicationRecord scope :active, -> { where(archived: false) } # TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config - CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations].freeze + CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations + popular_content].freeze + + # Max number of recommended categories/articles shown per locale. + POPULAR_CATEGORY_LIMIT = 3 + POPULAR_ARTICLE_LIMIT = 6 def file_base_data { @@ -115,6 +120,14 @@ class Portal < ApplicationRecord config_value('layout').presence || 'classic' end + def popular_category_ids(locale = default_locale) + Array(config.dig('popular_content', locale.to_s, 'category_ids')).first(POPULAR_CATEGORY_LIMIT) + end + + def popular_article_ids(locale = default_locale) + Array(config.dig('popular_content', locale.to_s, 'article_ids')).first(POPULAR_ARTICLE_LIMIT) + end + def social_profiles config_value('social_profiles') || {} end diff --git a/app/views/api/v1/accounts/portals/_portal.json.jbuilder b/app/views/api/v1/accounts/portals/_portal.json.jbuilder index 93626ee36..2e4e78f1c 100644 --- a/app/views/api/v1/accounts/portals/_portal.json.jbuilder +++ b/app/views/api/v1/accounts/portals/_portal.json.jbuilder @@ -19,6 +19,7 @@ json.config do json.layout portal.layout json.social_profiles portal.social_profiles json.locale_translations portal.config['locale_translations'] || {} + json.popular_content portal.config['popular_content'] || {} end if portal.channel_web_widget diff --git a/app/views/layouts/_portal_scripts.html.erb b/app/views/layouts/_portal_scripts.html.erb index b1479cace..2df4a02fd 100644 --- a/app/views/layouts/_portal_scripts.html.erb +++ b/app/views/layouts/_portal_scripts.html.erb @@ -67,6 +67,11 @@ html.light { #category-block:hover #category-name { color: var(--dynamic-hover-color); } +/* Recommended topic pills in the classic hero */ +.recommended-pill:hover { + border-color: var(--dynamic-hover-color); + color: var(--dynamic-hover-color); +}