From fe5c353106a6579af83ae36bfbf5515daffb606e Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 21 May 2026 17:21:53 +0530 Subject: [PATCH] feat: cache canned responses and agents in IndexedDB Converts the canned-response and agent API clients to CacheEnabledApiClient subclasses and wires revalidate actions into the corresponding Vuex modules, so settings pages and pickers stop fetching the same lists on every visit. The canned-response search box continues to bypass the cache and hit the network directly, so server-side ordering stays authoritative. Also fixes a pre-existing gap in teams/revalidate: SET_TEAMS merges into a record dict by id and never removes missing entries, so a team deleted server-side would survive a reconnect-driven revalidate. Now matches the get action by emitting CLEAR_TEAMS first. Each affected spec clears its IDB store before every case, since the preflight-throws-on-axios-reject pattern they relied on is going away in the next commit. --- app/javascript/dashboard/api/agents.js | 19 +++++++++++-- .../dashboard/api/cannedResponse.js | 27 +++++++++++++++---- .../dashboard/store/modules/agents.js | 13 ++++++++- .../dashboard/store/modules/cannedResponse.js | 16 +++++++++++ .../modules/specs/agents/actions.spec.js | 8 ++++++ .../modules/specs/inboxes/actions.spec.js | 8 ++++++ .../modules/specs/labels/actions.spec.js | 8 ++++++ .../store/modules/specs/teams/actions.spec.js | 8 ++++++ .../dashboard/store/modules/teams/actions.js | 1 + 9 files changed, 100 insertions(+), 8 deletions(-) diff --git a/app/javascript/dashboard/api/agents.js b/app/javascript/dashboard/api/agents.js index cfc6b36ff..5688364d8 100644 --- a/app/javascript/dashboard/api/agents.js +++ b/app/javascript/dashboard/api/agents.js @@ -1,12 +1,27 @@ /* global axios */ -import ApiClient from './ApiClient'; +import CacheEnabledApiClient from './CacheEnabledApiClient'; -class Agents extends ApiClient { +class Agents extends CacheEnabledApiClient { constructor() { super('agents', { accountScoped: true }); } + // eslint-disable-next-line class-methods-use-this + get cacheModelName() { + return 'account_user'; + } + + // eslint-disable-next-line class-methods-use-this + extractDataFromResponse(response) { + return response.data; + } + + // eslint-disable-next-line class-methods-use-this + marshallData(dataToParse) { + return { data: dataToParse }; + } + bulkInvite({ emails }) { return axios.post(`${this.url}/bulk_create`, { emails, diff --git a/app/javascript/dashboard/api/cannedResponse.js b/app/javascript/dashboard/api/cannedResponse.js index f558dcaca..11ffb6a70 100644 --- a/app/javascript/dashboard/api/cannedResponse.js +++ b/app/javascript/dashboard/api/cannedResponse.js @@ -1,15 +1,32 @@ /* global axios */ -import ApiClient from './ApiClient'; +import CacheEnabledApiClient from './CacheEnabledApiClient'; -class CannedResponse extends ApiClient { +class CannedResponse extends CacheEnabledApiClient { constructor() { super('canned_responses', { accountScoped: true }); } - get({ searchKey }) { - const url = searchKey ? `${this.url}?search=${searchKey}` : this.url; - return axios.get(url); + // eslint-disable-next-line class-methods-use-this + get cacheModelName() { + return 'canned_response'; + } + + // eslint-disable-next-line class-methods-use-this + extractDataFromResponse(response) { + return response.data; + } + + // eslint-disable-next-line class-methods-use-this + marshallData(dataToParse) { + return { data: dataToParse }; + } + + get({ searchKey } = {}) { + if (searchKey) { + return axios.get(`${this.url}?search=${searchKey}`); + } + return super.get(true); } } diff --git a/app/javascript/dashboard/store/modules/agents.js b/app/javascript/dashboard/store/modules/agents.js index 26ad05e65..1e3200c77 100644 --- a/app/javascript/dashboard/store/modules/agents.js +++ b/app/javascript/dashboard/store/modules/agents.js @@ -44,13 +44,24 @@ export const actions = { get: async ({ commit }) => { commit(types.default.SET_AGENT_FETCHING_STATUS, true); try { - const response = await AgentAPI.get(); + const response = await AgentAPI.get(true); commit(types.default.SET_AGENT_FETCHING_STATUS, false); commit(types.default.SET_AGENTS, response.data); } catch (error) { commit(types.default.SET_AGENT_FETCHING_STATUS, false); } }, + revalidate: async ({ commit }, { newKey }) => { + try { + const isExistingKeyValid = await AgentAPI.validateCacheKey(newKey); + if (!isExistingKeyValid) { + const response = await AgentAPI.refetchAndCommit(newKey); + commit(types.default.SET_AGENTS, response.data); + } + } catch (error) { + // Ignore error + } + }, create: async ({ commit }, agentInfo) => { commit(types.default.SET_AGENT_CREATING_STATUS, true); try { diff --git a/app/javascript/dashboard/store/modules/cannedResponse.js b/app/javascript/dashboard/store/modules/cannedResponse.js index 568150392..f8301467f 100644 --- a/app/javascript/dashboard/store/modules/cannedResponse.js +++ b/app/javascript/dashboard/store/modules/cannedResponse.js @@ -47,6 +47,22 @@ const actions = { } }, + revalidateCannedResponses: async function revalidateCannedResponses( + { commit }, + { newKey } + ) { + try { + const isExistingKeyValid = + await CannedResponseAPI.validateCacheKey(newKey); + if (!isExistingKeyValid) { + const response = await CannedResponseAPI.refetchAndCommit(newKey); + commit(types.default.SET_CANNED, response.data); + } + } catch (error) { + // Ignore error + } + }, + createCannedResponse: async function createCannedResponse( { commit }, cannedObj diff --git a/app/javascript/dashboard/store/modules/specs/agents/actions.spec.js b/app/javascript/dashboard/store/modules/specs/agents/actions.spec.js index 1a43e9200..c65917909 100644 --- a/app/javascript/dashboard/store/modules/specs/agents/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/agents/actions.spec.js @@ -1,6 +1,7 @@ import axios from 'axios'; import { actions } from '../../agents'; import * as types from '../../../mutation-types'; +import AgentAPI from '../../../../api/agents'; import agentList from './fixtures'; const commit = vi.fn(); @@ -8,6 +9,13 @@ const dispatch = vi.fn(); global.axios = axios; vi.mock('axios'); +// Clear the IDB-backed cache between tests so each case starts from a known +// empty state and isn't affected by data persisted by a previous test. +beforeEach(async () => { + await AgentAPI.dataManager.initDb(); + await AgentAPI.dataManager.db.clear(AgentAPI.cacheModelName); +}); + describe('#actions', () => { describe('#get', () => { it('sends correct actions if API is success', async () => { diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js index edbc3f764..6a2c557fa 100644 --- a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js @@ -1,12 +1,20 @@ import axios from 'axios'; import { actions } from '../../inboxes'; import * as types from '../../../mutation-types'; +import InboxesAPI from '../../../../api/inboxes'; import inboxList from './fixtures'; const commit = vi.fn(); global.axios = axios; vi.mock('axios'); +// Clear the IDB-backed cache between tests so each case starts from a known +// empty state and isn't affected by data persisted by a previous test. +beforeEach(async () => { + await InboxesAPI.dataManager.initDb(); + await InboxesAPI.dataManager.db.clear(InboxesAPI.cacheModelName); +}); + describe('#actions', () => { describe('#get', () => { it('sends correct actions if API is success', async () => { diff --git a/app/javascript/dashboard/store/modules/specs/labels/actions.spec.js b/app/javascript/dashboard/store/modules/specs/labels/actions.spec.js index 9ffefc276..141ede430 100644 --- a/app/javascript/dashboard/store/modules/specs/labels/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/labels/actions.spec.js @@ -1,12 +1,20 @@ import axios from 'axios'; import { actions } from '../../labels'; import * as types from '../../../mutation-types'; +import LabelsAPI from '../../../../api/labels'; import labelsList from './fixtures'; const commit = vi.fn(); global.axios = axios; vi.mock('axios'); +// Clear the IDB-backed cache between tests so each case starts from a known +// empty state and isn't affected by data persisted by a previous test. +beforeEach(async () => { + await LabelsAPI.dataManager.initDb(); + await LabelsAPI.dataManager.db.clear(LabelsAPI.cacheModelName); +}); + describe('#actions', () => { describe('#get', () => { it('sends correct actions if API is success', async () => { diff --git a/app/javascript/dashboard/store/modules/specs/teams/actions.spec.js b/app/javascript/dashboard/store/modules/specs/teams/actions.spec.js index 7359f82b6..5c1b5f1ce 100644 --- a/app/javascript/dashboard/store/modules/specs/teams/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/teams/actions.spec.js @@ -8,12 +8,20 @@ import { EDIT_TEAM, DELETE_TEAM, } from '../../teams/types'; +import TeamsAPI from '../../../../api/teams'; import teamsList from './fixtures'; const commit = vi.fn(); global.axios = axios; vi.mock('axios'); +// Clear the IDB-backed cache between tests so each case starts from a known +// empty state and isn't affected by data persisted by a previous test. +beforeEach(async () => { + await TeamsAPI.dataManager.initDb(); + await TeamsAPI.dataManager.db.clear(TeamsAPI.cacheModelName); +}); + describe('#actions', () => { describe('#get', () => { it('sends correct actions if API is success', async () => { diff --git a/app/javascript/dashboard/store/modules/teams/actions.js b/app/javascript/dashboard/store/modules/teams/actions.js index a8eab88b5..3b8209017 100644 --- a/app/javascript/dashboard/store/modules/teams/actions.js +++ b/app/javascript/dashboard/store/modules/teams/actions.js @@ -27,6 +27,7 @@ export const actions = { const isExistingKeyValid = await TeamsAPI.validateCacheKey(newKey); if (!isExistingKeyValid) { const response = await TeamsAPI.refetchAndCommit(newKey); + commit(CLEAR_TEAMS); commit(SET_TEAMS, response.data); } } catch (error) {