feat: introduce voice call dashboard (#14954)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import CompanyAPI from 'dashboard/api/companies';
|
||||
import { useCompaniesStore } from '../companies';
|
||||
|
||||
vi.mock('dashboard/api/companies', () => ({
|
||||
default: {
|
||||
show: vi.fn(),
|
||||
update: vi.fn(),
|
||||
destroyAvatar: vi.fn(),
|
||||
listContacts: vi.fn(),
|
||||
searchContacts: vi.fn(),
|
||||
createContact: vi.fn(),
|
||||
removeContact: vi.fn(),
|
||||
destroyCustomAttributes: 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 };
|
||||
};
|
||||
|
||||
describe('companies store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps the latest active company when show requests resolve out of order', async () => {
|
||||
const firstRequest = createDeferred();
|
||||
const secondRequest = createDeferred();
|
||||
|
||||
CompanyAPI.show
|
||||
.mockImplementationOnce(() => firstRequest.promise)
|
||||
.mockImplementationOnce(() => secondRequest.promise);
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
const staleRequest = companiesStore.show(1);
|
||||
const currentRequest = companiesStore.show(2);
|
||||
|
||||
secondRequest.resolve({
|
||||
data: {
|
||||
payload: {
|
||||
id: 2,
|
||||
name: 'Beta Company',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await currentRequest;
|
||||
|
||||
expect(companiesStore.activeCompanyId).toBe(2);
|
||||
expect(companiesStore.getUIFlags.fetchingItem).toBe(false);
|
||||
|
||||
firstRequest.resolve({
|
||||
data: {
|
||||
payload: {
|
||||
id: 1,
|
||||
name: 'Alpha Company',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await staleRequest;
|
||||
|
||||
expect(companiesStore.activeCompanyId).toBe(2);
|
||||
expect(companiesStore.getRecord(1)).toEqual({});
|
||||
expect(companiesStore.getRecord(2)).toEqual(
|
||||
expect.objectContaining({ id: 2, name: 'Beta Company' })
|
||||
);
|
||||
expect(companiesStore.getUIFlags.fetchingItem).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps avatar files intact when building multipart update params', async () => {
|
||||
CompanyAPI.update.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: {
|
||||
id: 1,
|
||||
name: 'Acme',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
const avatar = new File(['avatar'], 'avatar.png', { type: 'image/png' });
|
||||
|
||||
await companiesStore.update({
|
||||
id: 1,
|
||||
name: 'Acme',
|
||||
avatar,
|
||||
});
|
||||
|
||||
const formData = CompanyAPI.update.mock.calls[0][1];
|
||||
expect(formData.get('company[avatar]')).toBe(avatar);
|
||||
expect(formData.get('company[name]')).toBe('Acme');
|
||||
});
|
||||
|
||||
it('preserves custom attribute keys when building update params', async () => {
|
||||
CompanyAPI.update.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: {
|
||||
id: 1,
|
||||
name: 'Acme',
|
||||
custom_attributes: {
|
||||
subscriptionPlan: 'Enterprise',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
await companiesStore.update({
|
||||
id: 1,
|
||||
customAttributes: {
|
||||
subscriptionPlan: 'Enterprise',
|
||||
},
|
||||
});
|
||||
|
||||
expect(CompanyAPI.update).toHaveBeenCalledWith(1, {
|
||||
company: {
|
||||
custom_attributes: {
|
||||
subscriptionPlan: 'Enterprise',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('links an existing contact and refreshes company contacts', async () => {
|
||||
CompanyAPI.createContact.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: {
|
||||
id: 2,
|
||||
name: 'Jane Contact',
|
||||
company_id: 1,
|
||||
linked_to_current_company: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
CompanyAPI.listContacts.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Jane Contact',
|
||||
company_id: 1,
|
||||
linked_to_current_company: true,
|
||||
},
|
||||
],
|
||||
meta: { total_count: 1, page: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
companiesStore.setActiveCompanyId(1);
|
||||
|
||||
await companiesStore.attachContactToCompany(1, 2);
|
||||
|
||||
expect(CompanyAPI.createContact).toHaveBeenCalledWith(1, {
|
||||
contact_id: 2,
|
||||
});
|
||||
expect(CompanyAPI.listContacts).toHaveBeenCalledWith(1, 1);
|
||||
expect(companiesStore.companyContacts).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 2,
|
||||
companyId: 1,
|
||||
linkedToCurrentCompany: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a company custom attribute and updates the company record', async () => {
|
||||
CompanyAPI.destroyCustomAttributes.mockResolvedValueOnce({
|
||||
data: {
|
||||
payload: {
|
||||
id: 1,
|
||||
name: 'Acme',
|
||||
custom_attributes: {
|
||||
region: 'us',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
await companiesStore.deleteCustomAttributes({
|
||||
id: 1,
|
||||
customAttributes: ['plan'],
|
||||
});
|
||||
|
||||
expect(CompanyAPI.destroyCustomAttributes).toHaveBeenCalledWith(1, [
|
||||
'plan',
|
||||
]);
|
||||
expect(companiesStore.getRecord(1)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
customAttributes: {
|
||||
region: 'us',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user