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:
giquieu
2022-07-12 05:03:16 -03:00
committed by GitHub
parent 68fcd28751
commit 827f977a37
18 changed files with 283 additions and 28 deletions

View File

@@ -71,6 +71,10 @@ class ContactAPI extends ApiClient {
custom_attributes: customAttributes,
});
}
destroyAvatar(contactId) {
return axios.delete(`${this.url}/${contactId}/avatar`);
}
}
export default new ContactAPI();

View File

@@ -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'
);
});
});
});

View File

@@ -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: '',

View File

@@ -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() {

View File

@@ -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"
},

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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);
});
});
});