diff --git a/app/components/application/detail-header.js b/app/components/application/detail-header.js
index b7df3dde..34431455 100644
--- a/app/components/application/detail-header.js
+++ b/app/components/application/detail-header.js
@@ -1,7 +1,15 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
+import { service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { TOAST_OPTIONS } from '../../constants/toast-options';
+import { NUDGE_APPLICATION_URL } from '../../constants/apis';
+import apiRequest from '../../utils/api-request';
export default class DetailHeader extends Component {
+ @service toast;
+
+ @tracked isLoading = false;
get application() {
return this.args.application;
}
@@ -41,18 +49,22 @@ export default class DetailHeader extends Component {
}
get nudgeCount() {
- return this.application?.nudgeCount ?? 0;
+ return this.args.nudgeCount ?? this.application?.nudgeCount ?? 0;
+ }
+
+ get lastNudgeAt() {
+ return this.args.lastNudgeAt ?? this.application?.lastNudgeAt ?? null;
}
get isNudgeDisabled() {
- if (this.status !== 'pending') {
+ if (this.isLoading || this.status !== 'pending') {
return true;
}
- if (!this.application?.lastNudgedAt) {
+ if (!this.lastNudgeAt) {
return false;
}
const now = Date.now();
- const lastNudgeTime = new Date(this.application.lastNudgedAt).getTime();
+ const lastNudgeTime = new Date(this.lastNudgeAt).getTime();
const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
return now - lastNudgeTime < TWENTY_FOUR_HOURS;
}
@@ -80,9 +92,39 @@ export default class DetailHeader extends Component {
}
@action
- nudgeApplication() {
- //ToDo: Implement logic for callling nudge API here
- console.log('nudge application');
+ async nudgeApplication() {
+ this.isLoading = true;
+
+ try {
+ const response = await apiRequest(
+ NUDGE_APPLICATION_URL(this.application.id),
+ 'PATCH',
+ );
+
+ if (!response.ok) {
+ throw new Error(`Nudge failed: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ const updatedNudgeData = {
+ nudgeCount: data?.nudgeCount ?? this.nudgeCount + 1,
+ lastNudgeAt: data?.lastNudgeAt ?? new Date().toISOString(),
+ };
+
+ this.toast.success(
+ 'Nudge successful, you will be able to nudge again after 24hrs',
+ 'Success!',
+ TOAST_OPTIONS,
+ );
+
+ this.args.onNudge?.(updatedNudgeData);
+ } catch (error) {
+ console.error('Nudge failed:', error);
+ this.toast.error('Failed to nudge application', 'Error!', TOAST_OPTIONS);
+ } finally {
+ this.isLoading = false;
+ }
}
@action
diff --git a/app/constants/apis.js b/app/constants/apis.js
index 6689719d..1d163a5b 100644
--- a/app/constants/apis.js
+++ b/app/constants/apis.js
@@ -52,3 +52,11 @@ export const APPLICATION_BY_ID_URL = (applicationId) => {
};
export const CREATE_APPLICATION_URL = `${APPS.API_BACKEND}/applications`;
+
+export const NUDGE_APPLICATION_URL = (applicationId) => {
+ return `${APPS.API_BACKEND}/applications/${applicationId}/nudge`;
+};
+
+export const APPLICATIONS_BY_USER_URL = (userId) => {
+ return `${APPS.API_BACKEND}/applications?userId=${userId}&dev=true`;
+};
diff --git a/app/controllers/applications/detail.js b/app/controllers/applications/detail.js
index 9581f34b..58efe28f 100644
--- a/app/controllers/applications/detail.js
+++ b/app/controllers/applications/detail.js
@@ -1,11 +1,24 @@
import Controller from '@ember/controller';
+import { action } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
import { adminMessage } from '../../constants/applications';
export default class ApplicationsDetailController extends Controller {
+ @tracked nudgeCount = null;
+ @tracked lastNudgeAt = null;
+
get application() {
return this.model?.application;
}
+ get nudgeCountValue() {
+ return this.nudgeCount ?? this.application?.nudgeCount ?? 0;
+ }
+
+ get lastNudgeAtValue() {
+ return this.lastNudgeAt ?? this.application?.lastNudgeAt ?? null;
+ }
+
get currentUser() {
return this.model?.currentUser;
}
@@ -41,4 +54,10 @@ export default class ApplicationsDetailController extends Controller {
get showAdminMessage() {
return adminMessage(this.application?.status);
}
+
+ @action
+ handleApplicationNudge(nudgeData) {
+ this.nudgeCount = nudgeData.nudgeCount;
+ this.lastNudgeAt = nudgeData.lastNudgeAt;
+ }
}
diff --git a/app/routes/applications/detail.js b/app/routes/applications/detail.js
index eac84ece..a2fcb3c1 100644
--- a/app/routes/applications/detail.js
+++ b/app/routes/applications/detail.js
@@ -1,7 +1,7 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import {
- APPLICATION_BY_ID_URL,
+ APPLICATIONS_BY_USER_URL,
SELF_USER_PROFILE_URL,
} from '../../constants/apis';
import { ERROR_MESSAGES } from '../../constants/error-messages';
@@ -13,7 +13,7 @@ export default class ApplicationsDetailRoute extends Route {
@service toast;
@service router;
- async model(params) {
+ async model() {
try {
const userResponse = await apiRequest(SELF_USER_PROFILE_URL);
if (userResponse.status === 401) {
@@ -22,25 +22,32 @@ export default class ApplicationsDetailRoute extends Route {
return { application: null, currentUser: null };
}
+ const userData = await userResponse.json();
+ const userId = userData.id || userData.user?.id;
+
+ if (!userId) {
+ this.toast.error('User ID not found', 'Error!', TOAST_OPTIONS);
+ return { application: null, currentUser: userData };
+ }
+
const applicationResponse = await apiRequest(
- APPLICATION_BY_ID_URL(params.id),
+ APPLICATIONS_BY_USER_URL(userId),
);
if (applicationResponse.status === 404) {
this.toast.error('Application not found', 'Error!', TOAST_OPTIONS);
- return { application: null, currentUser: null };
+ return { application: null, currentUser: userData };
}
if (!applicationResponse.ok) {
throw new Error(`HTTP error! status: ${applicationResponse.status}`);
}
- const userData = await userResponse.json();
const applicationData = await applicationResponse.json();
- return {
- application: applicationData?.application,
- currentUser: userData,
- };
+ const applications = applicationData?.applications || [];
+ const application = applications[0] || null;
+
+ return { application, currentUser: userData };
} catch (error) {
this.toast.error(
'Something went wrong. ' + error.message,
diff --git a/app/templates/applications/detail.hbs b/app/templates/applications/detail.hbs
index 0a88f702..214f0c59 100644
--- a/app/templates/applications/detail.hbs
+++ b/app/templates/applications/detail.hbs
@@ -4,6 +4,9 @@
@application={{this.application}}
@userDetails={{this.currentUser}}
@isAdmin={{this.isAdmin}}
+ @onNudge={{this.handleApplicationNudge}}
+ @nudgeCount={{this.nudgeCountValue}}
+ @lastNudgeAt={{this.lastNudgeAtValue}}
data-test-detail-header
/>
diff --git a/tests/integration/components/application/detail-header-test.js b/tests/integration/components/application/detail-header-test.js
index e1e3c771..fa3f7bb5 100644
--- a/tests/integration/components/application/detail-header-test.js
+++ b/tests/integration/components/application/detail-header-test.js
@@ -1,6 +1,6 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'website-www/tests/helpers';
-import { render } from '@ember/test-helpers';
+import { render, click, settled, waitFor } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { APPLICATIONS_DATA } from 'website-www/tests/constants/application-data';
@@ -85,7 +85,7 @@ module('Integration | Component | application/detail-header', function (hooks) {
this.set('application', {
status: 'pending',
- lastNudgedAt: recentNudge,
+ lastNudgeAt: recentNudge,
});
await render(
@@ -93,4 +93,88 @@ module('Integration | Component | application/detail-header', function (hooks) {
);
assert.dom('[data-test-button="nudge-button"]').hasAttribute('disabled');
});
+
+ test('it shows loading state during nudge API call', async function (assert) {
+ const application = {
+ ...APPLICATIONS_DATA,
+ status: 'pending',
+ id: 'test-id',
+ };
+ this.set('application', application);
+ this.set('onNudge', () => {});
+
+ await render(hbs`
+
+ `);
+
+ const originalFetch = window.fetch;
+ let resolveNudge;
+ window.fetch = () =>
+ new Promise((resolve) => {
+ resolveNudge = () =>
+ resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ nudgeCount: 1,
+ lastNudgeAt: new Date().toISOString(),
+ }),
+ });
+ });
+
+ click('[data-test-button="nudge-button"]');
+
+ await waitFor('[data-test-button="nudge-button"][disabled]');
+ assert.dom('[data-test-button="nudge-button"]').hasAttribute('disabled');
+
+ resolveNudge();
+ await settled();
+
+ window.fetch = originalFetch;
+ });
+
+ test('it calls onNudge callback with updated data on successful nudge', async function (assert) {
+ assert.expect(2);
+
+ const application = {
+ ...APPLICATIONS_DATA,
+ status: 'pending',
+ nudgeCount: 5,
+ id: 'test-id',
+ };
+ this.set('application', application);
+ this.set('onNudge', (nudgeData) => {
+ assert.strictEqual(
+ nudgeData.nudgeCount,
+ 6,
+ 'Nudge count should be incremented',
+ );
+ assert.ok(nudgeData.lastNudgeAt, 'Last nudge at should be set');
+ });
+
+ const originalFetch = window.fetch;
+ window.fetch = () =>
+ Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ nudgeCount: 6,
+ lastNudgeAt: new Date().toISOString(),
+ }),
+ });
+
+ await render(hbs`
+
+ `);
+
+ await click('[data-test-button="nudge-button"]');
+
+ window.fetch = originalFetch;
+ });
});
diff --git a/tests/unit/routes/applications/detail-test.js b/tests/unit/routes/applications/detail-test.js
index 8335e429..62096a0f 100644
--- a/tests/unit/routes/applications/detail-test.js
+++ b/tests/unit/routes/applications/detail-test.js
@@ -2,7 +2,7 @@ import { module, test } from 'qunit';
import { setupTest } from 'website-www/tests/helpers';
import sinon from 'sinon';
import {
- APPLICATION_BY_ID_URL,
+ APPLICATIONS_BY_USER_URL,
SELF_USER_PROFILE_URL,
} from 'website-www/constants/apis';
@@ -26,27 +26,28 @@ module('Unit | Route | applications/detail', function (hooks) {
assert.ok(route, 'The applications/detail route exists');
});
- test('fetches application by id successfully', async function (assert) {
- const mockApplication = { id: '123', userId: 'user1' };
- const mockUser = { first_name: 'John' };
- const applicationId = '123';
+ test('fetches application by userId successfully', async function (assert) {
+ const mockApplications = [
+ { id: '123', userId: 'user1', status: 'pending' },
+ ];
+ const mockUser = { first_name: 'John', id: 'user1' };
this.fetchStub
.onCall(0)
.resolves(new Response(JSON.stringify(mockUser), { status: 200 }));
this.fetchStub.onCall(1).resolves(
- new Response(JSON.stringify({ application: mockApplication }), {
+ new Response(JSON.stringify({ applications: mockApplications }), {
status: 200,
}),
);
- const result = await this.route.model({ id: applicationId });
+ const result = await this.route.model({ id: '123' }); // params.id still passed but unused
assert.deepEqual(
result,
- { application: mockApplication, currentUser: mockUser },
- 'Returns application and currentUser from API',
+ { application: mockApplications[0], currentUser: mockUser },
+ 'Returns first application and currentUser from API',
);
assert.ok(
this.fetchStub.firstCall.calledWith(
@@ -57,10 +58,10 @@ module('Unit | Route | applications/detail', function (hooks) {
);
assert.ok(
this.fetchStub.secondCall.calledWith(
- APPLICATION_BY_ID_URL(applicationId),
+ APPLICATIONS_BY_USER_URL('user1'),
sinon.match.object,
),
- 'Second API call is made to fetch application by id',
+ 'Second API call is made to fetch applications by userId',
);
});
@@ -80,17 +81,26 @@ module('Unit | Route | applications/detail', function (hooks) {
test('displays error toast on 404 response', async function (assert) {
this.fetchStub
.onCall(0)
- .resolves(new Response(JSON.stringify({}), { status: 200 }));
+ .resolves(new Response(JSON.stringify({ id: 'user1' }), { status: 200 }));
this.fetchStub
.onCall(1)
- .resolves(new Response(JSON.stringify({}), { status: 404 }));
+ .resolves(
+ new Response(JSON.stringify({ applications: [] }), { status: 404 }),
+ );
const result = await this.route.model({ id: '123' });
assert.deepEqual(
result,
- { application: null, currentUser: null },
- 'Returns null object for 404',
+ { application: null, currentUser: { id: 'user1' } },
+ 'Returns null application for 404 but returns user',
+ );
+ assert.ok(
+ this.fetchStub.secondCall.calledWith(
+ APPLICATIONS_BY_USER_URL('user1'),
+ sinon.match.object,
+ ),
+ 'API call is made to fetch applications by userId',
);
assert.ok(
this.route.toast.error.calledOnce,
@@ -98,21 +108,49 @@ module('Unit | Route | applications/detail', function (hooks) {
);
});
- test('displays error toast on API error', async function (assert) {
+ test('handles empty applications array gracefully', async function (assert) {
this.fetchStub
.onCall(0)
- .resolves(new Response(JSON.stringify({}), { status: 200 }));
+ .resolves(new Response(JSON.stringify({ id: 'user1' }), { status: 200 }));
this.fetchStub
.onCall(1)
- .resolves(new Response(JSON.stringify({}), { status: 500 }));
+ .resolves(
+ new Response(JSON.stringify({ applications: [] }), { status: 200 }),
+ );
const result = await this.route.model({ id: '123' });
assert.deepEqual(
result,
- { application: null, currentUser: null },
- 'Returns null object on error',
+ { application: null, currentUser: { id: 'user1' } },
+ 'Returns null application when array is empty',
+ );
+ assert.ok(
+ this.fetchStub.secondCall.calledWith(
+ APPLICATIONS_BY_USER_URL('user1'),
+ sinon.match.object,
+ ),
+ 'API call is made to fetch applications by userId',
+ );
+ });
+
+ test('handles missing userId in user data', async function (assert) {
+ this.fetchStub
+ .onCall(0)
+ .resolves(
+ new Response(JSON.stringify({ first_name: 'John' }), { status: 200 }),
+ );
+
+ const result = await this.route.model({ id: '123' });
+
+ assert.deepEqual(
+ result,
+ { application: null, currentUser: { first_name: 'John' } },
+ 'Returns null application when userId is missing',
+ );
+ assert.ok(
+ this.route.toast.error.calledOnce,
+ 'Error toast is displayed for missing userId',
);
- assert.ok(this.route.toast.error.calledOnce, 'Error toast is displayed');
});
});