Add Edit Profile Image option to Onboarding (#19589)

* Basic implementation

* Nit fix

* Using existing profile update api

* API call working

* Basic full implementation

* Nit fixes in design

* UI changes

* Image upload limit set

* Removed drag & drop

* Image size issue fix

* Minor naming fixes

* Removed native mobile code

* Nit fix

* Added few tests

* Added test

* Added test

* Added image upload error

* Added validateFileInput test failure case

* Updated code to test fix

* Revert db changes

* Minor design fixes

* Minor updated test

* Added validateFileInputs test temporarily

* Added processImageUpload test temporarily

* Aded files count test

* Minor warning fixes

* Updated placeholder text

* Added tests for TextInput and TextArea

* Removed un-required test file

* Removed un-required code

* Minor code cleaning
This commit is contained in:
Rajat Talesra 2023-06-23 16:08:27 +05:30 committed by GitHub
parent 35a2f8b026
commit 67ff8d4efb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 418 additions and 19 deletions

View file

@ -446,11 +446,13 @@ $onboarding-user-selected-hover: rgba(71, 85, 235, 0.2);
margin-bottom: 1rem;
.current-user-avatar-container {
width: 80px;
height: 80px;
}
.current-user-avatar {
border-radius: 100%;
width: inherit;
height: inherit;
object-fit: cover;
border: 2px solid var(--base-90);
@ -681,6 +683,40 @@ $onboarding-user-selected-hover: rgba(71, 85, 235, 0.2);
}
}
.onboarding-profile-details-container {
display: flex;
align-items: center;
padding: 0px;
gap: 24px;
}
.onboarding-profile-image {
width: 80px;
height: 80px;
aspect-ratio: 1/1;
border-radius: 40px;
border: 1.5px solid #d4d4d4;
}
.onboarding-profile-details-sub-container {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0px;
gap: 10px;
}
.onboarding-profile-user-name {
font-weight: var(--fw-bol);
font-size: var(--fs-xl);
line-height: var(--lh-base);
}
.onboarding-profile-upload-error {
color: darken($red, 8%);
font-size: 0.8em;
}
// Adjusting modals... This is naughty... Santa won't come.
.onboarding-main {
--onboarding-modal-height: 800px;

View file

@ -43,16 +43,26 @@ class UsersController < ApplicationController
end
flash[:settings_notice] = notice
@user.touch(:profile_updated_at)
redirect_to "/settings/#{@tab}"
respond_to do |format|
format.json { render json: { success: true, user: @user } }
format.html { redirect_to "/settings/#{@tab}" }
end
else
Honeycomb.add_field("error", @user.errors.messages.compact_blank)
Honeycomb.add_field("errored", true)
if @tab
render :edit, status: :bad_request
else
flash[:error] = @user.errors.full_messages.join(", ")
redirect_to "/settings"
error_message = @user.errors.full_messages.join(", ")
respond_to do |format|
format.json { render json: { success: false, error: error_message }, status: :bad_request }
format.html do
if @tab
render :edit, status: :bad_request
else
flash[:error] = error_message
redirect_to "/settings"
end
end
end
end
end

View file

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { userData, updateOnboarding } from '../utilities';
import { ProfileImage } from './ProfileForm/ProfileImage';
import { Navigation } from './Navigation';
import { TextArea } from './ProfileForm/TextArea';
import { TextInput } from './ProfileForm/TextInput';
@ -21,9 +22,13 @@ export class ProfileForm extends Component {
this.user = userData();
this.state = {
groups: [],
formValues: { username: this.user.username },
formValues: {
username: this.user.username,
profile_image_90: this.user.profile_image_90,
},
canSkip: false,
last_onboarding_page: 'v2: personal info form',
profile_image_90: this.user.profile_image_90,
};
}
@ -48,12 +53,12 @@ export class ProfileForm extends Component {
async onSubmit() {
const { formValues, last_onboarding_page } = this.state;
const { username, ...newFormValues } = formValues;
const { username, profile_image_90, ...newFormValues } = formValues;
try {
const response = await request('/onboarding', {
method: 'PATCH',
body: {
user: { last_onboarding_page, username },
user: { last_onboarding_page, profile_image_90, username },
profile: { ...newFormValues },
},
});
@ -142,10 +147,18 @@ export class ProfileForm extends Component {
}
}
onProfileImageUrlChange = (url) => {
this.setState({ profile_image_90: url }, () => {
this.handleFieldChange({
target: { name: 'profile_image_90', value: url },
});
});
};
render() {
const { prev, slidesCount, currentSlideIndex, communityConfig } =
this.props;
const { profile_image_90, username, name } = this.user;
const { username, name } = this.user;
const { canSkip, groups = [], error, errorMessage } = this.state;
const SUMMARY_MAXLENGTH = 200;
const summaryCharacters = this.state?.formValues?.summary?.length || 0;
@ -204,15 +217,13 @@ export class ProfileForm extends Component {
able to edit this later in your Settings.
</h2>
</header>
<div className="current-user-info">
<figure className="current-user-avatar-container">
<img
className="current-user-avatar"
alt="profile"
src={profile_image_90}
/>
</figure>
<h3>{name}</h3>
<div className="onboarding-profile-sub-section mt-8">
<ProfileImage
onMainImageUrlChange={this.onProfileImageUrlChange}
mainImage={this.state.profile_image_90}
userId={this.user.id}
name={name}
/>
</div>
<div className="onboarding-profile-sub-section">
<TextInput
@ -222,6 +233,9 @@ export class ProfileForm extends Component {
default_value: username,
required: true,
maxLength: 20,
placeholder_text: 'johndoe',
description: '',
input_type: 'text',
}}
onFieldChange={this.handleFieldChange}
/>
@ -234,6 +248,8 @@ export class ProfileForm extends Component {
placeholder_text: 'Tell us a little about yourself',
required: false,
maxLength: SUMMARY_MAXLENGTH,
description: '',
input_type: 'text_area',
}}
onFieldChange={this.handleFieldChange}
/>

View file

@ -0,0 +1,111 @@
import { h, Fragment } from 'preact';
import { useState } from 'preact/hooks';
import PropTypes from 'prop-types';
import { generateMainImage } from '../actions';
import { validateFileInputs } from '../../../packs/validateFileInputs';
import { Spinner } from '@crayons/Spinner/Spinner';
const StandardImageUpload = ({ handleImageUpload, isUploadingImage }) =>
isUploadingImage ? null : (
<Fragment>
<label className="cursor-pointer crayons-btn crayons-btn--secondary">
Edit profile image
<input
data-testid="profile-image-input"
id="profile-image-input"
type="file"
onChange={handleImageUpload}
accept="image/*"
className="screen-reader-only"
data-max-file-size-mb="25"
/>
</label>
</Fragment>
);
export const ProfileImage = ({
onMainImageUrlChange,
mainImage,
userId,
name,
}) => {
const [uploadError, setUploadError] = useState(false);
const [uploadErrorMessage, setUploadErrorMessage] = useState(null);
const [uploadingImage, setUploadingImage] = useState(false);
const onImageUploadSuccess = (url) => {
onMainImageUrlChange(url);
setUploadingImage(false);
};
const handleMainImageUpload = (event) => {
event.preventDefault();
setUploadingImage(true);
clearUploadError();
if (validateFileInputs()) {
const { files: image } = event.dataTransfer || event.target;
const payload = { image, userId };
generateMainImage({
payload,
successCb: onImageUploadSuccess,
failureCb: onUploadError,
});
} else {
setUploadingImage(false);
}
};
const clearUploadError = () => {
setUploadError(false);
setUploadErrorMessage(null);
};
const onUploadError = (error) => {
setUploadingImage(false);
setUploadError(true);
setUploadErrorMessage(error.message);
};
return (
<div className="onboarding-profile-details-container" role="presentation">
{!uploadingImage && mainImage && (
<img
className="onboarding-profile-image"
alt="profile"
src={mainImage}
/>
)}
<div className="onboarding-profile-details-sub-container">
<h3 className="onboarding-profile-user-name">{name}</h3>
{uploadingImage && (
<span class="lh-base pl-1 border-0 py-2 inline-block">
<Spinner /> Uploading...
</span>
)}
<Fragment>
<StandardImageUpload
isUploadingImage={uploadingImage}
handleImageUpload={handleMainImageUpload}
/>
</Fragment>
{uploadError && (
<p className="onboarding-profile-upload-error">
{uploadErrorMessage}
</p>
)}
</div>
</div>
);
};
ProfileImage.propTypes = {
mainImage: PropTypes.string,
onMainImageUrlChange: PropTypes.func.isRequired,
};
ProfileImage.displayName = 'ProfileImage';

View file

@ -102,6 +102,66 @@ describe('ProfileForm', () => {
);
});
it('should render TextInput with placeholder text', () => {
const { getByPlaceholderText } = render(
<ProfileForm
prev={jest.fn()}
next={jest.fn()}
slidesCount={3}
currentSlideIndex={1}
communityConfig={{ communityName: 'Community' }}
/>,
);
const usernameInput = getByPlaceholderText('johndoe');
expect(usernameInput).toBeInTheDocument();
});
it('should render TextArea with placeholder text', () => {
const { getByPlaceholderText } = render(
<ProfileForm
prev={jest.fn()}
next={jest.fn()}
slidesCount={3}
currentSlideIndex={1}
communityConfig={{ communityName: 'Community' }}
/>,
);
const bioTextArea = getByPlaceholderText('Tell us a little about yourself');
expect(bioTextArea).toBeInTheDocument();
});
it('should render TextInput with input type "text"', () => {
const { getByPlaceholderText } = render(
<ProfileForm
prev={jest.fn()}
next={jest.fn()}
slidesCount={3}
currentSlideIndex={1}
communityConfig={{ communityName: 'Community' }}
/>,
);
const usernameInput = getByPlaceholderText('johndoe');
expect(usernameInput.type).toBe('text');
});
it('should render TextArea with description text', () => {
const { getByText } = render(
<ProfileForm
prev={jest.fn()}
next={jest.fn()}
slidesCount={3}
currentSlideIndex={1}
communityConfig={{ communityName: 'Community' }}
/>,
);
const bioDescription = getByText('Bio');
expect(bioDescription).toBeInTheDocument();
});
it('should show the correct name and username', () => {
const { queryByText } = renderProfileForm();

View file

@ -0,0 +1,133 @@
import { h } from 'preact';
import { render, fireEvent, waitFor } from '@testing-library/preact';
import { axe } from 'jest-axe';
import fetch from 'jest-fetch-mock';
import '@testing-library/jest-dom';
import { ProfileImage } from '../ProfileForm/ProfileImage';
global.fetch = fetch;
describe('<ProfileImage />', () => {
it('should render correctly', () => {
const onMainImageUrlChangeMock = jest.fn();
const { getByTestId } = render(
<ProfileImage
onMainImageUrlChange={onMainImageUrlChangeMock}
mainImage="test.jpg"
userId="1"
name="Test User"
/>,
);
expect(getByTestId('profile-image-input')).toBeInTheDocument();
});
it('should have no a11y violations', async () => {
const { container } = render(
<ProfileImage
mainImage="/i/r5tvutqpl7th0qhzcw7f.png"
onMainImageUrlChange={jest.fn()}
userId="user1"
name="User 1"
/>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('displays an upload input when the image is not being uploaded', () => {
const { getByLabelText } = render(
<ProfileImage
mainImage=""
onMainImageUrlChange={jest.fn()}
userId="user1"
name="User 1"
/>,
);
const uploadInput = getByLabelText(/edit profile image/i);
expect(uploadInput.getAttribute('type')).toEqual('file');
});
it('shows the uploaded image', () => {
const { getByRole, queryByText } = render(
<ProfileImage
mainImage="/some-fake-image.jpg"
onMainImageUrlChange={jest.fn()}
userId="user1"
name="User 1"
/>,
);
const uploadInput = getByRole('img', { name: 'profile' });
expect(uploadInput.getAttribute('src')).toEqual('/some-fake-image.jpg');
expect(queryByText('Uploading...')).not.toBeInTheDocument();
});
it('shows the "Uploading..." message when an image is being uploaded', () => {
const { getByLabelText, getByText } = render(
<ProfileImage
mainImage=""
onMainImageUrlChange={jest.fn()}
userId="user1"
name="User 1"
/>,
);
const file = new File(['file content'], 'filename.png', {
type: 'image/png',
});
const uploadInput = getByLabelText(/edit profile image/i);
fireEvent.change(uploadInput, { target: { files: [file] } });
expect(getByText('Uploading...')).toBeInTheDocument();
});
it('displays an upload error when necessary', async () => {
const { getByLabelText, findByText, queryByText } = render(
<ProfileImage
onMainImageUrlChange={jest.fn()}
mainImage="test.png"
userId="1"
name="Test User"
/>,
);
const inputEl = getByLabelText('Edit profile image', { exact: false });
expect(inputEl.getAttribute('accept')).toEqual('image/*');
const file = new File(['(⌐□_□)'], 'chucknorris.png', {
type: 'image/png',
});
fetch.mockReject({
message: 'Some Fake Error',
});
fireEvent.change(inputEl, { target: { files: [file] } });
const fakeError = await findByText(/some fake error/i);
expect(fakeError).toBeInTheDocument();
expect(queryByText('Uploading...')).not.toBeInTheDocument();
});
it('should handle image upload correctly', async () => {
const onMainImageUrlChangeMock = jest.fn();
const { getByTestId } = render(
<ProfileImage
onMainImageUrlChange={onMainImageUrlChangeMock}
mainImage=""
userId="user1"
name="User 1"
/>,
);
const file = new File(['file content'], 'filename.png', {
type: 'image/png',
});
const uploadInput = getByTestId('profile-image-input');
fireEvent.change(uploadInput, { target: { files: [file] } });
await waitFor(() => {
expect(uploadInput.files).toHaveLength(1);
});
});
});

View file

@ -0,0 +1,33 @@
function generateUploadFormdata(image) {
const token = window.csrfToken;
const formData = new FormData();
formData.append('authenticity_token', token);
formData.append('user[profile_image]', image);
return formData;
}
export function generateMainImage({ payload, successCb, failureCb, signal }) {
const image = payload.image[0];
const { userId } = payload;
if (image) {
fetch(`/users/${userId}`, {
method: 'PUT',
headers: {
'X-CSRF-Token': window.csrfToken,
Accept: 'application/json',
},
body: generateUploadFormdata(image),
credentials: 'same-origin',
signal,
})
.then((response) => response.json())
.then((json) => {
if (json.error) {
throw new Error(json.error);
}
return successCb(json.user.profile_image.url);
})
.catch((message) => failureCb(message));
}
}