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 @@ + + 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); +} + + diff --git a/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue b/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue new file mode 100644 index 000000000..6eaa81b7b --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue @@ -0,0 +1,158 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue b/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue new file mode 100644 index 000000000..b0da9c41e --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue @@ -0,0 +1,56 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue b/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue new file mode 100644 index 000000000..f57d154eb --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue @@ -0,0 +1,34 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue new file mode 100644 index 000000000..4d01b0500 --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue @@ -0,0 +1,250 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/constants.js b/app/javascript/dashboard/components-next/Calls/constants.js new file mode 100644 index 000000000..f7399bfef --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/constants.js @@ -0,0 +1,51 @@ +import { + VOICE_CALL_STATUS, + VOICE_CALL_DIRECTION, +} from 'dashboard/components-next/message/constants'; + +export const CALL_KIND = { + ONGOING: 'ongoing', + INCOMING: 'incoming', + OUTGOING: 'outgoing', + MISSED: 'missed', + NO_REPLY: 'no_reply', + FAILED: 'failed', +}; + +// The API returns display values: status (ringing/in-progress/completed/ +// no-answer/failed) and direction (inbound/outbound). The list UI presents +// them as a single "kind" per row. +export const getCallKind = call => { + if ( + [VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes( + call.status + ) + ) { + return CALL_KIND.ONGOING; + } + if ( + [VOICE_CALL_STATUS.FAILED, VOICE_CALL_STATUS.REJECTED].includes(call.status) + ) { + return CALL_KIND.FAILED; + } + const isInbound = call.direction === VOICE_CALL_DIRECTION.INBOUND; + if (call.status === VOICE_CALL_STATUS.NO_ANSWER) { + return isInbound ? CALL_KIND.MISSED : CALL_KIND.NO_REPLY; + } + return isInbound ? CALL_KIND.INCOMING : CALL_KIND.OUTGOING; +}; + +// Filter chips map to the status/direction params supported by CallFinder. +export const CALL_ACTIVITY_PARAMS = { + missed: { + status: VOICE_CALL_STATUS.NO_ANSWER, + direction: VOICE_CALL_DIRECTION.INBOUND, + }, + no_reply: { + status: VOICE_CALL_STATUS.NO_ANSWER, + direction: VOICE_CALL_DIRECTION.OUTBOUND, + }, + incoming: { direction: VOICE_CALL_DIRECTION.INBOUND }, + outgoing: { direction: VOICE_CALL_DIRECTION.OUTBOUND }, + in_progress: { status: VOICE_CALL_STATUS.IN_PROGRESS }, +}; diff --git a/app/javascript/dashboard/components-next/audio/AudioPlayer.vue b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue new file mode 100644 index 000000000..c716ffbfb --- /dev/null +++ b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue @@ -0,0 +1,158 @@ + + + diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 294e0dd5d..909a44c69 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -2,6 +2,7 @@ import { h, ref, computed, onMounted, watch } from 'vue'; import { provideSidebarContext, useSidebarResize } from './provider'; import { useAccount } from 'dashboard/composables/useAccount'; +import { useConfig } from 'dashboard/composables/useConfig'; import { useKbd } from 'dashboard/composables/utils/useKbd'; import { useMapGetter } from 'dashboard/composables/store'; import { useStore } from 'vuex'; @@ -43,7 +44,14 @@ const emit = defineEmits([ ]); const { accountScopedRoute, isOnChatwootCloud } = useAccount(); +const { isEnterprise } = useConfig(); const store = useStore(); + +// Calls run on the enterprise-only API (cloud runs enterprise); hide the entry +// on community so it doesn't lead to a dashboard/CTA the backend can't serve. +const isCallsAvailable = computed( + () => isOnChatwootCloud.value || isEnterprise +); const searchShortcut = useKbd([`$mod`, 'k']); const { t } = useI18n(); @@ -563,6 +571,17 @@ const menuItems = computed(() => { }, ], }, + ...(isCallsAvailable.value + ? [ + { + name: 'Calls', + label: t('SIDEBAR.CALLS'), + icon: 'i-lucide-phone', + to: accountScopedRoute('calls_dashboard_index'), + activeOn: ['calls_dashboard_index'], + }, + ] + : []), { name: 'Contacts', label: t('SIDEBAR.CONTACTS'), diff --git a/app/javascript/dashboard/i18n/locale/en/calls.json b/app/javascript/dashboard/i18n/locale/en/calls.json new file mode 100644 index 000000000..0ca5441f0 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/calls.json @@ -0,0 +1,45 @@ +{ + "CALLS_PAGE": { + "HEADER": "Calls", + "ALL_CALLS": "All Calls", + "ALL_CALLS_COUNT": "All Calls ({count})", + "EMPTY_STATE": "No calls found", + "SETUP": { + "TITLE": "Make and receive calls in one place", + "SUBTITLE": "Set up a voice channel to start handling calls with your team. Every call, along with its recording, will appear here.", + "ACTION": "Set up voice channel" + }, + "FILTERS": { + "MISSED": "Missed", + "NO_REPLY": "No reply", + "OTHER_ACTIVITY": "Other activity", + "INCOMING": "Incoming", + "OUTGOING": "Outgoing", + "IN_PROGRESS": "In progress", + "ASSIGNEE": "Assignee", + "ALL_ASSIGNEES": "All assignees", + "MORE_FILTERS": "More filters", + "INBOX": "Inbox", + "ALL_INBOXES": "All inboxes" + }, + "STATUS": { + "ONGOING": "Ongoing", + "INCOMING": "Incoming", + "OUTGOING": "Outgoing", + "MISSED": "Missed", + "NO_REPLY": "No reply", + "FAILED": "Failed" + }, + "ROW": { + "PICKED_BY": "Picked by", + "DIALED_BY": "Dialed by", + "ANSWERED": "Answered", + "RINGING": "Ringing", + "IN_PROGRESS": "In progress", + "NO_AGENT": "No agent answered this call", + "NO_CONTACT_ANSWER": "Contact did not answer", + "FAILED": "This call could not be connected", + "YESTERDAY": "Yesterday" + } + } +} diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index 12db16ba7..990ab9835 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -5,6 +5,7 @@ import attributesMgmt from './attributesMgmt.json'; import auditLogs from './auditLogs.json'; import automation from './automation.json'; import bulkActions from './bulkActions.json'; +import calls from './calls.json'; import campaign from './campaign.json'; import cannedMgmt from './cannedMgmt.json'; import chatlist from './chatlist.json'; @@ -51,6 +52,7 @@ export default { ...auditLogs, ...automation, ...bulkActions, + ...calls, ...campaign, ...cannedMgmt, ...chatlist, diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index ceb0438b1..c013a177f 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -324,6 +324,7 @@ "COMPANIES": "Companies", "ALL_COMPANIES": "All Companies", "CAPTAIN": "Captain", + "CALLS": "Calls", "CAPTAIN_ASSISTANTS": "Assistants", "CAPTAIN_OVERVIEW": "Overview", "CAPTAIN_DOCUMENTS": "Documents", diff --git a/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue new file mode 100644 index 000000000..80f8e08c5 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/calls/pages/CallsIndex.vue @@ -0,0 +1,168 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/calls/routes.js b/app/javascript/dashboard/routes/dashboard/calls/routes.js new file mode 100644 index 000000000..fa952a749 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/calls/routes.js @@ -0,0 +1,22 @@ +import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes'; +import { + CONVERSATION_PERMISSIONS, + ROLES, +} from 'dashboard/constants/permissions'; +import { frontendURL } from '../../../helper/URLHelper'; +import CallsIndex from './pages/CallsIndex.vue'; + +export const routes = [ + { + path: frontendURL('accounts/:accountId/calls'), + name: 'calls_dashboard_index', + component: CallsIndex, + meta: { + permissions: [...ROLES, ...CONVERSATION_PERMISSIONS], + installationTypes: [ + INSTALLATION_TYPES.CLOUD, + INSTALLATION_TYPES.ENTERPRISE, + ], + }, + }, +]; diff --git a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js index 04d11c621..4611aad38 100644 --- a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js +++ b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js @@ -1,6 +1,7 @@ import settings from './settings/settings.routes'; import conversation from './conversation/conversation.routes'; import { routes as searchRoutes } from '../../modules/search/search.routes'; +import { routes as callRoutes } from './calls/routes'; import { routes as contactRoutes } from './contacts/routes'; import { routes as companyRoutes } from './companies/routes'; import { routes as notificationRoutes } from './notifications/routes'; @@ -25,6 +26,7 @@ export default { ...inboxRoutes, ...conversation.routes, ...settings.routes, + ...callRoutes, ...contactRoutes, ...companyRoutes, ...searchRoutes, diff --git a/app/javascript/dashboard/stores/callHistory.js b/app/javascript/dashboard/stores/callHistory.js new file mode 100644 index 000000000..c0a2c0fa9 --- /dev/null +++ b/app/javascript/dashboard/stores/callHistory.js @@ -0,0 +1,40 @@ +import camelcaseKeys from 'camelcase-keys'; +import CallsAPI from 'dashboard/api/calls'; +import { throwErrorMessage } from 'dashboard/store/utils/api'; +import { defineStore } from 'pinia'; + +export const useCallHistoryStore = defineStore('callHistory', { + state: () => ({ + records: [], + meta: { count: 0, currentPage: 1, totalPages: 0 }, + uiFlags: { isFetching: false }, + fetchRequestToken: 0, + }), + + actions: { + async fetchCalls(params = {}) { + this.uiFlags.isFetching = true; + this.fetchRequestToken += 1; + const requestToken = this.fetchRequestToken; + try { + const { data } = await CallsAPI.get(params); + // A newer fetch (filter/page change) superseded this one; drop the result. + if (this.fetchRequestToken !== requestToken) return this.records; + this.records = camelcaseKeys(data.payload, { deep: true }); + this.meta = camelcaseKeys(data.meta); + return this.records; + } catch (error) { + // Don't surface errors from a fetch that a newer request already replaced. + if (this.fetchRequestToken !== requestToken) return this.records; + // Drop the previous results so stale rows aren't shown under the new view. + this.records = []; + this.meta = { count: 0, currentPage: 1, totalPages: 0 }; + return throwErrorMessage(error); + } finally { + if (this.fetchRequestToken === requestToken) { + this.uiFlags.isFetching = false; + } + } + }, + }, +}); diff --git a/app/javascript/dashboard/stores/specs/callHistory.spec.js b/app/javascript/dashboard/stores/specs/callHistory.spec.js new file mode 100644 index 000000000..834ae8ab5 --- /dev/null +++ b/app/javascript/dashboard/stores/specs/callHistory.spec.js @@ -0,0 +1,117 @@ +import { setActivePinia, createPinia } from 'pinia'; +import CallsAPI from 'dashboard/api/calls'; +import { throwErrorMessage } from 'dashboard/store/utils/api'; +import { useCallHistoryStore } from '../callHistory'; + +vi.mock('dashboard/api/calls', () => ({ + default: { + get: vi.fn(), + }, +})); + +vi.mock('dashboard/store/utils/api', () => ({ + throwErrorMessage: vi.fn(error => error), +})); + +const createDeferred = () => { + let resolve; + const promise = new Promise(res => { + resolve = res; + }); + + return { promise, resolve }; +}; + +const buildResponse = (payload, meta) => ({ data: { payload, meta } }); + +describe('callHistory store', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it('fetches calls and stores camelized records and meta', async () => { + CallsAPI.get.mockResolvedValue( + buildResponse( + [{ id: 1, recording_url: 'rec.mp3', contact: { phone_number: '+1' } }], + { count: 44, current_page: 1, total_pages: 2 } + ) + ); + const store = useCallHistoryStore(); + + await store.fetchCalls({ page: 1, status: 'no-answer' }); + + expect(CallsAPI.get).toHaveBeenCalledWith({ page: 1, status: 'no-answer' }); + expect(store.records).toEqual([ + { id: 1, recordingUrl: 'rec.mp3', contact: { phoneNumber: '+1' } }, + ]); + expect(store.meta).toEqual({ count: 44, currentPage: 1, totalPages: 2 }); + expect(store.uiFlags.isFetching).toBe(false); + }); + + it('drops a superseded response that resolves after the latest one', async () => { + const firstRequest = createDeferred(); + const secondRequest = createDeferred(); + CallsAPI.get + .mockImplementationOnce(() => firstRequest.promise) + .mockImplementationOnce(() => secondRequest.promise); + const store = useCallHistoryStore(); + + const staleFetch = store.fetchCalls({ page: 1 }); + const currentFetch = store.fetchCalls({ page: 2 }); + + secondRequest.resolve( + buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 }) + ); + await currentFetch; + + firstRequest.resolve( + buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 }) + ); + await staleFetch; + + expect(store.records).toEqual([{ id: 2 }]); + expect(store.meta.count).toBe(1); + expect(store.uiFlags.isFetching).toBe(false); + }); + + it('keeps fetching state when a superseded response resolves first', async () => { + const firstRequest = createDeferred(); + const secondRequest = createDeferred(); + CallsAPI.get + .mockImplementationOnce(() => firstRequest.promise) + .mockImplementationOnce(() => secondRequest.promise); + const store = useCallHistoryStore(); + + const staleFetch = store.fetchCalls({ page: 1 }); + const currentFetch = store.fetchCalls({ page: 2 }); + + firstRequest.resolve( + buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 }) + ); + await staleFetch; + + expect(store.records).toEqual([]); + expect(store.uiFlags.isFetching).toBe(true); + + secondRequest.resolve( + buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 }) + ); + await currentFetch; + + expect(store.records).toEqual([{ id: 2 }]); + expect(store.uiFlags.isFetching).toBe(false); + }); + + it('surfaces the error and resets fetching state on failure', async () => { + const error = new Error('Request failed'); + CallsAPI.get.mockRejectedValue(error); + const store = useCallHistoryStore(); + + await store.fetchCalls(); + + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(store.records).toEqual([]); + expect(store.uiFlags.isFetching).toBe(false); + }); +}); diff --git a/app/javascript/dashboard/stores/companies.spec.js b/app/javascript/dashboard/stores/specs/companies.spec.js similarity index 99% rename from app/javascript/dashboard/stores/companies.spec.js rename to app/javascript/dashboard/stores/specs/companies.spec.js index 1c44d4292..98d9fd9b6 100644 --- a/app/javascript/dashboard/stores/companies.spec.js +++ b/app/javascript/dashboard/stores/specs/companies.spec.js @@ -1,6 +1,6 @@ import { setActivePinia, createPinia } from 'pinia'; import CompanyAPI from 'dashboard/api/companies'; -import { useCompaniesStore } from './companies'; +import { useCompaniesStore } from '../companies'; vi.mock('dashboard/api/companies', () => ({ default: { diff --git a/app/javascript/shared/helpers/specs/timeHelper.spec.js b/app/javascript/shared/helpers/specs/timeHelper.spec.js index d12a42bc8..8a4b50b17 100644 --- a/app/javascript/shared/helpers/specs/timeHelper.spec.js +++ b/app/javascript/shared/helpers/specs/timeHelper.spec.js @@ -1,11 +1,12 @@ import { - messageStamp, - messageTimestamp, - dynamicTime, dateFormat, - shortTimestamp, + dynamicTime, getDayDifferenceFromNow, hasOneDayPassed, + messageStamp, + messageTimestamp, + relativeDayTimestamp, + shortTimestamp, } from 'shared/helpers/timeHelper'; beforeEach(() => { @@ -37,6 +38,33 @@ describe('#messageTimestamp', () => { }); }); +describe('#relativeDayTimestamp', () => { + // System time is mocked to May 5, 2023 00:00 UTC. + const toUnix = date => Math.floor(date / 1000); + + it('returns the time for timestamps from today', () => { + const today = toUnix(Date.UTC(2023, 4, 5, 15, 35, 0)); + expect(relativeDayTimestamp(today, 'Yesterday')).toEqual('3:35 PM'); + }); + + it('returns the supplied label for timestamps from yesterday', () => { + const yesterday = toUnix(Date.UTC(2023, 4, 4, 9, 0, 0)); + expect(relativeDayTimestamp(yesterday, 'Yesterday')).toEqual('Yesterday'); + }); + + it('returns a day and month for older timestamps in the current year', () => { + const earlierThisYear = toUnix(Date.UTC(2023, 1, 10, 12, 0, 0)); + expect(relativeDayTimestamp(earlierThisYear, 'Yesterday')).toEqual( + 'Feb 10' + ); + }); + + it('returns a full date for timestamps from a previous year', () => { + const lastYear = toUnix(Date.UTC(2021, 1, 10, 12, 0, 0)); + expect(relativeDayTimestamp(lastYear, 'Yesterday')).toEqual('Feb 10, 2021'); + }); +}); + describe('#dynamicTime', () => { it('returns correct value', () => { Date.now = vi.fn(() => new Date(Date.UTC(2023, 1, 14)).valueOf()); diff --git a/app/javascript/shared/helpers/timeHelper.js b/app/javascript/shared/helpers/timeHelper.js index db5609d89..cde5ccf89 100644 --- a/app/javascript/shared/helpers/timeHelper.js +++ b/app/javascript/shared/helpers/timeHelper.js @@ -1,6 +1,9 @@ import { format, isSameYear, + isThisYear, + isToday, + isYesterday, fromUnixTime, formatDistanceToNow, differenceInDays, @@ -33,6 +36,22 @@ export const messageTimestamp = (time, dateFormat = 'MMM d, yyyy') => { return messageDate; }; +/** + * Formats a Unix timestamp relative to today: the time for today, a caller- + * supplied label for yesterday, and a date otherwise. The yesterday label is + * passed in so the caller keeps ownership of translation. + * @param {number} time - Unix timestamp. + * @param {string} yesterdayLabel - Localized label shown for yesterday. + * @returns {string} Formatted timestamp string. + */ +export const relativeDayTimestamp = (time, yesterdayLabel) => { + const date = fromUnixTime(time); + if (isToday(date)) return format(date, 'h:mm a'); + if (isYesterday(date)) return yesterdayLabel; + if (isThisYear(date)) return format(date, 'MMM d'); + return format(date, 'MMM d, yyyy'); +}; + /** * Converts a Unix timestamp to a relative time string (e.g., 3 hours ago). * @param {number} time - Unix timestamp. From 08f49f5896f8d2959c10c2434553705e5668570d Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:28:48 +0530 Subject: [PATCH 11/38] fix: prevent channel list crash on hard reload (#15074) --- .../routes/dashboard/settings/inbox/ChannelList.vue | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index de0c6059b..fd509d350 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -1,5 +1,5 @@