Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/components/new-join-steps/new-step-one.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
<div class="two-column-layout__left">
<h3 class="section-heading">Profile Picture</h3>
{{#if this.isImageUploading}}
<div class="image-preview-container">
<Spinner />
<div class="image-preview-loading">
<p>Processing image...</p>
</div>
{{else}}
Expand All @@ -13,6 +12,7 @@
src={{this.imagePreview}}
alt="Profile preview"
class="image-preview"
data-test-image-preview
/>
<Reusables::Button
@type="button"
Expand Down
57 changes: 44 additions & 13 deletions app/components/new-join-steps/new-step-one.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
ROLE_OPTIONS,
STEP_DATA_STORAGE_KEY,
} from '../../constants/new-join-form';
import { USER_PROFILE_IMAGE_URL } from '../../constants/apis';
import { TOAST_OPTIONS } from '../../constants/toast-options';
import BaseStepComponent from './base-step';

export default class NewStepOneComponent extends BaseStepComponent {
Expand Down Expand Up @@ -72,7 +74,7 @@ export default class NewStepOneComponent extends BaseStepComponent {
}

@action
handleImageSelect(event) {
async handleImageSelect(event) {
const file = event.target.files?.[0];
if (!file || !file.type.startsWith('image/')) {
this.toast.error(
Expand All @@ -89,21 +91,50 @@ export default class NewStepOneComponent extends BaseStepComponent {

this.isImageUploading = true;

const reader = new FileReader();
reader.onload = (e) => {
const base64String = e.target.result;
this.imagePreview = base64String;
this.updateFieldValue?.('imageUrl', base64String);
this.isImageUploading = false;
};
reader.onerror = () => {
try {
const formData = new FormData();
formData.append('type', 'application');
formData.append('profile', file);

const response = await fetch(USER_PROFILE_IMAGE_URL, {
method: 'POST',
credentials: 'include',
body: formData,
});
if (response.ok) {
const data = await response.json();
const imageUrl = data?.image?.url || data.picture;

if (!imageUrl) {
this.toast.error(
'Upload succeeded but no image URL was returned. Please try again.',
'Error!',
);
return;
}
this.imagePreview = imageUrl;
this.updateFieldValue?.('imageUrl', imageUrl);

this.toast.success(
'Profile image uploaded successfully!',
'Success!',
TOAST_OPTIONS,
);
} else {
const errorData = await response.json();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at response.json()
If the server returns a non-JSON error response (e.g., a 502 Bad Gateway with an HTML body), await response.json() will throw, get swallowed by the catch, and display a cryptic message like "Unexpected token '<'" instead of a meaningful error.

this.toast.error(
errorData.message || 'Failed to upload image. Please try again.',
'Error!',
TOAST_OPTIONS,
);
}
} catch (error) {
this.toast.error(
'Failed to read the selected file. Please try again.',
error.message || 'Failed to upload image. Please try again.',
'Error!',
);
} finally {
this.isImageUploading = false;
};

reader.readAsDataURL(file);
}
}
}
2 changes: 2 additions & 0 deletions app/constants/apis.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,5 @@ export const NUDGE_APPLICATION_URL = (applicationId) => {
export const APPLICATIONS_BY_USER_URL = (userId) => {
return `${APPS.API_BACKEND}/applications?userId=${userId}&dev=true`;
};

export const USER_PROFILE_IMAGE_URL = `${APPS.API_BACKEND}/users/picture`;
8 changes: 8 additions & 0 deletions app/styles/new-stepper.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,14 @@
gap: 1rem;
}

.image-preview-loading {
display: flex;
height: 8rem;
flex-direction: column;
justify-content: center;
align-items: center;
}

.image-preview {
width: 9.5rem;
height: 9.5rem;
Expand Down
141 changes: 141 additions & 0 deletions tests/integration/components/new-join-steps/new-step-one-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'website-www/tests/helpers';
import { render, triggerEvent, waitFor } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import sinon from 'sinon';

module(
'Integration | Component | new-join-steps/new-step-one',
function (hooks) {
setupRenderingTest(hooks);

hooks.beforeEach(function () {
localStorage.removeItem('newStepOneData');

this.toast = this.owner.lookup('service:toast');
sinon.stub(this.toast, 'success');
sinon.stub(this.toast, 'error');

this.setIsPreValid = sinon.stub();
this.setIsValid = sinon.stub();
});

hooks.afterEach(function () {
sinon.restore();
});

test('handleImageSelect rejects non-image files', async function (assert) {
await render(
hbs`<NewJoinSteps::NewStepOne @setIsPreValid={{this.setIsPreValid}} @setIsValid={{this.setIsValid}} />`,
);

const file = new File(['pdf content'], 'document.pdf', {
type: 'application/pdf',
});

await triggerEvent('input[type="file"]', 'change', {
files: [file],
});

assert.ok(
this.toast.error.calledWithExactly(
'Invalid file type. Please upload an image file.',
'Error!',
),
'Shows error for non-image file',
);
});

test('handleImageSelect rejects files larger than 2MB', async function (assert) {
await render(
hbs`<NewJoinSteps::NewStepOne @setIsPreValid={{this.setIsPreValid}} @setIsValid={{this.setIsValid}} />`,
);

const largeFile = new File(['x'.repeat(3 * 1024 * 1024)], 'large.jpg', {
type: 'image/jpeg',
});

await triggerEvent('input[type="file"]', 'change', {
files: [largeFile],
});

assert.ok(
this.toast.error.calledWithExactly(
'Image size must be less than 2MB',
'Error!',
),
'Shows error for oversized file',
);
});

test('imagePreview and imageUrl are updated on successful upload', async function (assert) {
sinon.stub(window, 'fetch').resolves({
ok: true,
json: async () => ({
image: {
url: 'https://example.com/photo.jpg',
},
}),
});

await render(
hbs`<NewJoinSteps::NewStepOne @setIsPreValid={{this.setIsPreValid}} @setIsValid={{this.setIsValid}} />`,
);

const file = new File(['image'], 'photo.jpg', { type: 'image/jpeg' });

await triggerEvent('input[type="file"]', 'change', {
files: [file],
});

await waitFor('[data-test-image-preview]');

assert.dom('[data-test-image-preview]').exists();
assert.dom('[data-test-image-preview]').hasAttribute('src');

const storedData = JSON.parse(
localStorage.getItem('newStepOneData') || '{}',
);
assert.strictEqual(
storedData.imageUrl,
'https://example.com/photo.jpg',
'Persists returned image URL to localStorage',
);
assert.ok(
this.toast.success.calledWithExactly(
'Profile image uploaded successfully!',
'Success!',
sinon.match.object,
),
'Shows success toast',
);
});

test('shows error toast on image upload API failure', async function (assert) {
sinon.stub(window, 'fetch').resolves({
ok: false,
json: async () => ({ message: 'Server error' }),
});

await render(
hbs`<NewJoinSteps::NewStepOne @setIsPreValid={{this.setIsPreValid}} @setIsValid={{this.setIsValid}} />`,
);

const file = new File(['image'], 'photo.jpg', { type: 'image/jpeg' });

await triggerEvent('input[type="file"]', 'change', {
files: [file],
});

assert.dom('[data-test-image-preview]').doesNotExist();
assert.ok(
this.toast.error.calledWithExactly(
'Server error',
'Error!',
sinon.match.object,
),
'Shows error toast with API message',
);
});
},
);