mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +00:00 
			
		
		
		
	feat: Add ability to bulk import contacts (#3026)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
		| @@ -52,6 +52,14 @@ class ContactAPI extends ApiClient { | |||||||
|     )}`; |     )}`; | ||||||
|     return axios.get(requestURL); |     return axios.get(requestURL); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   importContacts(file) { | ||||||
|  |     const formData = new FormData(); | ||||||
|  |     formData.append('import_file', file); | ||||||
|  |     return axios.post(`${this.url}/import`, formData, { | ||||||
|  |       headers: { 'Content-Type': 'multipart/form-data' }, | ||||||
|  |     }); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| export default new ContactAPI(); | export default new ContactAPI(); | ||||||
|   | |||||||
| @@ -59,6 +59,18 @@ describe('#ContactsAPI', () => { | |||||||
|         '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support' |         '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support' | ||||||
|       ); |       ); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|  |     it('#importContacts', () => { | ||||||
|  |       const file = 'file'; | ||||||
|  |       contactAPI.importContacts(file); | ||||||
|  |       expect(context.axiosMock.post).toHaveBeenCalledWith( | ||||||
|  |         '/api/v1/contacts/import', | ||||||
|  |         expect.any(FormData), | ||||||
|  |         { | ||||||
|  |           headers: { 'Content-Type': 'multipart/form-data' }, | ||||||
|  |         } | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -0,0 +1,3 @@ | |||||||
|  | .margin-right-small { | ||||||
|  |   margin-right: var(--space-small); | ||||||
|  | } | ||||||
| @@ -71,7 +71,8 @@ | |||||||
|     @include padding($space-large); |     @include padding($space-large); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   form { |   form, | ||||||
|  |   .modal-content { | ||||||
|     @include padding($space-large); |     @include padding($space-large); | ||||||
|     align-self: center; |     align-self: center; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -54,6 +54,19 @@ | |||||||
|     "TITLE": "Create new contact", |     "TITLE": "Create new contact", | ||||||
|     "DESC": "Add basic information details about the contact." |     "DESC": "Add basic information details about the contact." | ||||||
|   }, |   }, | ||||||
|  |   "IMPORT_CONTACTS": { | ||||||
|  |     "BUTTON_LABEL": "Import", | ||||||
|  |     "TITLE": "Import Contacts", | ||||||
|  |     "DESC": "Import contacts through a CSV file.", | ||||||
|  |     "DOWNLOAD_LABEL": "Download a sample csv.", | ||||||
|  |     "FORM": { | ||||||
|  |       "LABEL": "CSV File", | ||||||
|  |       "SUBMIT": "Import", | ||||||
|  |       "CANCEL": "Cancel" | ||||||
|  |     }, | ||||||
|  |     "SUCCESS_MESSAGE": "Contacts saved successfully", | ||||||
|  |     "ERROR_MESSAGE": "There was an error, please try again" | ||||||
|  |   }, | ||||||
|   "DELETE_CONTACT": { |   "DELETE_CONTACT": { | ||||||
|     "BUTTON_LABEL": "Delete Contact", |     "BUTTON_LABEL": "Delete Contact", | ||||||
|     "TITLE": "Delete contact", |     "TITLE": "Delete contact", | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ | |||||||
|         this-selected-contact-id="" |         this-selected-contact-id="" | ||||||
|         :on-input-search="onInputSearch" |         :on-input-search="onInputSearch" | ||||||
|         :on-toggle-create="onToggleCreate" |         :on-toggle-create="onToggleCreate" | ||||||
|  |         :on-toggle-import="onToggleImport" | ||||||
|         :header-title="label" |         :header-title="label" | ||||||
|       /> |       /> | ||||||
|       <contacts-table |       <contacts-table | ||||||
| @@ -30,6 +31,9 @@ | |||||||
|       :on-close="closeContactInfoPanel" |       :on-close="closeContactInfoPanel" | ||||||
|     /> |     /> | ||||||
|     <create-contact :show="showCreateModal" @cancel="onToggleCreate" /> |     <create-contact :show="showCreateModal" @cancel="onToggleCreate" /> | ||||||
|  |     <woot-modal :show.sync="showImportModal" :on-close="onToggleImport"> | ||||||
|  |       <import-contacts v-if="showImportModal" :on-close="onToggleImport" /> | ||||||
|  |     </woot-modal> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| @@ -41,6 +45,7 @@ import ContactsTable from './ContactsTable'; | |||||||
| import ContactInfoPanel from './ContactInfoPanel'; | import ContactInfoPanel from './ContactInfoPanel'; | ||||||
| import CreateContact from 'dashboard/routes/dashboard/conversation/contact/CreateContact'; | import CreateContact from 'dashboard/routes/dashboard/conversation/contact/CreateContact'; | ||||||
| import TableFooter from 'dashboard/components/widgets/TableFooter'; | import TableFooter from 'dashboard/components/widgets/TableFooter'; | ||||||
|  | import ImportContacts from './ImportContacts.vue'; | ||||||
|  |  | ||||||
| const DEFAULT_PAGE = 1; | const DEFAULT_PAGE = 1; | ||||||
|  |  | ||||||
| @@ -51,6 +56,7 @@ export default { | |||||||
|     TableFooter, |     TableFooter, | ||||||
|     ContactInfoPanel, |     ContactInfoPanel, | ||||||
|     CreateContact, |     CreateContact, | ||||||
|  |     ImportContacts, | ||||||
|   }, |   }, | ||||||
|   props: { |   props: { | ||||||
|     label: { type: String, default: '' }, |     label: { type: String, default: '' }, | ||||||
| @@ -59,6 +65,7 @@ export default { | |||||||
|     return { |     return { | ||||||
|       searchQuery: '', |       searchQuery: '', | ||||||
|       showCreateModal: false, |       showCreateModal: false, | ||||||
|  |       showImportModal: false, | ||||||
|       selectedContactId: '', |       selectedContactId: '', | ||||||
|       sortConfig: { name: 'asc' }, |       sortConfig: { name: 'asc' }, | ||||||
|     }; |     }; | ||||||
| @@ -168,6 +175,9 @@ export default { | |||||||
|     onToggleCreate() { |     onToggleCreate() { | ||||||
|       this.showCreateModal = !this.showCreateModal; |       this.showCreateModal = !this.showCreateModal; | ||||||
|     }, |     }, | ||||||
|  |     onToggleImport() { | ||||||
|  |       this.showImportModal = !this.showImportModal; | ||||||
|  |     }, | ||||||
|     onSortChange(params) { |     onSortChange(params) { | ||||||
|       this.sortConfig = params; |       this.sortConfig = params; | ||||||
|       this.fetchContacts(this.meta.currentPage); |       this.fetchContacts(this.meta.currentPage); | ||||||
|   | |||||||
| @@ -29,11 +29,20 @@ | |||||||
|         <woot-button |         <woot-button | ||||||
|           color-scheme="success" |           color-scheme="success" | ||||||
|           icon="ion-android-add-circle" |           icon="ion-android-add-circle" | ||||||
|           @click="onToggleCreate" |           class="margin-right-small" | ||||||
|           data-testid="create-new-contact" |           data-testid="create-new-contact" | ||||||
|  |           @click="onToggleCreate" | ||||||
|         > |         > | ||||||
|           {{ $t('CREATE_CONTACT.BUTTON_LABEL') }} |           {{ $t('CREATE_CONTACT.BUTTON_LABEL') }} | ||||||
|         </woot-button> |         </woot-button> | ||||||
|  |  | ||||||
|  |         <woot-button | ||||||
|  |           color-scheme="info" | ||||||
|  |           icon="ion-android-upload" | ||||||
|  |           @click="onToggleImport" | ||||||
|  |         > | ||||||
|  |           {{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }} | ||||||
|  |         </woot-button> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </header> |   </header> | ||||||
| @@ -41,7 +50,6 @@ | |||||||
|  |  | ||||||
| <script> | <script> | ||||||
| export default { | export default { | ||||||
|   components: {}, |  | ||||||
|   props: { |   props: { | ||||||
|     headerTitle: { |     headerTitle: { | ||||||
|       type: String, |       type: String, | ||||||
| @@ -63,10 +71,15 @@ export default { | |||||||
|       type: Function, |       type: Function, | ||||||
|       default: () => {}, |       default: () => {}, | ||||||
|     }, |     }, | ||||||
|  |     onToggleImport: { | ||||||
|  |       type: Function, | ||||||
|  |       default: () => {}, | ||||||
|  |     }, | ||||||
|   }, |   }, | ||||||
|   data() { |   data() { | ||||||
|     return { |     return { | ||||||
|       showCreateModal: false, |       showCreateModal: false, | ||||||
|  |       showImportModal: false, | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
| @@ -78,6 +91,7 @@ export default { | |||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <style lang="scss" scoped> | <style lang="scss" scoped> | ||||||
|  | @import '~dashboard/assets/scss/_utility-helpers.scss'; | ||||||
| .page-title { | .page-title { | ||||||
|   margin: 0; |   margin: 0; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,92 @@ | |||||||
|  | <template> | ||||||
|  |   <modal :show.sync="show" :on-close="onClose"> | ||||||
|  |     <div class="column content-box"> | ||||||
|  |       <woot-modal-header :header-title="$t('IMPORT_CONTACTS.TITLE')"> | ||||||
|  |         <p> | ||||||
|  |           {{ $t('IMPORT_CONTACTS.DESC') }} | ||||||
|  |           <a :href="csvUrl" download="import-contacts-sample">{{ | ||||||
|  |             $t('IMPORT_CONTACTS.DOWNLOAD_LABEL') | ||||||
|  |           }}</a> | ||||||
|  |         </p> | ||||||
|  |       </woot-modal-header> | ||||||
|  |       <div class="row modal-content"> | ||||||
|  |         <div class="medium-12 columns"> | ||||||
|  |           <label> | ||||||
|  |             <span>{{ $t('IMPORT_CONTACTS.FORM.LABEL') }}</span> | ||||||
|  |             <input | ||||||
|  |               id="file" | ||||||
|  |               ref="file" | ||||||
|  |               type="file" | ||||||
|  |               accept="text/csv" | ||||||
|  |               @change="handleFileUpload" | ||||||
|  |             /> | ||||||
|  |           </label> | ||||||
|  |         </div> | ||||||
|  |         <div class="modal-footer"> | ||||||
|  |           <div class="medium-12 columns"> | ||||||
|  |             <woot-button | ||||||
|  |               :disabled="uiFlags.isCreating || !file" | ||||||
|  |               :loading="uiFlags.isCreating" | ||||||
|  |               @click="uploadFile" | ||||||
|  |             > | ||||||
|  |               {{ $t('IMPORT_CONTACTS.FORM.SUBMIT') }} | ||||||
|  |             </woot-button> | ||||||
|  |             <button class="button clear" @click.prevent="onClose"> | ||||||
|  |               {{ $t('IMPORT_CONTACTS.FORM.CANCEL') }} | ||||||
|  |             </button> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </modal> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script> | ||||||
|  | import Modal from '../../../../components/Modal'; | ||||||
|  | import { mapGetters } from 'vuex'; | ||||||
|  | import alertMixin from 'shared/mixins/alertMixin'; | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |   components: { | ||||||
|  |     Modal, | ||||||
|  |   }, | ||||||
|  |   mixins: [alertMixin], | ||||||
|  |   props: { | ||||||
|  |     onClose: { | ||||||
|  |       type: Function, | ||||||
|  |       default: () => {}, | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   data() { | ||||||
|  |     return { | ||||||
|  |       show: true, | ||||||
|  |       file: '', | ||||||
|  |     }; | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     ...mapGetters({ | ||||||
|  |       uiFlags: 'contacts/getUIFlags', | ||||||
|  |     }), | ||||||
|  |     csvUrl() { | ||||||
|  |       return '/downloads/import-contacts-sample.csv'; | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     async uploadFile() { | ||||||
|  |       try { | ||||||
|  |         if (!this.file) return; | ||||||
|  |         await this.$store.dispatch('contacts/import', this.file); | ||||||
|  |         this.onClose(); | ||||||
|  |         this.showAlert(this.$t('IMPORT_CONTACTS.SUCCESS_MESSAGE')); | ||||||
|  |       } catch (error) { | ||||||
|  |         this.showAlert( | ||||||
|  |           error.message || this.$t('IMPORT_CONTACTS.ERROR_MESSAGE') | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     handleFileUpload() { | ||||||
|  |       this.file = this.$refs.file.files[0]; | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | </script> | ||||||
| @@ -82,7 +82,18 @@ export const actions = { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |   import: async ({ commit }, file) => { | ||||||
|  |     commit(types.SET_CONTACT_UI_FLAG, { isCreating: true }); | ||||||
|  |     try { | ||||||
|  |       await ContactAPI.importContacts(file); | ||||||
|  |       commit(types.SET_CONTACT_UI_FLAG, { isCreating: false }); | ||||||
|  |     } catch (error) { | ||||||
|  |       commit(types.SET_CONTACT_UI_FLAG, { isCreating: false }); | ||||||
|  |       if (error.response?.data?.message) { | ||||||
|  |         throw new ExceptionWithMessage(error.response.data.message); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|   delete: async ({ commit }, id) => { |   delete: async ({ commit }, id) => { | ||||||
|     commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true }); |     commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true }); | ||||||
|     try { |     try { | ||||||
|   | |||||||
							
								
								
									
										26
									
								
								public/downloads/import-contacts-sample.csv
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								public/downloads/import-contacts-sample.csv
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | |||||||
|  | id,name,email,identifier,phone_number,ip_address,custom_attribute_1,custom_attribute_2 | ||||||
|  | 1,Clarice Uzzell,cuzzell0@mozilla.org,bb4e11cd-0f23-49da-a123-dcc1fec6852c,+498963648018,70.61.11.201,Random-value-1,Random-value-1 | ||||||
|  | 2,Marieann Creegan,mcreegan1@cornell.edu,e60bab4c-9fbb-47eb-8f75-42025b789c47,+15417543010,168.186.4.241,Random-value0,Random-value0 | ||||||
|  | 3,Nancey Windibank,nwindibank2@bluehost.com,f793e813-4210-4bf3-a812-711418de25d2,+15417543011,73.44.41.59,Random-value1,Random-value1 | ||||||
|  | 4,Sibel Stennine,sstennine3@yellowbook.com,d6e35a2d-d093-4437-a577-7df76316b937,+15417543011,115.249.27.155,Random-value2,Random-value2 | ||||||
|  | 5,Tina O'Lunney,tolunney4@si.edu,3540d40a-5567-4f28-af98-5583a7ddbc56,+15417543011,219.181.212.8,Random-value3,Random-value3 | ||||||
|  | 6,Quinn Neve,qneve5@army.mil,ba0e1bf0-c74b-41ce-8a2d-0b08fa0e5aa5,+15417543011,231.210.115.166,Random-value4,Random-value4 | ||||||
|  | 7,Karylin Gaunson,kgaunson6@tripod.com,d24cac79-c81b-4b84-a33e-0441b7c6a981,+15417543011,160.189.41.11,Random-value5,Random-value5 | ||||||
|  | 8,Jamison Shenton,jshenton7@upenn.edu,29a7a8c0-c7f7-4af9-852f-761b1a784a7a,+15417543011,53.94.18.201,Random-value6,Random-value6 | ||||||
|  | 9,Gavan Threlfall,gthrelfall8@spotify.com,847d4943-ddb5-47cc-8008-ed5092c675c5,+15417543011,18.87.247.249,Random-value7,Random-value7 | ||||||
|  | 10,Katina Hemmingway,khemmingway9@ameblo.jp,8f0b5efd-b6a8-4f1e-a1e3-b0ea8c9e3048,+15417543011,25.191.96.124,Random-value8,Random-value8 | ||||||
|  | 11,Jillian Deinhard,jdeinharda@canalblog.com,bd952787-1b05-411f-9975-b916ec0950cc,+15417543011,11.211.174.93,Random-value9,Random-value9 | ||||||
|  | 12,Blake Finden,bfindenb@wsj.com,12c95613-e49d-4fa2-86fb-deabb6ebe600,+15417543011,47.26.205.153,Random-value10,Random-value10 | ||||||
|  | 13,Liane Maxworthy,lmaxworthyc@un.org,36b68e4c-40d6-4e09-bf59-7db3b27b18f0,+15417543011,157.196.34.166,Random-value11,Random-value11 | ||||||
|  | 14,Martynne Ledley,mledleyd@sourceforge.net,1856bceb-cb36-415c-8ffc-0527f3f750d8,+15417543011,109.231.152.148,Random-value12,Random-value12 | ||||||
|  | 15,Katharina Ruffli,krufflie@huffingtonpost.com,604de5c9-b154-4279-8978-41fb71f0f773,+15417543011,20.43.146.179,Random-value13,Random-value13 | ||||||
|  | 16,Tucker Simmance,tsimmancef@bbc.co.uk,0a8fc3a7-4986-4a51-a503-6c7f974c90ad,+15417543011,179.76.226.171,Random-value14,Random-value14 | ||||||
|  | 17,Wenona Martinson,wmartinsong@census.gov,0e5ea6e3-6824-4e78-a6f5-672847eafa17,+15417543011,92.243.194.160,Random-value15,Random-value15 | ||||||
|  | 18,Gretna Vedyasov,gvedyasovh@lycos.com,6becf55b-a7b5-48f6-8788-b89cae85b066,+15417543011,25.22.86.101,Random-value16,Random-value16 | ||||||
|  | 19,Lurline Abdon,labdoni@archive.org,afa9429f-9034-4b06-9efa-980e01906ebf,+15417543011,150.249.116.118,Random-value17,Random-value17 | ||||||
|  | 20,Fiann Norcliff,fnorcliffj@istockphoto.com,59f72dec-14ba-4d6e-b17c-0d962e69ffac,+15417543011,237.167.197.197,Random-value18,Random-value18 | ||||||
|  | 21,Zed Linn,zlinnk@phoca.cz,95f7bc56-be92-4c9c-ad58-eff3e63c7bea,+15417543011,88.102.64.113,Random-value19,Random-value19 | ||||||
|  | 22,Averyl Simyson,asimysonl@livejournal.com,bde1fe59-c9bd-440c-bb39-79fe61dac1d1,+15417543011,141.248.89.29,Random-value20,Random-value20 | ||||||
|  | 23,Camella Blackadder,cblackadderm@nifty.com,0c981752-5857-487c-b9b5-5d0253df740a,+15417543011,118.123.138.115,Random-value21,Random-value21 | ||||||
|  | 24,Aurie Spatig,aspatign@printfriendly.com,4cf22bfb-2c3f-41d1-9993-6e3758e457ba,+15417543011,157.45.102.235,Random-value22,Random-value22 | ||||||
|  | 25,Adrienne Bellard,abellardo@cnn.com,f10f9b8d-38ac-4e17-8a7d-d2e6a055f944,+15417543011,170.73.198.47,Random-value23,Random-value23 | ||||||
| 
 | 
		Reference in New Issue
	
	Block a user
	 Fayaz Ahmed
					Fayaz Ahmed