From 2891a72cb9cb43129e3ae8a197412f6184c030d4 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:18:58 +0530 Subject: [PATCH 01/39] feat(whatsapp): enable reconfigure for embedded signup inboxes (#15038) --- .../whatsapp/authorizations_controller.rb | 9 ++- app/javascript/dashboard/featureFlags.js | 1 + .../inbox/settingsPage/ConfigurationPage.vue | 60 ++++++++++++++++++- .../whatsapp/embedded_signup_service.rb | 2 +- .../whatsapp/reauthorization_service.rb | 6 +- config/features.yml | 4 ++ .../authorizations_controller_spec.rb | 19 ++---- spec/models/account_spec.rb | 2 +- .../whatsapp/embedded_signup_service_spec.rb | 4 +- 9 files changed, 82 insertions(+), 25 deletions(-) diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index db94113d9..580ae77c6 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -1,4 +1,6 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController + # Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins. + before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? } before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } # POST /api/v1/accounts/:account_id/whatsapp/authorization @@ -31,7 +33,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: end def validate_reauthorization_required - return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup? + return if @inbox.channel.reauthorization_required? || can_reconfigure_channel? render json: { success: false, @@ -39,10 +41,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: }, status: :unprocessable_entity end - def can_upgrade_to_embedded_signup? + def can_reconfigure_channel? channel = @inbox.channel return false unless channel.provider == 'whatsapp_cloud' + # Reconfiguring a live embedded-signup channel requires the feature flag. + return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup' + true end diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index e3c57e99d..eb8e1b7bd 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -8,6 +8,7 @@ export const FEATURE_FLAGS = { CAMPAIGNS: 'campaigns', WHATSAPP_CAMPAIGNS: 'whatsapp_campaign', WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer', + WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure', CANNED_RESPONSES: 'canned_responses', CRM: 'crm', CUSTOM_ATTRIBUTES: 'custom_attributes', diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue index e1ecdbd3f..61038cec1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue @@ -1,5 +1,9 @@ + + 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 20/39] 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 @@