feat(companies): add contact company selector (#14496)

Adds a company selector to the contact details form so agents can
associate a contact with an existing company directly from the contact
page.

Closes

- None

Why

Contacts already expose company information through the CRM fields, but
the form only accepted free-text company names. As we split company CRM
work into smaller PRs, this keeps the contact page aligned with the
structured company model while preserving the existing company-name
behavior used by automations.

What changed

- Shows a company dropdown in the contact details form when the
Companies feature is enabled.
- Keeps legacy free-text company names editable when a contact has no
structured `company_id`.
- Allows Enterprise contact create/update APIs to accept account-scoped
`company_id`.
- Syncs `additional_attributes.company_name` when a contact is
associated with a company, including the existing email-domain
auto-association path.
- Serializes `company_id` in the contact model payload so the form can
show the current association.

How to test

1. Enable Companies for an account and open a contact details page.
2. In Edit contact details, use the Company field to select an existing
company.
3. Save the contact and refresh the page.
4. Confirm the selected company remains visible and the contact is
associated with that company.
5. Confirm contacts with only a legacy free-text company name still show
the text input instead of an empty selector.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
This commit is contained in:
Sojan Jose
2026-06-16 15:29:27 +05:30
committed by GitHub
co-authored by Sivin Varghese iamsivin Sony Mathew
parent de137e8297
commit 41a3ab6dfa
23 changed files with 355 additions and 24 deletions
@@ -214,3 +214,5 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
render json: error, status: error_status
end
end
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
@@ -26,6 +26,13 @@ const resetForm = () => {
form.description = '';
};
const open = (company = {}) => {
form.name = company.name || '';
form.domain = company.domain || '';
form.description = company.description || '';
dialogRef.value?.open();
};
const handleConfirm = () => {
if (isFormInvalid.value) return;
@@ -45,7 +52,7 @@ const onSuccess = () => {
closeDialog();
};
defineExpose({ dialogRef, onSuccess });
defineExpose({ dialogRef, onSuccess, open });
</script>
<template>
@@ -0,0 +1,153 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDebounceFn } from '@vueuse/core';
import CompanyAPI from 'dashboard/api/companies';
import { useAlert } from 'dashboard/composables';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CompanyCreateDialog from 'dashboard/components-next/Companies/CompanyCreateDialog.vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
// Name of the linked company, so the label shows before the list is loaded.
selectedName: {
type: String,
default: '',
},
isDetailsView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const CREATE_PREFIX = 'create:';
const options = ref([]);
const searchQuery = ref('');
const createDialogRef = ref(null);
const isCreatingCompany = ref(false);
const toOption = company => ({ label: company.name, value: company.id });
const createOption = computed(() => {
const name = searchQuery.value.trim();
if (!name) return null;
const exists = options.value.some(
option => option.label.toLowerCase() === name.toLowerCase()
);
if (exists) return null;
return {
label: t('COMPANIES.SELECTOR.CREATE_OPTION', { name }),
value: `${CREATE_PREFIX}${name}`,
};
});
const comboboxOptions = computed(() => {
const list = [...options.value];
// Keep the linked company visible even when it is not in the loaded results.
if (
props.modelValue &&
props.selectedName &&
!list.some(option => option.value === Number(props.modelValue))
) {
list.unshift({
label: props.selectedName,
value: Number(props.modelValue),
});
}
if (createOption.value) list.push(createOption.value);
return list;
});
const fetchCompanies = async query => {
try {
const {
data: { payload },
} = query
? await CompanyAPI.search(query)
: await CompanyAPI.get({ page: 1 });
options.value = (payload || []).map(toOption);
} catch {
options.value = [];
}
};
// Fetch lazily, only when the dropdown opens, instead of on mount.
const handleOpen = () => {
searchQuery.value = '';
fetchCompanies('');
};
const handleSearch = useDebounceFn(query => {
searchQuery.value = query?.trim() || '';
fetchCompanies(searchQuery.value);
}, 300);
// Open the create dialog (prefilled with the typed name) so the user can add
// domain/description before saving, instead of creating with just a name.
const createCompany = async company => {
isCreatingCompany.value = true;
try {
const {
data: { payload },
} = await CompanyAPI.create({ company });
createDialogRef.value?.onSuccess();
emit('select', { id: payload.id, name: payload.name });
useAlert(t('COMPANIES.CREATE.MESSAGES.SUCCESS'));
} catch {
useAlert(t('COMPANIES.CREATE.MESSAGES.ERROR'));
} finally {
isCreatingCompany.value = false;
}
};
const handleSelect = value => {
if (typeof value === 'string' && value.startsWith(CREATE_PREFIX)) {
createDialogRef.value?.open({ name: value.slice(CREATE_PREFIX.length) });
// Drop the transient "Add …" option so the button label doesn't stick to
// it if the dialog is dismissed without creating.
searchQuery.value = '';
return;
}
const id = value ? Number(value) : '';
const selected = comboboxOptions.value.find(option => option.value === id);
emit('select', { id, name: selected?.label || '' });
};
</script>
<template>
<ComboBox
:model-value="modelValue"
:options="comboboxOptions"
:display-label="selectedName"
:placeholder="t('COMPANIES.SELECTOR.PLACEHOLDER')"
:search-placeholder="t('COMPANIES.SEARCH_PLACEHOLDER')"
use-api-results
class="[&>div>button]:h-8 [&>div>div_ul]:max-h-56"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
}"
@open="handleOpen"
@search="handleSearch"
@update:model-value="handleSelect"
/>
<CompanyCreateDialog
ref="createDialogRef"
:is-loading="isCreatingCompany"
@create="createCompany"
/>
</template>
@@ -15,6 +15,7 @@ const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
email: { type: String, default: '' },
companyId: { type: [Number, String], default: '' },
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
@@ -41,6 +42,7 @@ const getInitialContactData = () => ({
id: props.id,
name: props.name,
email: props.email,
companyId: props.companyId,
phoneNumber: props.phoneNumber,
additionalAttributes: props.additionalAttributes,
});
@@ -5,8 +5,11 @@ import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CompanySelector from 'dashboard/components-next/Companies/CompanySelector.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
@@ -28,6 +31,7 @@ const props = defineProps({
const emit = defineEmits(['update']);
const { t } = useI18n();
const { currentAccount, isCloudFeatureEnabled } = useAccount();
const FORM_CONFIG = {
FIRST_NAME: { field: 'firstName' },
@@ -54,6 +58,7 @@ const defaultState = {
id: 0,
name: '',
email: '',
companyId: '',
firstName: '',
lastName: '',
phoneNumber: '',
@@ -85,6 +90,23 @@ const validationRules = {
const v$ = useVuelidate(validationRules, state);
const isFormInvalid = computed(() => v$.value.$invalid);
const hasCompaniesFeature = computed(
() =>
currentAccount.value?.id && isCloudFeatureEnabled(FEATURE_FLAGS.COMPANIES)
);
const showCompanySelector = computed(
() =>
hasCompaniesFeature.value &&
(Boolean(state.companyId) || !state.additionalAttributes.companyName)
);
const emitContactUpdate = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
};
const prepareStateBasedOnProps = () => {
if (props.isNewContact) {
@@ -96,6 +118,7 @@ const prepareStateBasedOnProps = () => {
name = '',
email: emailAddress,
phoneNumber,
companyId = '',
additionalAttributes = {},
} = props.contactData || {};
const { firstName, lastName } = splitName(name || '');
@@ -115,6 +138,7 @@ const prepareStateBasedOnProps = () => {
Object.assign(state, {
id,
name,
companyId: companyId || '',
firstName,
lastName,
email: emailAddress,
@@ -200,11 +224,7 @@ const getFormBinding = key => {
}
}
const isFormValid = await v$.value.$validate();
if (isFormValid) {
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
}
await emitContactUpdate();
},
});
};
@@ -221,6 +241,12 @@ const handleCountrySelection = value => {
emit('update', state);
};
const handleCompanySelection = async ({ id, name }) => {
state.companyId = id || '';
state.additionalAttributes.companyName = name || '';
await emitContactUpdate();
};
const resetValidation = () => {
v$.value.$reset();
};
@@ -273,6 +299,13 @@ defineExpose({
:placeholder="item.placeholder"
:show-border="isDetailsView"
/>
<CompanySelector
v-else-if="item.key === 'COMPANY_NAME' && showCompanySelector"
:model-value="state.companyId"
:selected-name="state.additionalAttributes.companyName"
:is-details-view="isDetailsView"
@select="handleCompanySelection"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
@@ -92,6 +92,7 @@ const handleAvatarHover = (id, isHovered) => {
:id="contact.id"
:name="contact.name"
:email="contact.email"
:company-id="contact.companyId"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
@@ -14,6 +14,9 @@ const props = defineProps({
value.every(option => 'value' in option && 'label' in option),
},
placeholder: { type: String, default: '' },
// Fallback label shown when the selected value is not in `options` yet
// (e.g. API-backed lists that load lazily on open).
displayLabel: { type: String, default: '' },
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
searchPlaceholder: { type: String, default: '' },
@@ -23,7 +26,7 @@ const props = defineProps({
useApiResults: { type: Boolean, default: false }, // useApiResults prop to determine if search is handled by API
});
const emit = defineEmits(['update:modelValue', 'search']);
const emit = defineEmits(['update:modelValue', 'search', 'open']);
const { t } = useI18n();
@@ -52,7 +55,7 @@ const selectedLabel = computed(() => {
const selected = props.options.find(
option => option.value === selectedValue.value
);
return selected?.label ?? selectPlaceholder.value;
return selected?.label ?? (props.displayLabel || selectPlaceholder.value);
});
const selectOption = option => {
@@ -72,6 +75,7 @@ const toggleDropdown = () => {
open.value = !open.value;
if (open.value) {
search.value = '';
emit('open');
nextTick(() => dropdownRef.value?.focus());
}
};
@@ -87,7 +87,7 @@ defineExpose({
}"
role="option"
:aria-selected="isSelected(option)"
@click="emit('select', option)"
@click.stop="emit('select', option)"
>
<span
:class="{
@@ -25,6 +25,10 @@
"ACTIONS": {
"CREATE": "Add company"
},
"SELECTOR": {
"PLACEHOLDER": "Select company",
"CREATE_OPTION": "Add \"{name}\""
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
@@ -126,7 +126,7 @@ const showCompany = companyId => {
};
const openCreateCompanyDialog = () => {
createCompanyDialogRef.value?.dialogRef.open();
createCompanyDialogRef.value?.open();
};
const createCompany = async company => {
@@ -13,8 +13,12 @@ const buildContactFormData = contactParams => {
const formData = new FormData();
const { additional_attributes = {}, ...contactProperties } = contactParams;
Object.keys(contactProperties).forEach(key => {
if (contactProperties[key]) {
formData.append(key, contactProperties[key]);
const value = contactProperties[key];
const shouldAppendBlankCompanyId =
key === 'company_id' && value !== undefined;
if (value || shouldAppendBlankCompanyId) {
formData.append(key, value ?? '');
}
});
const { social_profiles, ...additionalAttributesProperties } =
@@ -145,6 +145,33 @@ describe('#actions', () => {
[types.SET_CONTACT_UI_FLAG, { isUpdating: false }],
]);
});
it('preserves blank company_id when updating with form data', async () => {
axios.patch.mockResolvedValue({ data: { payload: contactList[0] } });
await actions.update(
{ commit },
{
id: contactList[0].id,
isFormData: true,
name: contactList[0].name,
companyId: '',
avatar: 'avatar-file',
additionalAttributes: {
companyName: '',
socialProfiles: {},
},
}
);
const lastPatchCall =
axios.patch.mock.calls[axios.patch.mock.calls.length - 1];
const formData = lastPatchCall[1];
expect(formData).toBeInstanceOf(FormData);
expect(formData.has('company_id')).toBe(true);
expect(formData.get('company_id')).toBe('');
});
});
describe('#create', () => {
+3 -1
View File
@@ -148,7 +148,7 @@ class Contact < ApplicationRecord
end
def push_event_data
{
data = {
additional_attributes: additional_attributes,
custom_attributes: custom_attributes,
email: email,
@@ -160,6 +160,8 @@ class Contact < ApplicationRecord
blocked: blocked,
type: 'contact'
}
data[:company_id] = company_id if account.feature_enabled?('companies')
data
end
def webhook_data
@@ -6,6 +6,7 @@ json.name resource.name
json.phone_number resource.phone_number
json.blocked resource.blocked
json.identifier resource.identifier
json.company_id resource.company_id if Current.account&.feature_enabled?('companies')
json.thumbnail resource.avatar_url
json.custom_attributes resource.custom_attributes
json.last_activity_at resource.last_activity_at.to_i if resource[:last_activity_at].present?
@@ -0,0 +1,17 @@
module Enterprise::Api::V1::Accounts::ContactsController
private
def permitted_params
params_with_company_id = super
return params_with_company_id unless Current.account.feature_enabled?('companies')
return params_with_company_id unless params.key?(:company_id)
params_with_company_id.merge(company_id: permitted_company_id)
end
def permitted_company_id
return nil if params[:company_id].blank?
Current.account.companies.find(params[:company_id]).id
end
end
@@ -6,6 +6,7 @@ module Enterprise::Concerns::Contact
after_commit :associate_company_from_email,
on: [:create, :update],
if: :should_associate_company?
before_save :sync_company_name_from_company, if: :will_save_change_to_company_id?
after_update_commit :record_company_activity, if: :saved_change_to_last_activity_at?
end
@@ -33,4 +34,14 @@ module Enterprise::Concerns::Contact
def record_company_activity
company&.record_activity_at!(last_activity_at) if last_activity_at.present?
end
def sync_company_name_from_company
self.additional_attributes ||= {}
if company_id.present?
additional_attributes['company_name'] = company&.name
else
additional_attributes.delete('company_name')
end
end
end
@@ -6,7 +6,7 @@ class Contacts::CompanyAssociationService
if company
# rubocop:disable Rails/SkipsModelValidations
# Using update_column and increment_counter to avoid triggering callbacks while maintaining counter cache
contact.update_column(:company_id, company.id)
contact.update_columns(company_id: company.id, additional_attributes: contact_attributes_with_company_name(contact, company))
Company.increment_counter(:contacts_count, company.id)
# rubocop:enable Rails/SkipsModelValidations
company.record_activity_at!(contact.last_activity_at) if contact.last_activity_at.present?
@@ -45,4 +45,8 @@ class Contacts::CompanyAssociationService
def derive_company_name(contact, domain)
contact.additional_attributes&.dig('company_name') || domain.split('.').first.tr('-_', ' ').titleize
end
def contact_attributes_with_company_name(contact, company)
(contact.additional_attributes || {}).merge('company_name' => company.name)
end
end
@@ -112,15 +112,15 @@ RSpec.describe 'Contacts API', type: :request do
end
it 'returns all contacts with company name asc order with null values at last' do
contact_3
get "/api/v1/accounts/#{account.id}/contacts?include_contact_inboxes=false&sort=-company_name",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].first['email']).to eq(contact_1.email)
expect(response_body['payload'].first['id']).to eq(contact_1.id)
expect(response_body['payload'].last['email']).to eq(contact_4.email)
expect(response_body['payload'].first(2).pluck('id')).to contain_exactly(contact.id, contact_1.id)
expect(response_body['payload'].last(2).pluck('id')).to contain_exactly(contact_3.id, contact_4.id)
end
it 'returns all contacts with country name desc order with null values at last' do
@@ -3,7 +3,10 @@ require 'rails_helper'
RSpec.describe 'Company contacts API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:company) { create(:company, name: 'Acme', account: account) }
let(:company) do
create(:company, name: 'Acme', domain: 'acme.com', description: 'Primary account', account: account,
custom_attributes: { 'industry' => 'Manufacturing' })
end
before { account.enable_features!(:companies) }
@@ -19,8 +22,16 @@ RSpec.describe 'Company contacts API', type: :request do
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].pluck('id')).to eq([linked_contact.id])
expect(response_body['payload'].first['company_id']).to eq(company.id)
expect(response_body['payload'].first['linked_to_current_company']).to be true
contact_payload = response_body['payload'].first
expect(contact_payload['company_id']).to eq(company.id)
expect(contact_payload['linked_to_current_company']).to be true
expect(contact_payload['company']).to include(
'id' => company.id,
'name' => 'Acme',
'domain' => 'acme.com',
'description' => 'Primary account',
'custom_attributes' => { 'industry' => 'Manufacturing' }
)
expect(response_body['meta']['total_count']).to eq(1)
end
end
@@ -38,9 +49,14 @@ RSpec.describe 'Company contacts API', type: :request do
as: :json
expect(response).to have_http_status(:success)
contact_ids = response.parsed_body['payload'].pluck('id')
response_payload = response.parsed_body['payload']
contact_ids = response_payload.pluck('id')
expect(contact_ids).to contain_exactly(available_contact.id, assigned_contact.id)
expect(contact_ids).not_to include(linked_contact.id)
expect(response_payload.find { |contact| contact['id'] == assigned_contact.id }['company']).to include(
'id' => other_company.id,
'name' => 'Other Company'
)
end
end
@@ -56,7 +72,7 @@ RSpec.describe 'Company contacts API', type: :request do
expect(response).to have_http_status(:success)
expect(contact.reload.company_id).to eq(company.id)
expect(contact.additional_attributes).to eq('city' => 'Berlin')
expect(contact.additional_attributes).to eq('city' => 'Berlin', 'company_name' => 'Acme')
expect(response.parsed_body['payload']['company_id']).to eq(company.id)
expect(response.parsed_body['payload']['linked_to_current_company']).to be true
expect(company.reload.last_activity_at).to be_within(1.second).of(contact.last_activity_at)
@@ -74,7 +90,7 @@ RSpec.describe 'Company contacts API', type: :request do
expect(response).to have_http_status(:ok)
expect(contact.reload.company_id).to be_nil
expect(contact.additional_attributes).to eq('company_name' => 'Acme', 'city' => 'Berlin')
expect(contact.additional_attributes).to eq('city' => 'Berlin')
end
end
end
@@ -0,0 +1,24 @@
require 'rails_helper'
RSpec.describe 'Enterprise Contacts API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
before { account.enable_features!(:companies) }
describe 'PATCH /api/v1/accounts/{account.id}/contacts/:id' do
it 'updates company association' do
company = create(:company, account: account, name: 'Acme')
contact = create(:contact, account: account)
patch "/api/v1/accounts/#{account.id}/contacts/#{contact.id}",
headers: admin.create_new_auth_token,
params: { company_id: company.id },
as: :json
expect(response).to have_http_status(:success)
expect(contact.reload.company).to eq(company)
expect(contact.additional_attributes['company_name']).to eq('Acme')
end
end
end
@@ -67,4 +67,20 @@ RSpec.describe Contact, type: :model do
end
end
end
describe '#push_event_data' do
let(:account) { create(:account) }
let(:company) { create(:company, account: account) }
let(:contact) { create(:contact, account: account, company: company) }
it 'includes company_id when companies feature is enabled' do
account.enable_features!(:companies)
expect(contact.push_event_data[:company_id]).to eq(company.id)
end
it 'does not include company_id when companies feature is disabled' do
expect(contact.push_event_data).not_to have_key(:company_id)
end
end
end
@@ -25,6 +25,7 @@ RSpec.describe Contacts::CompanyAssociationService, type: :service do
expect(contact.company).to be_present
expect(contact.company.domain).to eq('acme.com')
expect(contact.company.name).to eq('Acme')
expect(contact.additional_attributes['company_name']).to eq('Acme')
end
it 'reuses existing company with same domain' do
+3 -1
View File
@@ -74,8 +74,10 @@ RSpec.describe SlackUnfurlJob do
end
context 'when another account URL is shared' do
let(:another_account) { create(:account) }
before do
link_shared[:event][:links][0][:url] = 'https://qa.chatwoot.com/app/accounts/123/conversations/123'
link_shared[:event][:links][0][:url] = "https://qa.chatwoot.com/app/accounts/#{another_account.id}/conversations/123"
end
it 'does not unfurl' do