feat: Update API for contact avatar (#4719)
Added the ability to update the contact's avatar via API and Dashboard. - Contact create and update APIs can now accept avatar attachment parameters [form data]. - Contact create and update endpoints can now accept the avatar_url parameter.[json] - API endpoint to remove a contact avatar. - Updated Contact create/edit UI components with avatar support Fixes: #3428
This commit is contained in:
@@ -12,7 +12,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
|
||||
before_action :check_authorization
|
||||
before_action :set_current_page, only: [:index, :active, :search, :filter]
|
||||
before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes, :destroy_custom_attributes]
|
||||
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
|
||||
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
|
||||
|
||||
def index
|
||||
@@ -72,15 +72,17 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
@contact = Current.account.contacts.new(contact_params)
|
||||
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
|
||||
@contact.save!
|
||||
@contact_inbox = build_contact_inbox
|
||||
process_avatar
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@contact.assign_attributes(contact_update_params)
|
||||
@contact.save!
|
||||
process_avatar if permitted_params[:avatar].present? || permitted_params[:avatar_url].present?
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -95,6 +97,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def avatar
|
||||
@contact.avatar.purge if @contact.avatar.attached?
|
||||
@contact
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# TODO: Move this to a finder class
|
||||
@@ -131,19 +138,19 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
ContactInbox.create(contact: @contact, inbox: inbox, source_id: source_id)
|
||||
end
|
||||
|
||||
def contact_params
|
||||
params.require(:contact).permit(:name, :identifier, :email, :phone_number, additional_attributes: {}, custom_attributes: {})
|
||||
def permitted_params
|
||||
params.permit(:name, :identifier, :email, :phone_number, :avatar, :avatar_url, additional_attributes: {}, custom_attributes: {})
|
||||
end
|
||||
|
||||
def contact_custom_attributes
|
||||
return @contact.custom_attributes.merge(contact_params[:custom_attributes]) if contact_params[:custom_attributes]
|
||||
return @contact.custom_attributes.merge(permitted_params[:custom_attributes]) if permitted_params[:custom_attributes]
|
||||
|
||||
@contact.custom_attributes
|
||||
end
|
||||
|
||||
def contact_update_params
|
||||
# we want the merged custom attributes not the original one
|
||||
contact_params.except(:custom_attributes).merge({ custom_attributes: contact_custom_attributes })
|
||||
permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes })
|
||||
end
|
||||
|
||||
def set_include_contact_inboxes
|
||||
@@ -158,6 +165,16 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
|
||||
end
|
||||
|
||||
def process_avatar
|
||||
if permitted_params[:avatar].blank? && permitted_params[:avatar_url].present?
|
||||
::ContactAvatarJob.perform_later(@contact, params[:avatar_url])
|
||||
elsif permitted_params[:avatar].blank? && permitted_params[:email].present?
|
||||
hash = Digest::MD5.hexdigest(params[:email])
|
||||
gravatar_url = "https://www.gravatar.com/avatar/#{hash}?d=404"
|
||||
::ContactAvatarJob.perform_later(@contact, gravatar_url)
|
||||
end
|
||||
end
|
||||
|
||||
def render_error(error, error_status)
|
||||
render json: error, status: error_status
|
||||
end
|
||||
|
||||
@@ -71,6 +71,10 @@ class ContactAPI extends ApiClient {
|
||||
custom_attributes: customAttributes,
|
||||
});
|
||||
}
|
||||
|
||||
destroyAvatar(contactId) {
|
||||
return axios.delete(`${this.url}/${contactId}/avatar`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ContactAPI();
|
||||
|
||||
@@ -12,6 +12,7 @@ describe('#ContactsAPI', () => {
|
||||
expect(contactAPI).toHaveProperty('delete');
|
||||
expect(contactAPI).toHaveProperty('getConversations');
|
||||
expect(contactAPI).toHaveProperty('filter');
|
||||
expect(contactAPI).toHaveProperty('destroyAvatar');
|
||||
});
|
||||
|
||||
describeWithAPIMock('API calls', context => {
|
||||
@@ -100,6 +101,13 @@ describe('#ContactsAPI', () => {
|
||||
queryPayload
|
||||
);
|
||||
});
|
||||
|
||||
it('#destroyAvatar', () => {
|
||||
contactAPI.destroyAvatar(1);
|
||||
expect(context.axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/1/avatar'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<button
|
||||
class="button"
|
||||
:type="type"
|
||||
:class="buttonClasses"
|
||||
:disabled="isDisabled || isLoading"
|
||||
@click="handleClick"
|
||||
@@ -24,6 +25,10 @@ export default {
|
||||
name: 'WootButton',
|
||||
components: { EmojiOrIcon, Spinner },
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'submit',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: '',
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
<label>
|
||||
<span v-if="label">{{ label }}</span>
|
||||
</label>
|
||||
<woot-thumbnail v-if="src" size="80px" :src="src" />
|
||||
<woot-thumbnail
|
||||
v-if="src"
|
||||
size="80px"
|
||||
:src="src"
|
||||
:username="usernameAvatar"
|
||||
/>
|
||||
<div v-if="src && deleteAvatar" class="avatar-delete-btn">
|
||||
<woot-button
|
||||
color-scheme="alert"
|
||||
variant="hollow"
|
||||
size="tiny"
|
||||
type="button"
|
||||
@click="onAvatarDelete"
|
||||
>
|
||||
{{ this.$t('INBOX_MGMT.DELETE.AVATAR_DELETE_BUTTON_TEXT') }}
|
||||
@@ -38,6 +44,10 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
usernameAvatar: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
deleteAvatar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -50,7 +60,7 @@ export default {
|
||||
|
||||
this.$emit('change', {
|
||||
file,
|
||||
url: URL.createObjectURL(file),
|
||||
url: file ? URL.createObjectURL(file) : null,
|
||||
});
|
||||
},
|
||||
onAvatarDelete() {
|
||||
|
||||
@@ -148,6 +148,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"DELETE_AVATAR": {
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Contact avatar deleted successfully",
|
||||
"ERROR_MESSAGE": "Could not delete the contact avatar. Please try again later."
|
||||
}
|
||||
},
|
||||
"SUCCESS_MESSAGE": "Contact saved successfully",
|
||||
"ERROR_MESSAGE": "There was an error, please try again"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
<template>
|
||||
<form class="contact--form" @submit.prevent="handleSubmit">
|
||||
<div class="row">
|
||||
<div class="columns">
|
||||
<woot-avatar-uploader
|
||||
:label="$t('CONTACT_FORM.FORM.AVATAR.LABEL')"
|
||||
:src="avatarUrl"
|
||||
:username-avatar="name"
|
||||
:delete-avatar="!!avatarUrl"
|
||||
class="settings-item"
|
||||
@change="handleImageUpload"
|
||||
@onAvatarDelete="handleAvatarDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="columns">
|
||||
<label :class="{ error: $v.name.$error }">
|
||||
@@ -129,6 +142,8 @@ export default {
|
||||
email: '',
|
||||
name: '',
|
||||
phoneNumber: '',
|
||||
avatarFile: null,
|
||||
avatarUrl: '',
|
||||
socialProfileUserNames: {
|
||||
facebook: '',
|
||||
twitter: '',
|
||||
@@ -186,6 +201,7 @@ export default {
|
||||
this.phoneNumber = phoneNumber || '';
|
||||
this.companyName = additionalAttributes.company_name || '';
|
||||
this.description = additionalAttributes.description || '';
|
||||
this.avatarUrl = this.contact.thumbnail || '';
|
||||
const {
|
||||
social_profiles: socialProfiles = {},
|
||||
screen_name: twitterScreenName,
|
||||
@@ -198,7 +214,7 @@ export default {
|
||||
};
|
||||
},
|
||||
getContactObject() {
|
||||
return {
|
||||
const contactObject = {
|
||||
id: this.contact.id,
|
||||
name: this.name,
|
||||
email: this.email,
|
||||
@@ -210,6 +226,11 @@ export default {
|
||||
social_profiles: this.socialProfileUserNames,
|
||||
},
|
||||
};
|
||||
if (this.avatarFile) {
|
||||
contactObject.avatar = this.avatarFile;
|
||||
contactObject.isFormData = true;
|
||||
}
|
||||
return contactObject;
|
||||
},
|
||||
async handleSubmit() {
|
||||
this.$v.$touch();
|
||||
@@ -237,6 +258,28 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
handleImageUpload({ file, url }) {
|
||||
this.avatarFile = file;
|
||||
this.avatarUrl = url;
|
||||
},
|
||||
async handleAvatarDelete() {
|
||||
try {
|
||||
if (this.contact && this.contact.id) {
|
||||
await this.$store.dispatch('contacts/deleteAvatar', this.contact.id);
|
||||
this.showAlert(
|
||||
this.$t('CONTACT_FORM.DELETE_AVATAR.API.SUCCESS_MESSAGE')
|
||||
);
|
||||
}
|
||||
this.avatarFile = null;
|
||||
this.avatarUrl = '';
|
||||
} catch (error) {
|
||||
this.showAlert(
|
||||
error.message
|
||||
? error.message
|
||||
: this.$t('CONTACT_FORM.DELETE_AVATAR.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -6,6 +6,33 @@ import types from '../../mutation-types';
|
||||
import ContactAPI from '../../../api/contacts';
|
||||
import AccountActionsAPI from '../../../api/accountActions';
|
||||
|
||||
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 {
|
||||
social_profiles,
|
||||
...additionalAttributesProperties
|
||||
} = additional_attributes;
|
||||
Object.keys(additionalAttributesProperties).forEach(key => {
|
||||
formData.append(
|
||||
`additional_attributes[${key}]`,
|
||||
additionalAttributesProperties[key]
|
||||
);
|
||||
});
|
||||
Object.keys(social_profiles).forEach(key => {
|
||||
formData.append(
|
||||
`additional_attributes[social_profiles][${key}]`,
|
||||
social_profiles[key]
|
||||
);
|
||||
});
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
search: async ({ commit }, { search, page, sortAttr, label }) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
|
||||
@@ -52,10 +79,13 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
update: async ({ commit }, { id, ...updateObj }) => {
|
||||
update: async ({ commit }, { id, isFormData = false, ...contactParams }) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
const response = await ContactAPI.update(id, updateObj);
|
||||
const response = await ContactAPI.update(
|
||||
id,
|
||||
isFormData ? buildContactFormData(contactParams) : contactParams
|
||||
);
|
||||
commit(types.EDIT_CONTACT, response.data.payload);
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false });
|
||||
} catch (error) {
|
||||
@@ -68,10 +98,12 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
create: async ({ commit }, userObject) => {
|
||||
create: async ({ commit }, { isFormData = false, ...contactParams }) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isCreating: true });
|
||||
try {
|
||||
const response = await ContactAPI.create(userObject);
|
||||
const response = await ContactAPI.create(
|
||||
isFormData ? buildContactFormData(contactParams) : contactParams
|
||||
);
|
||||
commit(types.SET_CONTACT_ITEM, response.data.payload.contact);
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
|
||||
} catch (error) {
|
||||
@@ -83,6 +115,7 @@ export const actions = {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
import: async ({ commit }, file) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isCreating: true });
|
||||
try {
|
||||
@@ -95,6 +128,7 @@ export const actions = {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ commit }, id) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true });
|
||||
try {
|
||||
@@ -122,6 +156,15 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
deleteAvatar: async ({ commit }, id) => {
|
||||
try {
|
||||
const response = await ContactAPI.destroyAvatar(id);
|
||||
commit(types.EDIT_CONTACT, response.data.payload);
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
|
||||
fetchContactableInbox: async ({ commit }, id) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: true });
|
||||
try {
|
||||
|
||||
@@ -73,7 +73,13 @@ describe('#actions', () => {
|
||||
describe('#update', () => {
|
||||
it('sends correct mutations if API is success', async () => {
|
||||
axios.patch.mockResolvedValue({ data: { payload: contactList[0] } });
|
||||
await actions.update({ commit }, contactList[0]);
|
||||
await actions.update(
|
||||
{ commit },
|
||||
{
|
||||
id: contactList[0].id,
|
||||
contactParams: contactList[0],
|
||||
}
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CONTACT_UI_FLAG, { isUpdating: true }],
|
||||
[types.EDIT_CONTACT, contactList[0]],
|
||||
@@ -101,9 +107,15 @@ describe('#actions', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(actions.update({ commit }, contactList[0])).rejects.toThrow(
|
||||
DuplicateContactException
|
||||
);
|
||||
await expect(
|
||||
actions.update(
|
||||
{ commit },
|
||||
{
|
||||
id: contactList[0].id,
|
||||
contactParams: contactList[0],
|
||||
}
|
||||
)
|
||||
).rejects.toThrow(DuplicateContactException);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CONTACT_UI_FLAG, { isUpdating: true }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isUpdating: false }],
|
||||
@@ -116,7 +128,12 @@ describe('#actions', () => {
|
||||
axios.post.mockResolvedValue({
|
||||
data: { payload: { contact: contactList[0] } },
|
||||
});
|
||||
await actions.create({ commit }, contactList[0]);
|
||||
await actions.create(
|
||||
{ commit },
|
||||
{
|
||||
contactParams: contactList[0],
|
||||
}
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CONTACT_UI_FLAG, { isCreating: true }],
|
||||
[types.SET_CONTACT_ITEM, contactList[0]],
|
||||
@@ -142,9 +159,14 @@ describe('#actions', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(actions.create({ commit }, contactList[0])).rejects.toThrow(
|
||||
ExceptionWithMessage
|
||||
);
|
||||
await expect(
|
||||
actions.create(
|
||||
{ commit },
|
||||
{
|
||||
contactParams: contactList[0],
|
||||
}
|
||||
)
|
||||
).rejects.toThrow(ExceptionWithMessage);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CONTACT_UI_FLAG, { isCreating: true }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isCreating: false }],
|
||||
@@ -299,4 +321,18 @@ describe('#actions', () => {
|
||||
expect(commit.mock.calls).toEqual([[types.CLEAR_CONTACT_FILTERS]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#deleteAvatar', () => {
|
||||
it('sends correct mutations if API is success', async () => {
|
||||
axios.delete.mockResolvedValue({ data: { payload: contactList[0] } });
|
||||
await actions.deleteAvatar({ commit }, contactList[0].id);
|
||||
expect(commit.mock.calls).toEqual([[types.EDIT_CONTACT, contactList[0]]]);
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
|
||||
await expect(
|
||||
actions.deleteAvatar({ commit }, contactList[0].id)
|
||||
).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ class ContactAvatarJob < ApplicationJob
|
||||
max_size: 15 * 1024 * 1024
|
||||
)
|
||||
contact.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type)
|
||||
rescue Down::NotFound
|
||||
contact.avatar.attachment.destroy! if contact.avatar.attached?
|
||||
rescue Down::Error => e
|
||||
Rails.logger.error "Exception: invalid avatar url #{avatar_url} : #{e.message}"
|
||||
end
|
||||
|
||||
@@ -39,6 +39,10 @@ class ContactPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def avatar?
|
||||
true
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
3
app/views/api/v1/accounts/contacts/avatar.json.jbuilder
Normal file
3
app/views/api/v1/accounts/contacts/avatar.json.jbuilder
Normal file
@@ -0,0 +1,3 @@
|
||||
json.payload do
|
||||
json.partial! 'api/v1/models/contact.json.jbuilder', resource: @contact, with_contact_inboxes: false
|
||||
end
|
||||
Reference in New Issue
Block a user