From a4c2ad4dfb160ef57c7f7eeebf6ae95c40e99941 Mon Sep 17 00:00:00 2001 From: Mayank Bansal Date: Mon, 26 Jan 2026 03:13:51 +0530 Subject: [PATCH 1/5] feat: integrate image upload API --- app/components/new-join-steps/new-step-one.js | 54 +++++++++++++------ app/constants/apis.js | 2 + 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/app/components/new-join-steps/new-step-one.js b/app/components/new-join-steps/new-step-one.js index b25da32f..e05f0df0 100644 --- a/app/components/new-join-steps/new-step-one.js +++ b/app/components/new-join-steps/new-step-one.js @@ -7,7 +7,10 @@ import { ROLE_OPTIONS, STEP_DATA_STORAGE_KEY, } from '../../constants/new-join-form'; +import { APPLICATION_PROFILE_IMAGE_URL } from '../../constants/apis'; +import { TOAST_OPTIONS } from '../../constants/toast-options'; import BaseStepComponent from './base-step'; +import apiRequest from '../../utils/api-request'; export default class NewStepOneComponent extends BaseStepComponent { @service login; @@ -72,7 +75,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( @@ -89,21 +92,40 @@ 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 = () => { - this.toast.error( - 'Failed to read the selected file. Please try again.', - 'Error!', - ); - this.isImageUploading = false; - }; + try { + const formData = new FormData(); + formData.append('profile', file); - reader.readAsDataURL(file); + const response = await apiRequest(APPLICATION_PROFILE_IMAGE_URL, 'POST', formData); + if (response.ok) { + const data = await response.json(); + const imageUrl = data.url || data.picture; + + const reader = new FileReader(); + reader.onload = (e) => { + const base64String = e.target.result; + this.imagePreview = base64String; + this.updateFieldValue?.('imageUrl', imageUrl); + }; + reader.readAsDataURL(file); + + this.toast.success( + 'Profile image uploaded successfully!', + 'Success!', + TOAST_OPTIONS, + ); + } else { + const errorData = await response.json(); + this.toast.error( + errorData.message || 'Failed to upload image. Please try again.', + 'Error!', + ); + } + } catch (error) { + console.error('Image upload error:', error); + this.toast.error('Failed to upload image. Please try again.', 'Error!'); + } finally { + this.isImageUploading = false; + } } } diff --git a/app/constants/apis.js b/app/constants/apis.js index 668ed29a..a4a28cf3 100644 --- a/app/constants/apis.js +++ b/app/constants/apis.js @@ -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 APPLICATION_PROFILE_IMAGE_URL = `${APPS.API_BACKEND}/users/picture`; From aa60b8f4fd1cec8e6d99f57377498ff162bc466e Mon Sep 17 00:00:00 2001 From: Mayank Bansal Date: Thu, 19 Feb 2026 18:34:50 +0530 Subject: [PATCH 2/5] fix: modify constant for application image upload --- app/components/new-join-steps/new-step-one.js | 6 +++++- app/constants/apis.js | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/components/new-join-steps/new-step-one.js b/app/components/new-join-steps/new-step-one.js index e05f0df0..15436ad4 100644 --- a/app/components/new-join-steps/new-step-one.js +++ b/app/components/new-join-steps/new-step-one.js @@ -96,7 +96,11 @@ export default class NewStepOneComponent extends BaseStepComponent { const formData = new FormData(); formData.append('profile', file); - const response = await apiRequest(APPLICATION_PROFILE_IMAGE_URL, 'POST', formData); + const response = await apiRequest( + APPLICATION_PROFILE_IMAGE_URL, + 'POST', + formData, + ); if (response.ok) { const data = await response.json(); const imageUrl = data.url || data.picture; diff --git a/app/constants/apis.js b/app/constants/apis.js index a4a28cf3..02a61140 100644 --- a/app/constants/apis.js +++ b/app/constants/apis.js @@ -67,4 +67,4 @@ export const APPLICATIONS_BY_USER_URL = (userId) => { return `${APPS.API_BACKEND}/applications?userId=${userId}&dev=true`; }; -export const APPLICATION_PROFILE_IMAGE_URL = `${APPS.API_BACKEND}/users/picture`; +export const APPLICATION_PROFILE_IMAGE_URL = `${APPS.API_BACKEND}/applications/picture`; From 5624407812297456d9e8af398bc45e740500b978 Mon Sep 17 00:00:00 2001 From: Mayank Bansal Date: Thu, 19 Feb 2026 19:08:25 +0530 Subject: [PATCH 3/5] test: add test for image upload for applications --- .../new-join-steps/new-step-one.hbs | 1 + .../new-join-steps/new-step-one-test.js | 120 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 tests/integration/components/new-join-steps/new-step-one-test.js diff --git a/app/components/new-join-steps/new-step-one.hbs b/app/components/new-join-steps/new-step-one.hbs index 4bb1be77..580511e5 100644 --- a/app/components/new-join-steps/new-step-one.hbs +++ b/app/components/new-join-steps/new-step-one.hbs @@ -13,6 +13,7 @@ src={{this.imagePreview}} alt="Profile preview" class="image-preview" + data-test-image-preview /> `, + ); + + const file = new File(['pdf content'], 'document.pdf', { + type: 'application/pdf', + }); + + await triggerEvent('input[type="file"]', 'change', { + files: [file], + }); + + assert.ok( + this.toast.error.calledWith( + 'Invalid file type. Please upload an image file.', + ), + 'Shows error for non-image file', + ); + }); + + test('handleImageSelect rejects files larger than 2MB', async function (assert) { + await render( + hbs``, + ); + + 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.calledWith('Image size must be less than 2MB'), + 'Shows error for oversized file', + ); + }); + + test('imagePreview and imageUrl are updated on successful upload', async function (assert) { + const fetchStub = sinon.stub(window, 'fetch').resolves({ + ok: true, + json: async () => ({ url: 'https://example.com/photo.jpg' }), + }); + + await render( + hbs``, + ); + + 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'); + assert.ok( + this.toast.success.calledWith('Profile image uploaded successfully!'), + 'Shows success toast', + ); + + fetchStub.restore(); + }); + + test('shows error toast on image upload API failure', async function (assert) { + const fetchStub = sinon.stub(window, 'fetch').resolves({ + ok: false, + json: async () => ({ message: 'Server error' }), + }); + + await render( + hbs``, + ); + + 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.calledWith('Server error'), + 'Shows error toast with API message', + ); + + fetchStub.restore(); + }); + }, +); From 10236e5a05e3fcca7e0496b1770f69347e1a000e Mon Sep 17 00:00:00 2001 From: Mayank Bansal Date: Fri, 20 Feb 2026 01:02:16 +0530 Subject: [PATCH 4/5] fix: remove fileReader and use imageUrl from response --- app/components/new-join-steps/new-step-one.js | 23 +++++++----- .../new-join-steps/new-step-one-test.js | 37 ++++++++++++++----- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/app/components/new-join-steps/new-step-one.js b/app/components/new-join-steps/new-step-one.js index 15436ad4..b4bd111b 100644 --- a/app/components/new-join-steps/new-step-one.js +++ b/app/components/new-join-steps/new-step-one.js @@ -105,13 +105,15 @@ export default class NewStepOneComponent extends BaseStepComponent { const data = await response.json(); const imageUrl = data.url || data.picture; - const reader = new FileReader(); - reader.onload = (e) => { - const base64String = e.target.result; - this.imagePreview = base64String; - this.updateFieldValue?.('imageUrl', imageUrl); - }; - reader.readAsDataURL(file); + 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!', @@ -123,11 +125,14 @@ export default class NewStepOneComponent extends BaseStepComponent { this.toast.error( errorData.message || 'Failed to upload image. Please try again.', 'Error!', + TOAST_OPTIONS, ); } } catch (error) { - console.error('Image upload error:', error); - this.toast.error('Failed to upload image. Please try again.', 'Error!'); + this.toast.error( + error.message || 'Failed to upload image. Please try again.', + 'Error!', + ); } finally { this.isImageUploading = false; } diff --git a/tests/integration/components/new-join-steps/new-step-one-test.js b/tests/integration/components/new-join-steps/new-step-one-test.js index 5e1b0aee..9c8febdd 100644 --- a/tests/integration/components/new-join-steps/new-step-one-test.js +++ b/tests/integration/components/new-join-steps/new-step-one-test.js @@ -38,8 +38,9 @@ module( }); assert.ok( - this.toast.error.calledWith( + this.toast.error.calledWithExactly( 'Invalid file type. Please upload an image file.', + 'Error!', ), 'Shows error for non-image file', ); @@ -59,13 +60,16 @@ module( }); assert.ok( - this.toast.error.calledWith('Image size must be less than 2MB'), + 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) { - const fetchStub = sinon.stub(window, 'fetch').resolves({ + sinon.stub(window, 'fetch').resolves({ ok: true, json: async () => ({ url: 'https://example.com/photo.jpg' }), }); @@ -84,16 +88,27 @@ module( 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.calledWith('Profile image uploaded successfully!'), + this.toast.success.calledWithExactly( + 'Profile image uploaded successfully!', + 'Success!', + sinon.match.object, + ), 'Shows success toast', ); - - fetchStub.restore(); }); test('shows error toast on image upload API failure', async function (assert) { - const fetchStub = sinon.stub(window, 'fetch').resolves({ + sinon.stub(window, 'fetch').resolves({ ok: false, json: async () => ({ message: 'Server error' }), }); @@ -110,11 +125,13 @@ module( assert.dom('[data-test-image-preview]').doesNotExist(); assert.ok( - this.toast.error.calledWith('Server error'), + this.toast.error.calledWithExactly( + 'Server error', + 'Error!', + sinon.match.object, + ), 'Shows error toast with API message', ); - - fetchStub.restore(); }); }, ); From 1709989cbaf3d2aa14f32e2d827ac0da368df684 Mon Sep 17 00:00:00 2001 From: Mayank Bansal Date: Sat, 21 Feb 2026 19:13:04 +0530 Subject: [PATCH 5/5] fix: update api and response for application profile upload --- app/components/new-join-steps/new-step-one.hbs | 3 +-- app/components/new-join-steps/new-step-one.js | 16 ++++++++-------- app/constants/apis.js | 2 +- app/styles/new-stepper.module.css | 8 ++++++++ .../new-join-steps/new-step-one-test.js | 6 +++++- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/app/components/new-join-steps/new-step-one.hbs b/app/components/new-join-steps/new-step-one.hbs index 580511e5..8625b37a 100644 --- a/app/components/new-join-steps/new-step-one.hbs +++ b/app/components/new-join-steps/new-step-one.hbs @@ -2,8 +2,7 @@

Profile Picture

{{#if this.isImageUploading}} -
- +

Processing image...

{{else}} diff --git a/app/components/new-join-steps/new-step-one.js b/app/components/new-join-steps/new-step-one.js index b4bd111b..b0bfd73f 100644 --- a/app/components/new-join-steps/new-step-one.js +++ b/app/components/new-join-steps/new-step-one.js @@ -7,10 +7,9 @@ import { ROLE_OPTIONS, STEP_DATA_STORAGE_KEY, } from '../../constants/new-join-form'; -import { APPLICATION_PROFILE_IMAGE_URL } from '../../constants/apis'; +import { USER_PROFILE_IMAGE_URL } from '../../constants/apis'; import { TOAST_OPTIONS } from '../../constants/toast-options'; import BaseStepComponent from './base-step'; -import apiRequest from '../../utils/api-request'; export default class NewStepOneComponent extends BaseStepComponent { @service login; @@ -94,16 +93,17 @@ export default class NewStepOneComponent extends BaseStepComponent { try { const formData = new FormData(); + formData.append('type', 'application'); formData.append('profile', file); - const response = await apiRequest( - APPLICATION_PROFILE_IMAGE_URL, - 'POST', - formData, - ); + 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.url || data.picture; + const imageUrl = data?.image?.url || data.picture; if (!imageUrl) { this.toast.error( diff --git a/app/constants/apis.js b/app/constants/apis.js index 02a61140..281bd00d 100644 --- a/app/constants/apis.js +++ b/app/constants/apis.js @@ -67,4 +67,4 @@ export const APPLICATIONS_BY_USER_URL = (userId) => { return `${APPS.API_BACKEND}/applications?userId=${userId}&dev=true`; }; -export const APPLICATION_PROFILE_IMAGE_URL = `${APPS.API_BACKEND}/applications/picture`; +export const USER_PROFILE_IMAGE_URL = `${APPS.API_BACKEND}/users/picture`; diff --git a/app/styles/new-stepper.module.css b/app/styles/new-stepper.module.css index 53748129..8e9b22aa 100644 --- a/app/styles/new-stepper.module.css +++ b/app/styles/new-stepper.module.css @@ -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; diff --git a/tests/integration/components/new-join-steps/new-step-one-test.js b/tests/integration/components/new-join-steps/new-step-one-test.js index 9c8febdd..ffc65bd7 100644 --- a/tests/integration/components/new-join-steps/new-step-one-test.js +++ b/tests/integration/components/new-join-steps/new-step-one-test.js @@ -71,7 +71,11 @@ module( test('imagePreview and imageUrl are updated on successful upload', async function (assert) { sinon.stub(window, 'fetch').resolves({ ok: true, - json: async () => ({ url: 'https://example.com/photo.jpg' }), + json: async () => ({ + image: { + url: 'https://example.com/photo.jpg', + }, + }), }); await render(