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
+35 -17
View File
@@ -1,21 +1,12 @@
/* global axios */
import ApiClient from './ApiClient';
export const buildCompanyParams = (page, sort) => {
let params = `page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
export const buildSearchParams = (query, page, sort) => {
let params = `q=${encodeURIComponent(query)}&page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
const buildParams = params =>
new URLSearchParams(
Object.entries(params).filter(
([key, value]) => value !== undefined && (value !== '' || key === 'q')
)
).toString();
class CompanyAPI extends ApiClient {
constructor() {
@@ -24,14 +15,41 @@ class CompanyAPI extends ApiClient {
get(params = {}) {
const { page = 1, sort = 'name' } = params;
const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
const requestURL = `${this.url}?${buildParams({ page, sort })}`;
return axios.get(requestURL);
}
search(query = '', page = 1, sort = 'name') {
const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
const requestURL = `${this.url}/search?${buildParams({ q: query, page, sort })}`;
return axios.get(requestURL);
}
listContacts(id, page = 1) {
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
}
searchContacts(id, query = '', page = 1) {
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
return axios.get(requestURL);
}
createContact(id, payload) {
return axios.post(`${this.url}/${id}/contacts`, payload);
}
removeContact(id, contactId) {
return axios.delete(`${this.url}/${id}/contacts/${contactId}`);
}
destroyCustomAttributes(id, customAttributes) {
return axios.post(`${this.url}/${id}/destroy_custom_attributes`, {
custom_attributes: customAttributes,
});
}
destroyAvatar(id) {
return axios.delete(`${this.url}/${id}/avatar`);
}
}
export default new CompanyAPI();
@@ -1,7 +1,4 @@
import companyAPI, {
buildCompanyParams,
buildSearchParams,
} from '../companies';
import companyAPI from '../companies';
import ApiClient from '../ApiClient';
describe('#CompanyAPI', () => {
@@ -9,7 +6,6 @@ describe('#CompanyAPI', () => {
expect(companyAPI).toBeInstanceOf(ApiClient);
expect(companyAPI).toHaveProperty('get');
expect(companyAPI).toHaveProperty('show');
expect(companyAPI).toHaveProperty('create');
expect(companyAPI).toHaveProperty('update');
expect(companyAPI).toHaveProperty('delete');
expect(companyAPI).toHaveProperty('search');
@@ -32,111 +28,69 @@ describe('#CompanyAPI', () => {
window.axios = originalAxios;
});
it('#get with default params', () => {
it('#get includes pagination and sorting params', () => {
companyAPI.get({});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=name'
);
});
it('#get with page and sort params', () => {
companyAPI.get({ page: 2, sort: 'domain' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=2&sort=domain'
);
});
it('#get with descending sort', () => {
companyAPI.get({ page: 1, sort: '-created_at' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=-created_at'
);
});
it('#search with query', () => {
companyAPI.search('acme', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme&page=1&sort=name'
);
});
it('#search with special characters in query', () => {
it('#search encodes query params', () => {
companyAPI.search('acme & co', 2, 'domain');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
'/api/v1/companies/search?q=acme+%26+co&page=2&sort=domain'
);
});
it('#search with descending sort', () => {
companyAPI.search('test', 1, '-created_at');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=test&page=1&sort=-created_at'
);
});
it('#search with empty query', () => {
it('#search keeps empty query param for backend validation', () => {
companyAPI.search('', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=&page=1&sort=name'
);
});
});
});
describe('#buildCompanyParams', () => {
it('returns correct string with page only', () => {
expect(buildCompanyParams(1)).toBe('page=1');
});
it('returns correct string with page and sort', () => {
expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
});
it('returns correct string with different page', () => {
expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
});
it('returns correct string with descending sort', () => {
expect(buildCompanyParams(1, '-created_at')).toBe(
'page=1&sort=-created_at'
);
});
it('returns correct string without sort parameter', () => {
expect(buildCompanyParams(2, '')).toBe('page=2');
});
});
describe('#buildSearchParams', () => {
it('returns correct string with all parameters', () => {
expect(buildSearchParams('acme', 1, 'name')).toBe(
'q=acme&page=1&sort=name'
);
});
it('returns correct string with special characters', () => {
expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
'q=acme%20%26%20co&page=2&sort=domain'
);
});
it('returns correct string with empty query', () => {
expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
});
it('returns correct string without sort parameter', () => {
expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
});
it('returns correct string with descending sort', () => {
expect(buildSearchParams('company', 3, '-created_at')).toBe(
'q=company&page=3&sort=-created_at'
);
});
it('encodes special characters correctly', () => {
expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
'q=test%40example.com&page=1&sort=name'
);
it('#destroyAvatar deletes the company avatar endpoint', () => {
companyAPI.destroyAvatar(1);
expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/companies/1/avatar'
);
});
it('#listContacts fetches company contacts', () => {
companyAPI.listContacts(1, 2);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts?page=2'
);
});
it('#searchContacts encodes contact search params', () => {
companyAPI.searchContacts(1, 'jane & co', 3);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts/search?q=jane+%26+co&page=3'
);
});
it('#createContact links a contact to the company', () => {
companyAPI.createContact(1, { contact_id: 2 });
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts',
{ contact_id: 2 }
);
});
it('#removeContact unlinks a contact from the company', () => {
companyAPI.removeContact(1, 2);
expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts/2'
);
});
it('#destroyCustomAttributes removes company custom attributes', () => {
companyAPI.destroyCustomAttributes(1, ['plan']);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/companies/1/destroy_custom_attributes',
{ custom_attributes: ['plan'] }
);
});
});
});
@@ -1,7 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatDistanceToNow } from 'date-fns';
import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
@@ -12,9 +12,8 @@ const props = defineProps({
name: { type: String, default: '' },
domain: { type: String, default: '' },
contactsCount: { type: Number, default: 0 },
description: { type: String, default: '' },
avatarUrl: { type: String, default: '' },
updatedAt: { type: [String, Number], default: null },
lastActivityAt: { type: [String, Number], default: null },
});
const emit = defineEmits(['showCompany']);
@@ -27,15 +26,21 @@ const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
const avatarSource = computed(() => props.avatarUrl || null);
const formattedUpdatedAt = computed(() => {
if (!props.updatedAt) return '';
return formatDistanceToNow(new Date(props.updatedAt), { addSuffix: true });
const hasContacts = computed(() => Number(props.contactsCount || 0) > 0);
const contactsCountLabel = computed(() =>
t('COMPANIES.CONTACTS_COUNT', { n: Number(props.contactsCount || 0) })
);
const formattedLastActivityAt = computed(() => {
if (!props.lastActivityAt) return '';
return dynamicTime(props.lastActivityAt);
});
</script>
<template>
<CardLayout layout="row" @click="onClickViewDetails">
<div class="flex items-center justify-start flex-1 gap-4">
<div class="flex items-center justify-start flex-1 gap-4 cursor-pointer">
<Avatar
:username="displayName"
:src="avatarSource"
@@ -51,42 +56,29 @@ const formattedUpdatedAt = computed(() => {
{{ displayName }}
</span>
<span
v-if="domain && description"
v-if="hasContacts"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span>
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
{{ contactsCountLabel }}
</span>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center min-w-0">
<span
v-if="domain && !description"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
v-if="domain"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate cursor-text"
@click.stop
>
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span>
</span>
<span v-if="description" class="text-sm text-n-slate-11 truncate">
{{ description }}
</span>
<div
v-if="(description || domain) && contactsCount"
class="w-px h-3 bg-n-slate-6"
/>
<span
v-if="contactsCount"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
{{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
</span>
</div>
<span
v-if="updatedAt"
v-if="lastActivityAt"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
>
{{ formattedUpdatedAt }}
{{ formattedLastActivityAt }}
</span>
</div>
</div>
@@ -0,0 +1,115 @@
<script setup>
import { ref, useSlots } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
breadcrumbItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['back']);
const slots = useSlots();
const isSidebarOpen = ref(false);
const toggleSidebar = () => {
isSidebarOpen.value = !isSidebarOpen.value;
};
const closeMobileSidebar = () => {
if (!isSidebarOpen.value) {
return;
}
isSidebarOpen.value = false;
};
</script>
<template>
<section
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
>
<header class="sticky top-0 z-10 px-6 3xl:px-0">
<div class="w-full mx-auto max-w-[40.625rem]">
<div
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
>
<Breadcrumb :items="breadcrumbItems" @click="emit('back')" />
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
<slot />
</div>
</main>
</div>
<div
v-if="slots.sidebar"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
</div>
<div
v-if="slots.sidebar"
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
:class="isSidebarOpen ? 'w-full' : 'w-16'"
>
<div
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#details-sidebar-content'] },
]"
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
:class="[
isSidebarOpen
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
]"
>
<Button
ghost
slate
sm
class="!rounded-full rtl:rotate-180"
:class="{ 'bg-n-alpha-2': isSidebarOpen }"
:icon="
isSidebarOpen
? 'i-lucide-panel-right-close'
: 'i-lucide-panel-right-open'
"
data-details-sidebar-toggle
@click="toggleSidebar"
/>
</div>
<Transition
enter-active-class="transition-transform duration-200 ease-in-out"
leave-active-class="transition-transform duration-200 ease-in-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
>
<div
v-if="isSidebarOpen"
id="details-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
>
<slot name="sidebar" />
</div>
</Transition>
</div>
</section>
</template>
@@ -19,22 +19,15 @@ const emit = defineEmits(['search', 'update:sort']);
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
>
<span class="text-heading-1 truncate text-n-slate-12">
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-row flex-shrink-0 gap-2">
<div class="flex items-center">
<CompanySortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
</div>
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2 w-full">
<Input
:model-value="searchValue"
type="search"
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:placeholder="$t('COMPANIES.SEARCH_PLACEHOLDER')"
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
@@ -49,6 +42,13 @@ const emit = defineEmits(['search', 'update:sort']);
</template>
</Input>
</div>
<div class="flex items-center flex-shrink-0 gap-2">
<CompanySortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
</div>
</div>
</div>
</header>
@@ -35,6 +35,10 @@ const sortMenus = [
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.LAST_ACTIVITY_AT'),
value: 'last_activity_at',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
value: 'contacts_count',
@@ -101,6 +105,7 @@ const handleOrderChange = value => {
:model-value="activeSort"
:options="sortMenus"
:label="activeSortLabel"
sub-menu-position="left"
@update:model-value="handleSortChange"
/>
</div>
@@ -112,6 +117,7 @@ const handleOrderChange = value => {
:model-value="activeOrdering"
:options="orderingMenus"
:label="activeOrderingLabel"
sub-menu-position="left"
@update:model-value="handleOrderChange"
/>
</div>
@@ -0,0 +1,354 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { debounce } from '@chatwoot/utils';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
const props = defineProps({
company: {
type: Object,
default: () => ({}),
},
contacts: {
type: Array,
default: () => [],
},
meta: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
},
isBusy: {
type: Boolean,
default: false,
},
searchResults: {
type: Array,
default: () => [],
},
isSearching: {
type: Boolean,
default: false,
},
selectedContact: {
type: Object,
default: null,
},
});
const emit = defineEmits([
'cancelContactSelection',
'confirmContactSelection',
'removeContact',
'search',
'selectContact',
'update:currentPage',
]);
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const selectedContactId = ref(null);
const searchQuery = ref('');
const hasContacts = computed(() => props.contacts.length > 0);
const currentPage = computed(() => Number(props.meta?.page || 1));
const totalContacts = computed(() => Number(props.meta?.totalCount || 0));
const linkedContactIds = computed(
() => new Set(props.contacts.map(contact => Number(contact.id)))
);
const showPaginationFooter = computed(
() => hasContacts.value && totalContacts.value > props.contacts.length
);
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: {
accountId: route.params.accountId,
contactId,
},
});
};
const contactMeta = contact =>
[contact.email, contact.phoneNumber].filter(Boolean).join(' • ');
const contactName = contact =>
contact.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
const contactOptions = computed(() =>
props.searchResults
.filter(
contact =>
!contact.linkedToCurrentCompany &&
!linkedContactIds.value.has(Number(contact.id))
)
.map(contact => ({
value: contact.id,
label: [contactName(contact), contact.email, contact.phoneNumber]
.filter(Boolean)
.join(' · '),
}))
);
const emptyState = computed(() => {
if (props.isSearching) {
return t('COMPANIES.DETAIL.CONTACTS.LOADING');
}
return searchQuery.value.trim()
? t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.EMPTY')
: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.INITIAL');
});
const selectedContactName = computed(() =>
props.selectedContact ? contactName(props.selectedContact) : ''
);
const selectedContactMeta = computed(() =>
props.selectedContact ? contactMeta(props.selectedContact) : ''
);
const selectedContactCompanyName = computed(
() => props.selectedContact?.company?.name || ''
);
const summaryRows = computed(() => [
{
key: 'company',
label: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.COMPANY_LABEL'),
avatarName: props.company.name || t('COMPANIES.UNNAMED'),
avatarSrc: props.company.avatarUrl,
primary: props.company.name || t('COMPANIES.UNNAMED'),
secondary: props.company.domain,
},
{
key: 'contact',
label: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONTACT_LABEL'),
badge: selectedContactCompanyName.value
? t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CURRENT_COMPANY', {
companyName: selectedContactCompanyName.value,
})
: '',
avatarName: selectedContactName.value,
avatarSrc: props.selectedContact?.thumbnail,
primary: selectedContactName.value,
secondary: selectedContactMeta.value,
},
]);
const debouncedSearch = debounce(query => {
emit('search', query);
}, 300);
const handleSearch = query => {
searchQuery.value = query;
debouncedSearch(query.trim());
};
const handleContactSelect = contactId => {
const selectedContact = props.searchResults.find(
contact => contact.id === Number(contactId)
);
selectedContactId.value = null;
if (selectedContact) {
emit('selectContact', selectedContact);
}
};
</script>
<template>
<div class="flex flex-col gap-6 px-6 pb-6 pt-1">
<div v-if="!selectedContact" class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-base text-n-slate-12">
{{ t('COMPANIES.DETAIL.CONTACTS.ACTIONS.ADD') }}
</label>
<span class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.DESCRIPTION') }}
</span>
</div>
<ComboBox
use-api-results
:model-value="selectedContactId"
:options="contactOptions"
:disabled="isBusy"
:empty-state="emptyState"
:search-placeholder="
t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.SEARCH_PLACEHOLDER')
"
:placeholder="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.ADD')"
class="[&>div>button]:bg-n-alpha-black2"
@search="handleSearch"
@update:model-value="handleContactSelect"
/>
</div>
<div v-else class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-base text-n-slate-12">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONFIRM_TITLE') }}
</label>
<span class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONFIRM_DESCRIPTION') }}
</span>
</div>
<div class="flex flex-col gap-4">
<div
v-for="row in summaryRows"
:key="row.key"
class="flex flex-col gap-2"
>
<div class="flex items-center justify-between h-5 gap-2">
<label class="text-sm text-n-slate-12">
{{ row.label }}
</label>
<span
v-if="row.badge"
class="px-2 py-0.5 text-xs rounded-md text-n-amber-11 bg-n-alpha-2"
>
{{ row.badge }}
</span>
</div>
<div
class="border border-n-strong h-[60px] gap-2 flex items-center rounded-xl p-3"
>
<Avatar
:name="row.avatarName"
:src="row.avatarSrc"
:size="32"
rounded-full
hide-offline-status
/>
<div class="flex flex-col w-full min-w-0 gap-1">
<span
class="text-sm leading-4 font-medium truncate text-n-slate-12"
>
{{ row.primary }}
</span>
<span
v-if="row.secondary"
class="text-sm leading-4 truncate text-n-slate-11"
>
{{ row.secondary }}
</span>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3 mt-2">
<Button
variant="faded"
color="slate"
:label="t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
:disabled="isBusy"
@click="emit('cancelContactSelection')"
/>
<Button
:label="t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.ADD')"
class="w-full"
:is-loading="isBusy"
:disabled="isBusy"
@click="emit('confirmContactSelection')"
/>
</div>
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<h4 class="text-sm font-medium text-n-slate-12">
{{ t('COMPANIES.DETAIL.SIDEBAR.TABS.CONTACTS') }}
</h4>
<span v-if="hasContacts" class="text-xs tabular-nums text-n-slate-11">
{{ t('COMPANIES.CONTACTS_COUNT', { n: totalContacts }) }}
</span>
</div>
<div
v-if="isLoading && !hasContacts"
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.CONTACTS.LOADING') }}
</div>
<div
v-else-if="!hasContacts"
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
</div>
<div v-else class="flex flex-col divide-y divide-n-weak">
<div
v-for="contact in contacts"
:key="contact.id"
class="flex items-center gap-2 py-3 group/contact"
>
<button
type="button"
class="flex items-center flex-1 min-w-0 !p-0 gap-3 text-start rounded-lg transition-colors text-n-slate-12 hover:text-n-blue-11 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-n-brand focus-visible:ring-offset-2 focus-visible:ring-offset-n-background"
@click="openContact(contact.id)"
>
<Avatar
:name="contactName(contact)"
:src="contact.thumbnail"
:size="32"
rounded-full
hide-offline-status
/>
<div class="min-w-0 space-y-0.5">
<span
class="text-sm font-medium leading-5 truncate text-n-slate-12"
>
{{ contactName(contact) }}
</span>
<p
v-if="contactMeta(contact)"
class="text-sm leading-5 truncate text-n-slate-11"
>
{{ contactMeta(contact) }}
</p>
</div>
</button>
<Button
icon="i-lucide-unlink"
color="slate"
variant="ghost"
size="xs"
class="shrink-0 opacity-70 transition-opacity sm:opacity-0 sm:group-hover/contact:opacity-100 sm:focus-visible:opacity-100"
:disabled="isBusy"
:title="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
:aria-label="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
@click.stop="emit('removeContact', contact.id)"
/>
</div>
</div>
<PaginationFooter
v-if="showPaginationFooter"
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalContacts"
:items-per-page="15"
class="px-0 before:hidden"
@update:current-page="emit('update:currentPage', $event)"
/>
</div>
</div>
</template>
@@ -0,0 +1,94 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useCompaniesStore } from 'dashboard/stores/companies';
import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttribute.vue';
import CheckboxAttribute from 'dashboard/components-next/CustomAttributes/CheckboxAttribute.vue';
import DateAttribute from 'dashboard/components-next/CustomAttributes/DateAttribute.vue';
import OtherAttribute from 'dashboard/components-next/CustomAttributes/OtherAttribute.vue';
const props = defineProps({
companyId: {
type: Number,
required: true,
},
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const companiesStore = useCompaniesStore();
const { t } = useI18n();
const handleDelete = async () => {
try {
await companiesStore.deleteCustomAttributes({
id: props.companyId,
customAttributes: [props.attribute.attributeKey],
});
useAlert(t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.DELETE_SUCCESS'));
} catch (error) {
useAlert(
error?.response?.message ||
t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.DELETE_ERROR')
);
}
};
const handleUpdate = async value => {
try {
await companiesStore.update({
id: props.companyId,
customAttributes: {
[props.attribute.attributeKey]: value,
},
});
useAlert(t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.UPDATE_SUCCESS'));
} catch (error) {
useAlert(
error?.response?.message ||
t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.UPDATE_ERROR')
);
}
};
const componentMap = {
list: ListAttribute,
checkbox: CheckboxAttribute,
date: DateAttribute,
default: OtherAttribute,
};
const CurrentAttributeComponent = computed(
() =>
componentMap[props.attribute.attributeDisplayType] || componentMap.default
);
</script>
<template>
<div
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-2"
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
>
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="CurrentAttributeComponent"
:attribute="attribute"
:is-editing-view="isEditingView"
@update="handleUpdate"
@delete="handleDelete"
/>
</div>
</template>
@@ -0,0 +1,142 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import CompanyCustomAttributeItem from 'dashboard/components-next/Companies/CompanyDetail/CompanyCustomAttributeItem.vue';
const props = defineProps({
company: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const store = useStore();
const searchQuery = ref('');
const companyAttributes = useMapGetter('attributes/getCompanyAttributes');
const customAttributes = computed(() => props.company?.customAttributes || {});
const hasCompanyAttributes = computed(
() => companyAttributes.value?.length > 0
);
const processCompanyAttributes = (
attributes,
attributeValues,
filterCondition
) => {
if (!attributes.length) {
return [];
}
return attributes.reduce((result, attribute) => {
const { attributeKey } = attribute;
if (filterCondition(attributeKey, attributeValues)) {
result.push({
...attribute,
value: attributeValues[attributeKey] ?? '',
});
}
return result;
}, []);
};
const usedAttributes = computed(() =>
processCompanyAttributes(
companyAttributes.value,
customAttributes.value,
(key, values) => key in values
)
);
const unusedAttributes = computed(() =>
processCompanyAttributes(
companyAttributes.value,
customAttributes.value,
(key, values) => !(key in values)
)
);
const filteredUnusedAttributes = computed(() =>
unusedAttributes.value.filter(attribute =>
attribute.attributeDisplayName
.toLowerCase()
.includes(searchQuery.value.toLowerCase())
)
);
const unusedAttributesCount = computed(() => unusedAttributes.value.length);
const hasNoUnusedAttributes = computed(() => unusedAttributesCount.value === 0);
const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
onMounted(() => {
store.dispatch('attributes/get');
});
</script>
<template>
<div v-if="hasCompanyAttributes" class="flex flex-col gap-6 px-6 py-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<CompanyCustomAttributeItem
v-for="attribute in usedAttributes"
:key="`${company.id}-${attribute.id}`"
is-editing-view
:company-id="company.id"
:attribute="attribute"
/>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
<div class="flex-1 h-px bg-n-slate-5" />
<span class="text-sm font-medium text-n-slate-10">
{{
t('COMPANIES.DETAIL.ATTRIBUTES.UNUSED_ATTRIBUTES', {
count: unusedAttributesCount,
})
}}
</span>
<div class="flex-1 h-px bg-n-slate-5" />
</div>
<div class="flex flex-col gap-3">
<div v-if="!hasNoUnusedAttributes" class="relative">
<span
class="absolute i-lucide-search size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="t('COMPANIES.DETAIL.ATTRIBUTES.SEARCH_PLACEHOLDER')"
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-if="filteredUnusedAttributes.length === 0 && !hasNoUnusedAttributes"
class="flex items-center justify-start h-11"
>
<p class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.ATTRIBUTES.NO_ATTRIBUTES') }}
</p>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex flex-col gap-2">
<CompanyCustomAttributeItem
v-for="attribute in filteredUnusedAttributes"
:key="`${company.id}-${attribute.id}`"
:company-id="company.id"
:attribute="attribute"
/>
</div>
</div>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
{{ t('COMPANIES.DETAIL.ATTRIBUTES.EMPTY_STATE') }}
</p>
</template>
@@ -0,0 +1,203 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import { useCompaniesStore } from 'dashboard/stores/companies';
const props = defineProps({
company: { type: Object, default: () => ({}) },
isLoading: { type: Boolean, default: false },
});
const { t } = useI18n();
const companiesStore = useCompaniesStore();
const form = reactive({ name: '', domain: '', description: '' });
const avatarPreviewUrl = ref('');
const isUploadingAvatar = ref(false);
const uiFlags = computed(() => companiesStore.getUIFlags);
const isUpdating = computed(() => uiFlags.value.updatingItem);
const isAvatarBusy = computed(
() =>
isUploadingAvatar.value || uiFlags.value.deletingAvatar || isUpdating.value
);
const displayName = computed(
() => props.company?.name || t('COMPANIES.UNNAMED')
);
const avatarSource = computed(
() => avatarPreviewUrl.value || props.company?.avatarUrl || ''
);
const isFormInvalid = computed(() => !form.name.trim());
const hasChanges = computed(
() =>
form.name.trim() !== (props.company?.name || '').trim() ||
form.domain.trim() !== (props.company?.domain || '').trim() ||
form.description.trim() !== (props.company?.description || '').trim()
);
const summary = computed(() => {
const { createdAt, lastActivityAt } = props.company || {};
return [
createdAt &&
t('COMPANIES.DETAIL.PROFILE.CREATED_AT', {
date: dynamicTime(createdAt),
}),
lastActivityAt &&
t('COMPANIES.DETAIL.PROFILE.LAST_ACTIVE', {
date: dynamicTime(lastActivityAt),
}),
]
.filter(Boolean)
.join(' • ');
});
const syncForm = company => {
form.name = company?.name || '';
form.domain = company?.domain || '';
form.description = company?.description || '';
};
const isCurrentCompany = companyId => Number(props.company?.id) === companyId;
watch(
() => [
props.company?.id,
props.company?.name,
props.company?.domain,
props.company?.description,
props.company?.avatarUrl,
],
() => {
avatarPreviewUrl.value = '';
syncForm(props.company);
},
{ immediate: true }
);
const handleAvatarUpload = async ({ file, url }) => {
avatarPreviewUrl.value = url;
isUploadingAvatar.value = true;
try {
await companiesStore.update({ id: props.company.id, avatar: file });
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_SUCCESS'));
} catch {
avatarPreviewUrl.value = '';
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_ERROR'));
} finally {
isUploadingAvatar.value = false;
}
};
const handleAvatarDelete = async () => {
try {
await companiesStore.deleteCompanyAvatar(props.company.id);
avatarPreviewUrl.value = '';
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_SUCCESS'));
} catch {
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_ERROR'));
}
};
const handleUpdateCompany = async () => {
const companyId = Number(props.company.id);
try {
const updated = await companiesStore.update({
id: companyId,
name: form.name.trim(),
domain: form.domain.trim() || null,
description: form.description.trim() || null,
});
if (!isCurrentCompany(companyId)) return;
syncForm(updated);
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_SUCCESS'));
} catch {
if (!isCurrentCompany(companyId)) return;
syncForm(props.company);
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_ERROR'));
}
};
</script>
<template>
<div v-if="isLoading && !company?.id" class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.LOADING') }}
</div>
<div v-else-if="company?.id" class="flex flex-col items-start gap-8 pb-6">
<div class="flex flex-col items-start gap-3">
<Avatar
:name="displayName"
:src="avatarSource"
:size="72"
:allow-upload="!isAvatarBusy"
rounded-full
hide-offline-status
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ displayName }}
</h3>
<span class="text-sm leading-6 text-n-slate-11">{{ summary }}</span>
<p
v-if="isUploadingAvatar || uiFlags.deletingAvatar"
class="text-sm text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.AVATAR.UPDATING') }}
</p>
</div>
</div>
<div class="flex flex-col items-start w-full gap-6">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('COMPANIES.DETAIL.PROFILE.TITLE') }}
</span>
<div class="grid w-full gap-4 sm:grid-cols-2">
<Input
v-model="form.name"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
:disabled="isUpdating"
custom-input-class="h-8 !pt-1 !pb-1"
/>
<Input
v-model="form.domain"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
:disabled="isUpdating"
custom-input-class="h-8 !pt-1 !pb-1"
/>
</div>
<TextArea
v-model="form.description"
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
:disabled="isUpdating"
:max-length="280"
class="w-full"
show-character-count
auto-height
/>
<Button
:label="t('COMPANIES.DETAIL.PROFILE.ACTIONS.SAVE')"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid || !hasChanges"
@click="handleUpdateCompany"
/>
</div>
</div>
</template>
@@ -0,0 +1,45 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
company: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const dialogRef = ref(null);
const description = computed(() =>
props.company?.name
? t('COMPANIES.DETAIL.DELETE.DESCRIPTION_WITH_NAME', {
companyName: props.company.name,
})
: t('COMPANIES.DETAIL.DELETE.DESCRIPTION')
);
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('COMPANIES.DETAIL.DELETE.TITLE')"
:description="description"
:confirm-button-label="t('COMPANIES.DETAIL.DELETE.CONFIRM')"
:is-loading="isLoading"
@confirm="emit('confirm')"
/>
</template>
@@ -134,7 +134,7 @@ const handleInputUpdate = async () => {
:message-type="hasError ? 'error' : 'info'"
autofocus
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
@enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
@@ -191,7 +191,7 @@ const handleInputUpdate = async () => {
:message="attributeErrorMessage"
:message-type="hasError ? 'error' : 'info'"
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
@enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
@@ -457,7 +457,7 @@ const menuItems = computed(() => {
{},
{ page: 1, search: undefined }
),
activeOn: ['companies_dashboard_index'],
activeOn: ['companies_dashboard_index', 'companies_dashboard_show'],
},
],
},
@@ -3,14 +3,15 @@
"HEADER": "Custom Attributes",
"HEADER_BTN_TXT": "Add Custom Attribute",
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts, companies, or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"COUNT": "{n} attribute | {n} attributes",
"SEARCH_PLACEHOLDER": "Search attributes...",
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
"CONTACT": "Contact",
"COMPANY": "Company"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
@@ -108,7 +109,8 @@
"TABS": {
"HEADER": "Custom Attributes",
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
"CONTACT": "Contact",
"COMPANY": "Company"
},
"LIST": {
"TABLE_HEADER": {
@@ -7,6 +7,7 @@
"NAME": "Name",
"DOMAIN": "Domain",
"CREATED_AT": "Created at",
"LAST_ACTIVITY_AT": "Last activity",
"CONTACTS_COUNT": "Contacts count"
}
},
@@ -21,6 +22,100 @@
"LOADING": "Loading companies...",
"UNNAMED": "Unnamed Company",
"CONTACTS_COUNT": "{n} contact | {n} contacts",
"DETAIL": {
"LOADING": "Loading company details...",
"EMPTY_STATE": {
"TITLE": "Company not found",
"SUBTITLE": "This company may have been removed or is no longer available in this account."
},
"SIDEBAR": {
"TABS": {
"ATTRIBUTES": "Attributes",
"CONTACTS": "Contacts"
}
},
"ATTRIBUTES": {
"SEARCH_PLACEHOLDER": "Search attributes...",
"EMPTY_STATE": "There are no company custom attributes configured yet.",
"NO_ATTRIBUTES": "No matching attributes found.",
"UNUSED_ATTRIBUTES": "{count} unused attribute | {count} unused attributes",
"MESSAGES": {
"UPDATE_SUCCESS": "Company attribute updated.",
"UPDATE_ERROR": "Could not update company attribute.",
"DELETE_SUCCESS": "Company attribute removed.",
"DELETE_ERROR": "Could not remove company attribute."
}
},
"CONTACTS": {
"LOADING": "Loading contacts...",
"EMPTY": "No contacts are linked to this company yet.",
"UNNAMED_CONTACT": "Unnamed contact",
"ACTIONS": {
"ADD": "Add contact",
"REMOVE": "Remove contact"
},
"DIALOGS": {
"ADD": {
"DESCRIPTION": "Search for an existing contact and link it to this company.",
"SEARCH_PLACEHOLDER": "Search contacts...",
"INITIAL": "Start typing to search contacts.",
"EMPTY": "No contacts found.",
"CONFIRM_TITLE": "Link contact",
"CONFIRM_DESCRIPTION": "Confirm the company and contact before linking them.",
"COMPANY_LABEL": "Company",
"CONTACT_LABEL": "Contact",
"CURRENT_COMPANY": "Currently linked to {companyName}",
"ADD": "Link contact",
"CANCEL": "Cancel"
}
},
"MESSAGES": {
"ADD_SUCCESS": "Contact linked to company.",
"ADD_ERROR": "Could not link contact to company.",
"REASSIGN_SUCCESS": "Contact reassigned to company.",
"REASSIGN_ERROR": "Could not reassign contact to company.",
"REMOVE_SUCCESS": "Contact removed from company.",
"REMOVE_ERROR": "Could not remove contact from company."
}
},
"AVATAR": {
"UPDATING": "Updating company avatar...",
"UPLOAD_SUCCESS": "Company avatar updated.",
"UPLOAD_ERROR": "Could not update the company avatar.",
"DELETE_SUCCESS": "Company avatar removed.",
"DELETE_ERROR": "Could not remove the company avatar."
},
"PROFILE": {
"TITLE": "Edit company details",
"CREATED_AT": "Created {date}",
"LAST_ACTIVE": "Last active {date}",
"DESCRIPTION_PLACEHOLDER": "Add a short description for this company",
"ACTIONS": {
"SAVE": "Update company"
},
"MESSAGES": {
"UPDATE_SUCCESS": "Company updated.",
"UPDATE_ERROR": "Could not update the company."
},
"FIELDS": {
"NAME": "Name",
"DOMAIN": "Domain"
}
},
"DELETE": {
"SECTION_TITLE": "Danger zone",
"SECTION_DESCRIPTION": "Delete this company and unlink its contacts. The contacts will remain in the account.",
"BUTTON": "Delete company",
"TITLE": "Delete company?",
"DESCRIPTION": "This will remove the company and unlink all associated contacts. Contacts themselves will be preserved.",
"DESCRIPTION_WITH_NAME": "This will remove {companyName} and unlink all associated contacts. Contacts themselves will be preserved.",
"CONFIRM": "Delete company",
"MESSAGES": {
"SUCCESS": "Company deleted.",
"ERROR": "Could not delete the company."
}
}
},
"EMPTY_STATE": {
"TITLE": "No companies found"
}
@@ -111,6 +111,16 @@ const onPageChange = page => {
fetchCompanies(page, searchValue.value, sortParam.value);
};
const showCompany = companyId => {
router.push({
name: 'companies_dashboard_show',
params: {
accountId: route.params.accountId,
companyId,
},
});
};
const handleSort = async ({ sort, order }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
@@ -123,6 +133,11 @@ const handleSort = async ({ sort, order }) => {
onMounted(() => {
searchValue.value = searchQuery.value;
if (!route.query.sort && sortParam.value !== DEFAULT_SORT_FIELD) {
updateURLParams(pageNumber.value, searchQuery.value, sortParam.value);
}
fetchCompanies();
});
</script>
@@ -162,9 +177,9 @@ onMounted(() => {
:name="company.name"
:domain="company.domain"
:contacts-count="company.contactsCount || 0"
:description="company.description"
:avatar-url="company.avatarUrl"
:updated-at="company.updatedAt"
:last-activity-at="company.lastActivityAt"
@show-company="showCompany"
/>
</div>
</CompaniesListLayout>
@@ -0,0 +1,236 @@
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import Policy from 'dashboard/components/policy.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CompaniesDetailsLayout from 'dashboard/components-next/Companies/CompaniesDetailsLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CompanyContactsSidebar from 'dashboard/components-next/Companies/CompanyDetail/CompanyContactsSidebar.vue';
import CompanyProfileCard from 'dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue';
import ConfirmCompanyDeleteDialog from 'dashboard/components-next/Companies/CompanyDetail/ConfirmCompanyDeleteDialog.vue';
import { useCompaniesStore } from 'dashboard/stores/companies';
const route = useRoute();
const router = useRouter();
const companiesStore = useCompaniesStore();
const { t } = useI18n();
const confirmDeleteDialogRef = ref(null);
const selectedCandidate = ref(null);
const companyId = computed(() => Number(route.params.companyId));
const company = computed(() => companiesStore.getRecord(companyId.value));
const companyContacts = computed(() => companiesStore.companyContacts);
const companyContactsMeta = computed(() => companiesStore.companyContactsMeta);
const contactSearchResults = computed(
() => companiesStore.contactSearchResults
);
const uiFlags = computed(() => companiesStore.getUIFlags);
const isFetchingCompany = computed(() => uiFlags.value.fetchingItem);
const isFetchingContacts = computed(() => uiFlags.value.fetchingContacts);
const isSearchingContacts = computed(() => uiFlags.value.searchingContacts);
const isManagingContacts = computed(
() => uiFlags.value.creatingContact || uiFlags.value.removingContact
);
const isDeletingCompany = computed(() => uiFlags.value.deletingItem);
const hasCompany = computed(() => Boolean(company.value?.id));
const showInitialLoadingState = computed(
() =>
!hasCompany.value && (isFetchingCompany.value || isFetchingContacts.value)
);
const breadcrumbItems = computed(() => [
{ label: t('COMPANIES.HEADER') },
...(hasCompany.value
? [{ label: company.value?.name || t('COMPANIES.UNNAMED') }]
: []),
]);
const goToCompaniesIndex = () => {
router.push({
name: 'companies_dashboard_index',
params: { accountId: route.params.accountId },
query: { page: '1' },
});
};
const goToCompaniesList = () => {
if (window.history.state?.back) {
router.back();
return;
}
goToCompaniesIndex();
};
const loadCompanyContactsPage = async page => {
if (!companyId.value) return;
await companiesStore.getCompanyContacts(companyId.value, page);
};
const openDeleteCompanyDialog = () => {
confirmDeleteDialogRef.value?.dialogRef.open();
};
const clearSelectedCandidate = () => {
selectedCandidate.value = null;
};
const handleContactSearch = async query => {
await companiesStore.searchCompanyContactCandidates({
companyId: companyId.value,
search: query,
});
};
const handleConfirmContactSelection = async () => {
const candidate = selectedCandidate.value;
if (!candidate) return;
const isReassigning =
candidate.company?.id && candidate.company.id !== companyId.value;
const message = isReassigning
? t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REASSIGN_SUCCESS')
: t('COMPANIES.DETAIL.CONTACTS.MESSAGES.ADD_SUCCESS');
try {
await companiesStore.attachContactToCompany(companyId.value, candidate.id);
useAlert(message);
clearSelectedCandidate();
} catch {
const errorMessage = isReassigning
? t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REASSIGN_ERROR')
: t('COMPANIES.DETAIL.CONTACTS.MESSAGES.ADD_ERROR');
useAlert(errorMessage);
}
};
const handleRemoveContact = async contactId => {
const currentPage = Number(companyContactsMeta.value.page || 1);
const nextPage =
currentPage > 1 && companyContacts.value.length === 1
? currentPage - 1
: currentPage;
try {
await companiesStore.removeContactFromCompany(
companyId.value,
contactId,
nextPage
);
useAlert(t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REMOVE_SUCCESS'));
} catch {
useAlert(t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REMOVE_ERROR'));
}
};
const handleDeleteCompany = async () => {
try {
await companiesStore.delete(companyId.value);
useAlert(t('COMPANIES.DETAIL.DELETE.MESSAGES.SUCCESS'));
confirmDeleteDialogRef.value?.dialogRef.close();
goToCompaniesIndex();
} catch {
useAlert(t('COMPANIES.DETAIL.DELETE.MESSAGES.ERROR'));
}
};
watch(
companyId,
async id => {
companiesStore.resetCompanyDetailState();
clearSelectedCandidate();
if (!id) return;
await Promise.allSettled([
companiesStore.show(id),
companiesStore.getCompanyContacts(id),
]);
},
{ immediate: true }
);
onBeforeUnmount(() => {
companiesStore.resetCompanyDetailState();
});
</script>
<template>
<CompaniesDetailsLayout
:breadcrumb-items="breadcrumbItems"
@back="goToCompaniesList"
>
<div
v-if="showInitialLoadingState"
class="flex flex-col items-center justify-center gap-3 py-24 text-n-slate-11"
>
<Spinner />
<span class="text-sm">{{ t('COMPANIES.DETAIL.LOADING') }}</span>
</div>
<div
v-else-if="!hasCompany"
class="flex flex-col items-center justify-center gap-3 px-6 py-24 text-center rounded-2xl border border-n-weak bg-n-solid-2"
>
<span class="text-lg font-medium text-n-slate-12">
{{ t('COMPANIES.DETAIL.EMPTY_STATE.TITLE') }}
</span>
<p class="max-w-md text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.EMPTY_STATE.SUBTITLE') }}
</p>
</div>
<div v-else class="flex flex-col gap-6">
<CompanyProfileCard :company="company" :is-loading="isFetchingCompany" />
<Policy :permissions="['administrator']">
<section
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('COMPANIES.DETAIL.DELETE.SECTION_TITLE') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.DELETE.SECTION_DESCRIPTION') }}
</span>
</div>
<Button
:label="t('COMPANIES.DETAIL.DELETE.BUTTON')"
color="ruby"
:disabled="isDeletingCompany"
@click="openDeleteCompanyDialog"
/>
</section>
</Policy>
</div>
<template v-if="hasCompany" #sidebar>
<CompanyContactsSidebar
:company="company"
:contacts="companyContacts"
:meta="companyContactsMeta"
:is-loading="isFetchingContacts"
:is-busy="isManagingContacts"
:search-results="contactSearchResults"
:is-searching="isSearchingContacts"
:selected-contact="selectedCandidate"
@cancel-contact-selection="clearSelectedCandidate"
@confirm-contact-selection="handleConfirmContactSelection"
@search="handleContactSearch"
@select-contact="contact => (selectedCandidate = contact)"
@remove-contact="handleRemoveContact"
@update:current-page="loadCompanyContactsPage"
/>
</template>
<ConfirmCompanyDeleteDialog
ref="confirmDeleteDialogRef"
:company="company"
:is-loading="isDeletingCompany"
@confirm="handleDeleteCompany"
/>
</CompaniesDetailsLayout>
</template>
@@ -1,5 +1,6 @@
import { frontendURL } from '../../../helper/URLHelper';
import CompaniesIndex from './pages/CompaniesIndex.vue';
import CompanyDetailView from './pages/CompanyDetailView.vue';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
@@ -23,4 +24,17 @@ export const routes = [
},
],
},
{
path: frontendURL('accounts/:accountId/companies/:companyId'),
component: CompanyDetailView,
meta: commonMeta,
children: [
{
path: '',
name: 'companies_dashboard_show',
component: CompanyDetailView,
meta: commonMeta,
},
],
},
];
@@ -32,6 +32,7 @@ const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
const [showEditPopup, toggleEditPopup] = useToggle(false);
const [showDeletePopup, toggleDeletePopup] = useToggle(false);
const selectedAttribute = ref({});
const attributeModels = ['conversation_attribute', 'contact_attribute'];
const openAddPopup = () => {
toggleAddPopup(true);
@@ -69,8 +70,8 @@ onMounted(() => {
store.dispatch('attributes/get');
});
const attributeModel = computed(() =>
selectedTabIndex.value ? 'contact_attribute' : 'conversation_attribute'
const attributeModel = computed(
() => attributeModels[selectedTabIndex.value] || 'conversation_attribute'
);
const attributes = computed(() =>
@@ -30,6 +30,11 @@ export const getters = {
.filter(record => record.attribute_model === 'contact_attribute')
.map(camelcaseKeys);
},
getCompanyAttributes: _state => {
return _state.records
.filter(record => record.attribute_model === 'company_attribute')
.map(camelcaseKeys);
},
getAttributesByModel: _state => attributeModel => {
return _state.records.filter(
record => record.attribute_model === attributeModel
@@ -35,6 +35,36 @@ describe('#getters', () => {
]);
});
it('getCompanyAttributes', () => {
const state = {
records: [
{
attribute_display_name: 'Industry',
attribute_display_type: 0,
attribute_description: 'Company industry',
attribute_key: 'industry',
attribute_model: 'company_attribute',
},
{
attribute_display_name: 'Language',
attribute_display_type: 1,
attribute_description: 'Conversation language',
attribute_key: 'language',
attribute_model: 'conversation_attribute',
},
],
};
expect(getters.getCompanyAttributes(state)).toEqual([
{
attributeDisplayName: 'Industry',
attributeDisplayType: 0,
attributeDescription: 'Company industry',
attributeKey: 'industry',
attributeModel: 'company_attribute',
},
]);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
+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',
},
})
);
});
});