Skip to content

Commit 870c859

Browse files
feat: integrate image upload API for application (#1142)
* feat: integrate image upload API * fix: modify constant for application image upload * test: add test for image upload for applications * fix: remove fileReader and use imageUrl from response * fix: update api and response for application profile upload
1 parent 52e48cf commit 870c859

5 files changed

Lines changed: 197 additions & 15 deletions

File tree

app/components/new-join-steps/new-step-one.hbs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
<div class="two-column-layout__left">
33
<h3 class="section-heading">Profile Picture</h3>
44
{{#if this.isImageUploading}}
5-
<div class="image-preview-container">
6-
<Spinner />
5+
<div class="image-preview-loading">
76
<p>Processing image...</p>
87
</div>
98
{{else}}
@@ -13,6 +12,7 @@
1312
src={{this.imagePreview}}
1413
alt="Profile preview"
1514
class="image-preview"
15+
data-test-image-preview
1616
/>
1717
<Reusables::Button
1818
@type="button"

app/components/new-join-steps/new-step-one.js

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
ROLE_OPTIONS,
88
STEP_DATA_STORAGE_KEY,
99
} from '../../constants/new-join-form';
10+
import { USER_PROFILE_IMAGE_URL } from '../../constants/apis';
11+
import { TOAST_OPTIONS } from '../../constants/toast-options';
1012
import BaseStepComponent from './base-step';
1113

1214
export default class NewStepOneComponent extends BaseStepComponent {
@@ -72,7 +74,7 @@ export default class NewStepOneComponent extends BaseStepComponent {
7274
}
7375

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

9092
this.isImageUploading = true;
9193

92-
const reader = new FileReader();
93-
reader.onload = (e) => {
94-
const base64String = e.target.result;
95-
this.imagePreview = base64String;
96-
this.updateFieldValue?.('imageUrl', base64String);
97-
this.isImageUploading = false;
98-
};
99-
reader.onerror = () => {
94+
try {
95+
const formData = new FormData();
96+
formData.append('type', 'application');
97+
formData.append('profile', file);
98+
99+
const response = await fetch(USER_PROFILE_IMAGE_URL, {
100+
method: 'POST',
101+
credentials: 'include',
102+
body: formData,
103+
});
104+
if (response.ok) {
105+
const data = await response.json();
106+
const imageUrl = data?.image?.url || data.picture;
107+
108+
if (!imageUrl) {
109+
this.toast.error(
110+
'Upload succeeded but no image URL was returned. Please try again.',
111+
'Error!',
112+
);
113+
return;
114+
}
115+
this.imagePreview = imageUrl;
116+
this.updateFieldValue?.('imageUrl', imageUrl);
117+
118+
this.toast.success(
119+
'Profile image uploaded successfully!',
120+
'Success!',
121+
TOAST_OPTIONS,
122+
);
123+
} else {
124+
const errorData = await response.json();
125+
this.toast.error(
126+
errorData.message || 'Failed to upload image. Please try again.',
127+
'Error!',
128+
TOAST_OPTIONS,
129+
);
130+
}
131+
} catch (error) {
100132
this.toast.error(
101-
'Failed to read the selected file. Please try again.',
133+
error.message || 'Failed to upload image. Please try again.',
102134
'Error!',
103135
);
136+
} finally {
104137
this.isImageUploading = false;
105-
};
106-
107-
reader.readAsDataURL(file);
138+
}
108139
}
109140
}

app/constants/apis.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,5 @@ export const NUDGE_APPLICATION_URL = (applicationId) => {
6666
export const APPLICATIONS_BY_USER_URL = (userId) => {
6767
return `${APPS.API_BACKEND}/applications?userId=${userId}&dev=true`;
6868
};
69+
70+
export const USER_PROFILE_IMAGE_URL = `${APPS.API_BACKEND}/users/picture`;

app/styles/new-stepper.module.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,14 @@
239239
gap: 1rem;
240240
}
241241

242+
.image-preview-loading {
243+
display: flex;
244+
height: 8rem;
245+
flex-direction: column;
246+
justify-content: center;
247+
align-items: center;
248+
}
249+
242250
.image-preview {
243251
width: 9.5rem;
244252
height: 9.5rem;
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { module, test } from 'qunit';
2+
import { setupRenderingTest } from 'website-www/tests/helpers';
3+
import { render, triggerEvent, waitFor } from '@ember/test-helpers';
4+
import { hbs } from 'ember-cli-htmlbars';
5+
import sinon from 'sinon';
6+
7+
module(
8+
'Integration | Component | new-join-steps/new-step-one',
9+
function (hooks) {
10+
setupRenderingTest(hooks);
11+
12+
hooks.beforeEach(function () {
13+
localStorage.removeItem('newStepOneData');
14+
15+
this.toast = this.owner.lookup('service:toast');
16+
sinon.stub(this.toast, 'success');
17+
sinon.stub(this.toast, 'error');
18+
19+
this.setIsPreValid = sinon.stub();
20+
this.setIsValid = sinon.stub();
21+
});
22+
23+
hooks.afterEach(function () {
24+
sinon.restore();
25+
});
26+
27+
test('handleImageSelect rejects non-image files', async function (assert) {
28+
await render(
29+
hbs`<NewJoinSteps::NewStepOne @setIsPreValid={{this.setIsPreValid}} @setIsValid={{this.setIsValid}} />`,
30+
);
31+
32+
const file = new File(['pdf content'], 'document.pdf', {
33+
type: 'application/pdf',
34+
});
35+
36+
await triggerEvent('input[type="file"]', 'change', {
37+
files: [file],
38+
});
39+
40+
assert.ok(
41+
this.toast.error.calledWithExactly(
42+
'Invalid file type. Please upload an image file.',
43+
'Error!',
44+
),
45+
'Shows error for non-image file',
46+
);
47+
});
48+
49+
test('handleImageSelect rejects files larger than 2MB', async function (assert) {
50+
await render(
51+
hbs`<NewJoinSteps::NewStepOne @setIsPreValid={{this.setIsPreValid}} @setIsValid={{this.setIsValid}} />`,
52+
);
53+
54+
const largeFile = new File(['x'.repeat(3 * 1024 * 1024)], 'large.jpg', {
55+
type: 'image/jpeg',
56+
});
57+
58+
await triggerEvent('input[type="file"]', 'change', {
59+
files: [largeFile],
60+
});
61+
62+
assert.ok(
63+
this.toast.error.calledWithExactly(
64+
'Image size must be less than 2MB',
65+
'Error!',
66+
),
67+
'Shows error for oversized file',
68+
);
69+
});
70+
71+
test('imagePreview and imageUrl are updated on successful upload', async function (assert) {
72+
sinon.stub(window, 'fetch').resolves({
73+
ok: true,
74+
json: async () => ({
75+
image: {
76+
url: 'https://example.com/photo.jpg',
77+
},
78+
}),
79+
});
80+
81+
await render(
82+
hbs`<NewJoinSteps::NewStepOne @setIsPreValid={{this.setIsPreValid}} @setIsValid={{this.setIsValid}} />`,
83+
);
84+
85+
const file = new File(['image'], 'photo.jpg', { type: 'image/jpeg' });
86+
87+
await triggerEvent('input[type="file"]', 'change', {
88+
files: [file],
89+
});
90+
91+
await waitFor('[data-test-image-preview]');
92+
93+
assert.dom('[data-test-image-preview]').exists();
94+
assert.dom('[data-test-image-preview]').hasAttribute('src');
95+
96+
const storedData = JSON.parse(
97+
localStorage.getItem('newStepOneData') || '{}',
98+
);
99+
assert.strictEqual(
100+
storedData.imageUrl,
101+
'https://example.com/photo.jpg',
102+
'Persists returned image URL to localStorage',
103+
);
104+
assert.ok(
105+
this.toast.success.calledWithExactly(
106+
'Profile image uploaded successfully!',
107+
'Success!',
108+
sinon.match.object,
109+
),
110+
'Shows success toast',
111+
);
112+
});
113+
114+
test('shows error toast on image upload API failure', async function (assert) {
115+
sinon.stub(window, 'fetch').resolves({
116+
ok: false,
117+
json: async () => ({ message: 'Server error' }),
118+
});
119+
120+
await render(
121+
hbs`<NewJoinSteps::NewStepOne @setIsPreValid={{this.setIsPreValid}} @setIsValid={{this.setIsValid}} />`,
122+
);
123+
124+
const file = new File(['image'], 'photo.jpg', { type: 'image/jpeg' });
125+
126+
await triggerEvent('input[type="file"]', 'change', {
127+
files: [file],
128+
});
129+
130+
assert.dom('[data-test-image-preview]').doesNotExist();
131+
assert.ok(
132+
this.toast.error.calledWithExactly(
133+
'Server error',
134+
'Error!',
135+
sinon.match.object,
136+
),
137+
'Shows error toast with API message',
138+
);
139+
});
140+
},
141+
);

0 commit comments

Comments
 (0)