feat(companies): add company detail page (#14054)
This commit is contained in:
@@ -1,31 +1,375 @@
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import CompanyAPI from 'dashboard/api/companies';
|
||||
import { createStore } from 'dashboard/store/storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import snakecaseKeys from 'snakecase-keys';
|
||||
|
||||
const createInitialUIFlags = () => ({
|
||||
fetchingList: false,
|
||||
fetchingItem: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
deletingAvatar: false,
|
||||
deletingCustomAttributes: false,
|
||||
fetchingContacts: false,
|
||||
searchingContacts: false,
|
||||
creatingContact: false,
|
||||
removingContact: false,
|
||||
});
|
||||
|
||||
const camelizeCompany = data =>
|
||||
camelcaseKeys(data || {}, { deep: true, stopPaths: ['custom_attributes'] });
|
||||
|
||||
const camelizeContact = data =>
|
||||
camelcaseKeys(data || {}, {
|
||||
deep: true,
|
||||
stopPaths: ['custom_attributes', 'additional_attributes'],
|
||||
});
|
||||
|
||||
const normalizeMeta = meta => ({
|
||||
...camelcaseKeys(meta || {}),
|
||||
totalCount: Number(meta?.total_count || meta?.totalCount || meta?.count || 0),
|
||||
page: Number(meta?.page || meta?.current_page || 1),
|
||||
});
|
||||
|
||||
const appendFormData = (formData, key, value) => {
|
||||
if (value === undefined || value == null || value === '') return;
|
||||
if (
|
||||
value instanceof File ||
|
||||
value instanceof Blob ||
|
||||
typeof value !== 'object'
|
||||
) {
|
||||
formData.append(key, value);
|
||||
return;
|
||||
}
|
||||
Object.entries(value).forEach(([k, v]) =>
|
||||
appendFormData(formData, `${key}[${k}]`, v)
|
||||
);
|
||||
};
|
||||
|
||||
const buildCompanyRequestPayload = ({ avatar, customAttributes, ...rest }) => {
|
||||
const payload = {
|
||||
...snakecaseKeys(rest, { deep: true }),
|
||||
...(customAttributes && { custom_attributes: customAttributes }),
|
||||
...(avatar && { avatar }),
|
||||
};
|
||||
if (!avatar) return { company: payload };
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([k, v]) =>
|
||||
appendFormData(formData, `company[${k}]`, v)
|
||||
);
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const useCompaniesStore = createStore({
|
||||
name: 'companies',
|
||||
type: 'pinia',
|
||||
API: CompanyAPI,
|
||||
|
||||
getters: {
|
||||
getCompaniesList: state => {
|
||||
return camelcaseKeys(state.records, { deep: true });
|
||||
},
|
||||
getCompaniesList: state => state.records,
|
||||
},
|
||||
|
||||
actions: () => ({
|
||||
async search({ search, page, sort }) {
|
||||
setMeta(meta) {
|
||||
this.meta = normalizeMeta(meta);
|
||||
},
|
||||
|
||||
setActiveCompanyId(companyId) {
|
||||
this.activeCompanyId = Number(companyId);
|
||||
},
|
||||
|
||||
ensureActiveCompanyContext(companyId) {
|
||||
if (this.activeCompanyId === null) {
|
||||
this.setActiveCompanyId(companyId);
|
||||
}
|
||||
},
|
||||
|
||||
upsertCompanyRecord(record) {
|
||||
const index = this.records.findIndex(r => r.id === record.id);
|
||||
if (index === -1) this.records.push(record);
|
||||
else this.records[index] = record;
|
||||
},
|
||||
|
||||
updateCompanyContactsCount(companyId, contactsCount) {
|
||||
const company = this.getRecord(companyId);
|
||||
if (!company.id) return;
|
||||
this.upsertCompanyRecord({ ...company, contactsCount });
|
||||
},
|
||||
|
||||
clearContactSearchResults() {
|
||||
this.contactSearchResults = [];
|
||||
this.contactSearchMeta = {};
|
||||
this.activeContactSearchQuery = '';
|
||||
this.contactSearchRequestToken =
|
||||
(this.contactSearchRequestToken || 0) + 1;
|
||||
},
|
||||
|
||||
async get({ page = 1, sort = 'name' } = {}) {
|
||||
this.setUIFlag({ fetchingList: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.get({ page, sort });
|
||||
this.records = camelizeCompany(payload);
|
||||
this.setMeta(meta);
|
||||
return this.records;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ fetchingList: false });
|
||||
}
|
||||
},
|
||||
|
||||
async show(id) {
|
||||
this.setUIFlag({ fetchingItem: true });
|
||||
this.setActiveCompanyId(id);
|
||||
const activeCompanyId = Number(id);
|
||||
const requestToken = (this.companyDetailRequestToken || 0) + 1;
|
||||
this.companyDetailRequestToken = requestToken;
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.show(id);
|
||||
const company = camelizeCompany(payload);
|
||||
|
||||
if (
|
||||
this.companyDetailRequestToken !== requestToken ||
|
||||
this.activeCompanyId !== activeCompanyId
|
||||
) {
|
||||
return company;
|
||||
}
|
||||
|
||||
this.upsertCompanyRecord(company);
|
||||
this.setActiveCompanyId(company.id);
|
||||
return company;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (this.companyDetailRequestToken === requestToken) {
|
||||
this.setUIFlag({ fetchingItem: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async update({ id, ...companyAttrs }) {
|
||||
this.setUIFlag({ updatingItem: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.update(
|
||||
id,
|
||||
buildCompanyRequestPayload(companyAttrs)
|
||||
);
|
||||
const company = camelizeCompany(payload);
|
||||
this.upsertCompanyRecord(company);
|
||||
return company;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ updatingItem: false });
|
||||
}
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
this.setUIFlag({ deletingItem: true });
|
||||
try {
|
||||
await CompanyAPI.delete(id);
|
||||
this.records = this.records.filter(r => r.id !== Number(id));
|
||||
if (this.activeCompanyId === Number(id)) this.resetCompanyDetailState();
|
||||
return Number(id);
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ deletingItem: false });
|
||||
}
|
||||
},
|
||||
|
||||
async search({ search, page = 1, sort = 'name' }) {
|
||||
this.setUIFlag({ fetchingList: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.search(search, page, sort);
|
||||
this.records = payload;
|
||||
this.records = camelizeCompany(payload);
|
||||
this.setMeta(meta);
|
||||
return this.records;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ fetchingList: false });
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCompanyAvatar(companyId) {
|
||||
this.setUIFlag({ deletingAvatar: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.destroyAvatar(companyId);
|
||||
const company = camelizeCompany(payload);
|
||||
this.upsertCompanyRecord(company);
|
||||
return company;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ deletingAvatar: false });
|
||||
}
|
||||
},
|
||||
|
||||
async getCompanyContacts(companyId, page = 1) {
|
||||
this.setUIFlag({ fetchingContacts: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
const activeCompanyId = Number(companyId);
|
||||
const requestToken = (this.companyContactsRequestToken || 0) + 1;
|
||||
this.companyContactsRequestToken = requestToken;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.listContacts(companyId, page);
|
||||
const contacts = camelizeContact(payload);
|
||||
const normalizedMeta = normalizeMeta(meta);
|
||||
|
||||
if (
|
||||
this.companyContactsRequestToken !== requestToken ||
|
||||
this.activeCompanyId !== activeCompanyId
|
||||
) {
|
||||
return contacts;
|
||||
}
|
||||
|
||||
this.companyContacts = contacts;
|
||||
this.companyContactsMeta = normalizedMeta;
|
||||
this.updateCompanyContactsCount(companyId, normalizedMeta.totalCount);
|
||||
return contacts;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (this.companyContactsRequestToken === requestToken) {
|
||||
this.setUIFlag({ fetchingContacts: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async searchCompanyContactCandidates({ companyId, search, page = 1 }) {
|
||||
const query = search?.trim() || '';
|
||||
if (!query) {
|
||||
this.clearContactSearchResults();
|
||||
return [];
|
||||
}
|
||||
|
||||
this.setUIFlag({ searchingContacts: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
this.activeContactSearchQuery = query;
|
||||
const activeCompanyId = Number(companyId);
|
||||
const requestToken = (this.contactSearchRequestToken || 0) + 1;
|
||||
this.contactSearchRequestToken = requestToken;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.searchContacts(companyId, query, page);
|
||||
const contacts = camelizeContact(payload);
|
||||
const normalizedMeta = normalizeMeta(meta);
|
||||
|
||||
if (
|
||||
this.contactSearchRequestToken !== requestToken ||
|
||||
this.activeCompanyId !== activeCompanyId ||
|
||||
this.activeContactSearchQuery !== query
|
||||
) {
|
||||
return contacts;
|
||||
}
|
||||
|
||||
this.contactSearchResults = contacts;
|
||||
this.contactSearchMeta = normalizedMeta;
|
||||
return contacts;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (this.contactSearchRequestToken === requestToken) {
|
||||
this.setUIFlag({ searchingContacts: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async attachContactToCompany(companyId, contactId) {
|
||||
this.setUIFlag({ creatingContact: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
const activeCompanyId = Number(companyId);
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.createContact(companyId, {
|
||||
contact_id: contactId,
|
||||
});
|
||||
const contact = camelizeContact(payload);
|
||||
if (this.activeCompanyId === activeCompanyId) {
|
||||
await this.getCompanyContacts(companyId, 1);
|
||||
if (this.activeCompanyId === activeCompanyId) {
|
||||
this.clearContactSearchResults();
|
||||
}
|
||||
}
|
||||
return contact;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ creatingContact: false });
|
||||
}
|
||||
},
|
||||
|
||||
async removeContactFromCompany(companyId, contactId, page = null) {
|
||||
this.setUIFlag({ removingContact: true });
|
||||
this.ensureActiveCompanyContext(companyId);
|
||||
const activeCompanyId = Number(companyId);
|
||||
try {
|
||||
await CompanyAPI.removeContact(companyId, contactId);
|
||||
if (this.activeCompanyId === activeCompanyId) {
|
||||
await this.getCompanyContacts(
|
||||
companyId,
|
||||
page || this.companyContactsMeta?.page || 1
|
||||
);
|
||||
}
|
||||
return Number(contactId);
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ removingContact: false });
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCustomAttributes({ id, customAttributes }) {
|
||||
this.setUIFlag({ deletingCustomAttributes: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload },
|
||||
} = await CompanyAPI.destroyCustomAttributes(id, customAttributes);
|
||||
const company = camelizeCompany(payload);
|
||||
this.upsertCompanyRecord(company);
|
||||
return company;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ deletingCustomAttributes: false });
|
||||
}
|
||||
},
|
||||
|
||||
resetCompanyDetailState() {
|
||||
const { fetchingList } = this.uiFlags;
|
||||
this.activeCompanyId = null;
|
||||
this.companyDetailRequestToken =
|
||||
(this.companyDetailRequestToken || 0) + 1;
|
||||
this.companyContactsRequestToken =
|
||||
(this.companyContactsRequestToken || 0) + 1;
|
||||
this.contactSearchRequestToken =
|
||||
(this.contactSearchRequestToken || 0) + 1;
|
||||
this.companyContacts = [];
|
||||
this.companyContactsMeta = {};
|
||||
this.contactSearchResults = [];
|
||||
this.contactSearchMeta = {};
|
||||
this.activeContactSearchQuery = '';
|
||||
this.uiFlags = { ...createInitialUIFlags(), fetchingList };
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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