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'); }); });