feat(companies): add company detail page (#14054)

This commit is contained in:
salmonumbrella
2026-05-06 20:50:27 +05:30
committed by GitHub
parent 42bba748cf
commit fe44b07147
49 changed files with 2461 additions and 193 deletions
+351 -7
View File
@@ -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',
},
})
);
});
});