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:
co-authored by
Sivin Varghese
iamsivin
Sony Mathew
parent
de137e8297
commit
41a3ab6dfa
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user