diff --git a/app/components/application/detail-header.hbs b/app/components/application/detail-header.hbs index 34849825..c0f603ff 100644 --- a/app/components/application/detail-header.hbs +++ b/app/components/application/detail-header.hbs @@ -72,6 +72,7 @@ @text="Edit" @variant="light" @class="btn--xs" + @disabled={{this.isEditDisabled}} @onClick={{this.editApplication}} @test="edit-button" /> diff --git a/app/components/application/detail-header.js b/app/components/application/detail-header.js index 34431455..04363fcc 100644 --- a/app/components/application/detail-header.js +++ b/app/components/application/detail-header.js @@ -6,7 +6,21 @@ import { TOAST_OPTIONS } from '../../constants/toast-options'; import { NUDGE_APPLICATION_URL } from '../../constants/apis'; import apiRequest from '../../utils/api-request'; +const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; + +function isWithinCooldown(timestamp, cooldownMs = TWENTY_FOUR_HOURS) { + if (!timestamp) { + return false; + } + + const now = Date.now(); + const time = new Date(timestamp).getTime(); + + return now - time < cooldownMs; +} + export default class DetailHeader extends Component { + @service router; @service toast; @tracked isLoading = false; @@ -56,17 +70,22 @@ export default class DetailHeader extends Component { return this.args.lastNudgeAt ?? this.application?.lastNudgeAt ?? null; } + get lastEditAt() { + return this.application?.lastEditAt ?? null; + } + get isNudgeDisabled() { if (this.isLoading || this.status !== 'pending') { return true; } - if (!this.lastNudgeAt) { - return false; + return isWithinCooldown(this.lastNudgeAt); + } + + get isEditDisabled() { + if (this.isLoading) { + return true; } - const now = Date.now(); - const lastNudgeTime = new Date(this.lastNudgeAt).getTime(); - const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; - return now - lastNudgeTime < TWENTY_FOUR_HOURS; + return isWithinCooldown(this.lastEditAt); } get socialLinks() { @@ -129,13 +148,17 @@ export default class DetailHeader extends Component { @action editApplication() { - //ToDo: Implement logic for edit application here - console.log('edit application'); + this.router.transitionTo('join', { + queryParams: { + edit: true, + dev: true, + step: 1, + }, + }); } @action navigateToDashboard() { - //ToDo: Navigate to dashboard site for admin actions - console.log('navigate to dashboard'); + this.router.transitionTo(`/intro?id=${this.userDetails?.id}`); } } diff --git a/app/components/new-join-steps/base-step.js b/app/components/new-join-steps/base-step.js index 6f0addc7..451010c5 100644 --- a/app/components/new-join-steps/base-step.js +++ b/app/components/new-join-steps/base-step.js @@ -1,39 +1,85 @@ import { action } from '@ember/object'; import Component from '@glimmer/component'; +import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { debounceTask, runTask } from 'ember-lifeline'; import { JOIN_DEBOUNCE_TIME } from '../../constants/join'; +import { USER_ROLE_MAP } from '../../constants/new-join-form'; import { validateWordCount } from '../../utils/validator'; import { getLocalStorageItem, setLocalStorageItem } from '../../utils/storage'; export default class BaseStepComponent extends Component { + @service onboarding; + stepValidation = {}; @tracked data = {}; @tracked errorMessage = {}; @tracked wordCount = {}; - get storageKey() { - return ''; - } - postLoadInitialize() {} + STEP_FORM_DATA_MAPPING = { + newStepOneData: (app) => ({ + firstName: app.biodata?.firstName || '', + lastName: app.biodata?.lastName || '', + city: app.location?.city || '', + state: app.location?.state || '', + country: app.location?.country || '', + role: + Object.keys(USER_ROLE_MAP).find( + (key) => USER_ROLE_MAP[key] === app.role, + ) || '', + imageUrl: app.imageUrl || '', + }), + newStepTwoData: (app) => ({ + institution: app.professional?.institution || '', + skills: app.professional?.skills || '', + introduction: app.intro?.introduction || '', + }), + newStepThreeData: (app) => ({ + forFun: app.intro?.forFun || '', + funFact: app.intro?.funFact || '', + }), + newStepFourData: (app) => ({ + phoneNo: app.socialLink?.phoneNo || '', + twitter: app.socialLink?.twitter || '', + linkedin: app.socialLink?.linkedin || '', + instagram: app.socialLink?.instagram || '', + github: app.socialLink?.github || '', + peerlist: app.socialLink?.peerlist || '', + behance: app.socialLink?.behance || '', + dribble: app.socialLink?.dribble || '', + }), + newStepFiveData: (app) => ({ + whyRds: app.intro?.whyRds || '', + foundFrom: app.foundFrom || '', + numberOfHours: app.intro?.numberOfHours || '', + }), + }; + constructor(...args) { super(...args); this.initializeFormState(); } initializeFormState() { - let saved = {}; - try { - const stored = getLocalStorageItem(this.storageKey, '{}'); - saved = stored ? JSON.parse(stored) : {}; - } catch (e) { - console.warn('Failed to parse stored form data:', e); - saved = {}; + const storedData = getLocalStorageItem(this.storageKey, '{}'); + let initialFormData = storedData ? JSON.parse(storedData) : {}; + + if ( + Object.keys(initialFormData).length === 0 && + this.onboarding.applicationData + ) { + const stepDataMapper = this.STEP_FORM_DATA_MAPPING[this.storageKey]; + + if (stepDataMapper) { + initialFormData = stepDataMapper(this.onboarding.applicationData); + setLocalStorageItem(this.storageKey, JSON.stringify(initialFormData)); + } } - this.data = saved; + + this.data = initialFormData; this.errorMessage = Object.fromEntries( Object.keys(this.stepValidation).map((k) => [k, '']), diff --git a/app/components/new-join-steps/new-step-five.js b/app/components/new-join-steps/new-step-five.js index 71a35326..e829f761 100644 --- a/app/components/new-join-steps/new-step-five.js +++ b/app/components/new-join-steps/new-step-five.js @@ -6,7 +6,9 @@ import { import { heardFrom } from '../../constants/social-data'; export default class NewStepFiveComponent extends BaseStepComponent { - storageKey = STEP_DATA_STORAGE_KEY.stepFive; + get storageKey() { + return STEP_DATA_STORAGE_KEY.stepFive; + } heardFrom = heardFrom; stepValidation = { diff --git a/app/components/new-join-steps/new-step-four.js b/app/components/new-join-steps/new-step-four.js index eefa60b2..e436d275 100644 --- a/app/components/new-join-steps/new-step-four.js +++ b/app/components/new-join-steps/new-step-four.js @@ -6,7 +6,9 @@ import { import { phoneNumberRegex } from '../../constants/regex'; export default class NewStepFourComponent extends BaseStepComponent { - storageKey = STEP_DATA_STORAGE_KEY.stepFour; + get storageKey() { + return STEP_DATA_STORAGE_KEY.stepFour; + } stepValidation = { phoneNo: NEW_STEP_LIMITS.stepFour.phoneNo, diff --git a/app/components/new-join-steps/new-step-six.hbs b/app/components/new-join-steps/new-step-six.hbs index 796a58a3..e44573d9 100644 --- a/app/components/new-join-steps/new-step-six.hbs +++ b/app/components/new-join-steps/new-step-six.hbs @@ -91,10 +91,13 @@ Institution/Company: - {{#if this.stepData.two.college}} - {{this.stepData.two.college}} + {{#if this.stepData.two.institution}} + {{this.stepData.two.institution}} {{else}} Not provided {{/if}} diff --git a/app/components/new-join-steps/new-step-three.js b/app/components/new-join-steps/new-step-three.js index 74e6c16d..6aaba644 100644 --- a/app/components/new-join-steps/new-step-three.js +++ b/app/components/new-join-steps/new-step-three.js @@ -5,7 +5,9 @@ import { } from '../../constants/new-join-form'; export default class NewStepThreeComponent extends BaseStepComponent { - storageKey = STEP_DATA_STORAGE_KEY.stepThree; + get storageKey() { + return STEP_DATA_STORAGE_KEY.stepThree; + } stepValidation = { forFun: NEW_STEP_LIMITS.stepThree.forFun, funFact: NEW_STEP_LIMITS.stepThree.funFact, diff --git a/app/components/new-join-steps/new-step-two.hbs b/app/components/new-join-steps/new-step-two.hbs index a1dba022..91512ce7 100644 --- a/app/components/new-join-steps/new-step-two.hbs +++ b/app/components/new-join-steps/new-step-two.hbs @@ -27,18 +27,18 @@
- {{#if this.errorMessage.college}} + {{#if this.errorMessage.institution}}
{{this.errorMessage.college}}
+ data-test-error="institution" + >{{this.errorMessage.institution}}
{{/if}} diff --git a/app/components/new-join-steps/new-step-two.js b/app/components/new-join-steps/new-step-two.js index 41a35b67..c7b61423 100644 --- a/app/components/new-join-steps/new-step-two.js +++ b/app/components/new-join-steps/new-step-two.js @@ -5,10 +5,12 @@ import { } from '../../constants/new-join-form'; export default class NewStepTwoComponent extends BaseStepComponent { - storageKey = STEP_DATA_STORAGE_KEY.stepTwo; + get storageKey() { + return STEP_DATA_STORAGE_KEY.stepTwo; + } stepValidation = { skills: NEW_STEP_LIMITS.stepTwo.skills, - college: NEW_STEP_LIMITS.stepTwo.college, + institution: NEW_STEP_LIMITS.stepTwo.institution, introduction: NEW_STEP_LIMITS.stepTwo.introduction, }; } diff --git a/app/components/new-stepper.js b/app/components/new-stepper.js index da71fa8b..9032c13a 100644 --- a/app/components/new-stepper.js +++ b/app/components/new-stepper.js @@ -2,14 +2,23 @@ import { action } from '@ember/object'; import { service } from '@ember/service'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { CREATE_APPLICATION_URL } from '../constants/apis'; +import { + CREATE_APPLICATION_URL, + UPDATE_APPLICATION_URL, +} from '../constants/apis'; import { NEW_FORM_STEPS, USER_ROLE_MAP, STEP_DATA_STORAGE_KEY, } from '../constants/new-join-form'; import { TOAST_OPTIONS } from '../constants/toast-options'; -import { getLocalStorageItem, setLocalStorageItem } from '../utils/storage'; +import { socialFields } from '../constants/applications'; +import { + getLocalStorageItem, + safeParse, + setLocalStorageItem, +} from '../utils/storage'; +import apiRequest from '../utils/api-request'; export default class NewStepperComponent extends Component { MIN_STEP = 0; @@ -52,6 +61,10 @@ export default class NewStepperComponent extends Component { }); } + get isEditMode() { + return this.args.isEditMode; + } + get showPreviousButton() { return this.currentStep > this.MIN_STEP + 1; } @@ -116,19 +129,18 @@ export default class NewStepperComponent extends Component { this.isSubmitting = true; try { const applicationData = this.collectApplicationData(); + const url = this.isEditMode + ? UPDATE_APPLICATION_URL(this.onboarding.applicationData?.id) + : CREATE_APPLICATION_URL; + const method = this.isEditMode ? 'PATCH' : 'POST'; - const response = await fetch(CREATE_APPLICATION_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - body: JSON.stringify(applicationData), - }); + const response = await apiRequest(url, method, applicationData); if (response.status === 409) { this.toast.error( - 'You have already submitted an application.', + this.isEditMode + ? 'You will be able to edit after 24 hrs.' + : 'You have already submitted an application.', 'Application Exists!', TOAST_OPTIONS, ); @@ -138,7 +150,8 @@ export default class NewStepperComponent extends Component { if (!response.ok) { this.toast.error( - response.message || 'Failed to submit application. Please try again.', + response.message || + `Failed to ${this.isEditMode ? 'edit' : 'submit'} application. Please try again.`, 'Error!', TOAST_OPTIONS, ); @@ -146,11 +159,12 @@ export default class NewStepperComponent extends Component { return; } - const data = await response.json(); - this.applicationId = data.application?.id; + await response.json(); this.toast.success( - 'Application submitted successfully!', + this.isEditMode + ? 'You have successfully edited the application' + : 'Application submitted successfully!', 'Success!', TOAST_OPTIONS, ); @@ -163,7 +177,7 @@ export default class NewStepperComponent extends Component { } catch (error) { console.error('Error submitting application:', error); this.toast.error( - 'Failed to submit application. Please try again.', + `Failed to ${this.isEditMode ? 'edit' : 'submit'} application. Please try again.`, 'Error!', TOAST_OPTIONS, ); @@ -172,23 +186,13 @@ export default class NewStepperComponent extends Component { } collectApplicationData() { - const stepOneData = JSON.parse( - getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepOne) || '{}', - ); - const stepTwoData = JSON.parse( - getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepTwo) || '{}', - ); - const stepThreeData = JSON.parse( - getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepThree) || '{}', - ); - const stepFourData = JSON.parse( - getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepFour) || '{}', - ); - const stepFiveData = JSON.parse( - getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepFive) || '{}', - ); - - return { + const stepOneData = safeParse(STEP_DATA_STORAGE_KEY.stepOne); + const stepTwoData = safeParse(STEP_DATA_STORAGE_KEY.stepTwo); + const stepThreeData = safeParse(STEP_DATA_STORAGE_KEY.stepThree); + const stepFourData = safeParse(STEP_DATA_STORAGE_KEY.stepFour); + const stepFiveData = safeParse(STEP_DATA_STORAGE_KEY.stepFive); + + const formData = { ...stepOneData, ...stepTwoData, ...stepThreeData, @@ -197,6 +201,55 @@ export default class NewStepperComponent extends Component { role: stepOneData.role ? USER_ROLE_MAP[stepOneData.role] : '', numberOfHours: Number(stepFiveData.numberOfHours) || 0, }; + + if (this.isEditMode && this.onboarding.applicationData) { + return this.getModifiedFields(formData, this.onboarding.applicationData); + } + + return formData; + } + + getModifiedFields(formData, originalApplication) { + const modifiedData = {}; + + const originalValues = { + firstName: originalApplication.biodata?.firstName, + lastName: originalApplication.biodata?.lastName, + city: originalApplication.location?.city, + state: originalApplication.location?.state, + country: originalApplication.location?.country, + institution: originalApplication.professional?.institution, + skills: originalApplication.professional?.skills, + introduction: originalApplication.professional?.introduction, + forFun: originalApplication.intro?.forFun, + funFact: originalApplication.intro?.funFact, + whyRds: originalApplication.intro?.whyRds, + numberOfHours: originalApplication.intro?.numberOfHours, + foundFrom: originalApplication.foundFrom, + role: originalApplication.role, + imageUrl: originalApplication.imageUrl, + }; + Object.entries(originalValues).forEach(([formKey, originalValue]) => { + if (formData[formKey] !== originalValue) { + modifiedData[formKey] = formData[formKey]; + } + }); + + const socialLinkChanges = socialFields.reduce((acc, field) => { + const formValue = formData[field]; + const originalValue = originalApplication.socialLink?.[field]; + + if (formValue && formValue !== originalValue) { + acc[field] = formValue; + } + return acc; + }, {}); + + if (Object.keys(socialLinkChanges).length) { + modifiedData.socialLink = socialLinkChanges; + } + + return modifiedData; } clearAllStepData() { diff --git a/app/constants/apis.js b/app/constants/apis.js index ece7f3ec..668ed29a 100644 --- a/app/constants/apis.js +++ b/app/constants/apis.js @@ -55,6 +55,10 @@ export const APPLICATION_BY_ID_URL = (applicationId) => { export const CREATE_APPLICATION_URL = `${APPS.API_BACKEND}/applications`; +export const UPDATE_APPLICATION_URL = (applicationId) => { + return `${APPS.API_BACKEND}/applications/${applicationId}`; +}; + export const NUDGE_APPLICATION_URL = (applicationId) => { return `${APPS.API_BACKEND}/applications/${applicationId}/nudge`; }; diff --git a/app/constants/applications.js b/app/constants/applications.js index c3fda429..e776a8cb 100644 --- a/app/constants/applications.js +++ b/app/constants/applications.js @@ -23,6 +23,17 @@ export const mapSocialUrls = { behance: 'https://behance.net', }; +export const socialFields = [ + 'phoneNo', + 'twitter', + 'linkedin', + 'instagram', + 'github', + 'peerlist', + 'behance', + 'dribble', +]; + export function adminMessage(status) { switch (status) { case 'pending': diff --git a/app/constants/new-join-form.js b/app/constants/new-join-form.js index cd926676..456ff6ab 100644 --- a/app/constants/new-join-form.js +++ b/app/constants/new-join-form.js @@ -44,7 +44,7 @@ export const NEW_STEP_LIMITS = { }, stepTwo: { skills: { min: 5, max: 20 }, - college: { min: 1 }, + institution: { min: 1 }, introduction: { min: 100, max: 500 }, }, stepThree: { diff --git a/app/constants/urls.js b/app/constants/urls.js index f3fafe3c..38bc6030 100644 --- a/app/constants/urls.js +++ b/app/constants/urls.js @@ -33,7 +33,7 @@ const APP_URLS = { DASHBOARD: `${SCHEME}staging-dashboard.${DOMAIN}`, }, development: { - HOME: '/', + HOME: `${SCHEME}dev.${DOMAIN}`, WELCOME: `${SCHEME}staging-welcome.${DOMAIN}`, GOTO: '/goto', EVENTS: '/events', diff --git a/app/controllers/join.js b/app/controllers/join.js index 55f7a583..6125cbd9 100644 --- a/app/controllers/join.js +++ b/app/controllers/join.js @@ -18,7 +18,7 @@ export default class JoinController extends Controller { ANKUSH_TWITTER = ANKUSH_TWITTER; - queryParams = ['step', 'dev', 'oldOnboarding']; + queryParams = ['step', 'dev', 'oldOnboarding', 'edit']; get isDevMode() { return this.featureFlag.isDevMode; @@ -44,6 +44,10 @@ export default class JoinController extends Controller { return this.login.isLoggedIn && this.login.userData; } + get isEditMode() { + return this.edit === 'true'; + } + @action async handleGenerateChaincode(e) { e.preventDefault(); diff --git a/app/templates/join.hbs b/app/templates/join.hbs index d562b939..c5cdfecc 100644 --- a/app/templates/join.hbs +++ b/app/templates/join.hbs @@ -32,7 +32,7 @@ {{else}} - {{#if this.applicationData}} + {{#if (and this.applicationData (not this.isEditMode))}} {{else}} {{#if this.isDevMode}} - + {{else if this.isOldOnboarding}} {}; + + class OnboardingStub extends Service { + applicationData = null; + } + + class ToastStub extends Service { + success = sinon.stub(); + error = sinon.stub(); + } + + this.owner.register('service:onboarding', OnboardingStub); + this.owner.register('service:toast', ToastStub); + + const onboarding = this.owner.lookup('service:onboarding'); + onboarding.applicationData = APPLICATIONS_DATA; + }); + + hooks.afterEach(async function () { + Object.values(STEP_DATA_STORAGE_KEY).forEach((key) => + localStorage.removeItem(key), + ); + localStorage.removeItem('isValid'); + localStorage.removeItem('currentStep'); + }); + + test('initializeFormState prefers localStorage over applicationData', async function (assert) { + localStorage.setItem( + 'newStepOneData', + JSON.stringify({ + firstName: 'Local', + lastName: 'Storage', + role: 'Designer', + }), + ); + + await render( + hbs``, + ); + + const stepOneData = JSON.parse(localStorage.getItem('newStepOneData')); + assert.strictEqual(stepOneData.firstName, 'Local'); + assert.strictEqual(stepOneData.lastName, 'Storage'); + assert.strictEqual(stepOneData.role, 'Designer'); + assert.notStrictEqual( + stepOneData.firstName, + APPLICATIONS_DATA.biodata.firstName, + ); + }); + + test('initializeFormState loads from applicationData when localStorage empty', async function (assert) { + await render( + hbs``, + ); + + const stepOneData = JSON.parse( + localStorage.getItem(STEP_DATA_STORAGE_KEY.stepOne), + ); + assert.strictEqual( + stepOneData.firstName, + APPLICATIONS_DATA.biodata.firstName, + ); + assert.strictEqual( + stepOneData.lastName, + APPLICATIONS_DATA.biodata.lastName, + ); + assert.strictEqual(stepOneData.city, APPLICATIONS_DATA.location.city); + }); + + test('initializeFormState loads stepTwo data from applicationData', async function (assert) { + await render( + hbs``, + ); + + const stepTwoData = JSON.parse( + localStorage.getItem(STEP_DATA_STORAGE_KEY.stepTwo), + ); + assert.strictEqual( + stepTwoData.institution, + APPLICATIONS_DATA.professional.institution, + ); + assert.strictEqual( + stepTwoData.skills, + APPLICATIONS_DATA.professional.skills, + ); + assert.strictEqual( + stepTwoData.introduction, + APPLICATIONS_DATA.intro.introduction, + ); + }); + + test('initializeFormState loads stepThree data from applicationData', async function (assert) { + await render( + hbs``, + ); + + const stepThreeData = JSON.parse( + localStorage.getItem(STEP_DATA_STORAGE_KEY.stepThree), + ); + assert.strictEqual(stepThreeData.forFun, APPLICATIONS_DATA.intro.forFun); + assert.strictEqual(stepThreeData.funFact, APPLICATIONS_DATA.intro.funFact); + }); + + test('initializeFormState loads stepFour data from applicationData', async function (assert) { + await render( + hbs``, + ); + + const stepFourData = JSON.parse( + localStorage.getItem(STEP_DATA_STORAGE_KEY.stepFour), + ); + assert.strictEqual( + stepFourData.twitter, + APPLICATIONS_DATA.socialLink.twitter, + ); + assert.strictEqual( + stepFourData.instagram, + APPLICATIONS_DATA.socialLink.instagram, + ); + }); + + test('initializeFormState loads stepFive data from applicationData', async function (assert) { + await render( + hbs``, + ); + + const stepFiveData = JSON.parse( + localStorage.getItem(STEP_DATA_STORAGE_KEY.stepFive), + ); + assert.strictEqual(stepFiveData.whyRds, APPLICATIONS_DATA.intro.whyRds); + assert.strictEqual(stepFiveData.foundFrom, APPLICATIONS_DATA.foundFrom); + assert.strictEqual( + stepFiveData.numberOfHours, + APPLICATIONS_DATA.intro.numberOfHours, + ); + }); +}); diff --git a/tests/integration/components/new-stepper-test.js b/tests/integration/components/new-stepper-test.js index 4be21799..0766e54e 100644 --- a/tests/integration/components/new-stepper-test.js +++ b/tests/integration/components/new-stepper-test.js @@ -1,255 +1,365 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'website-www/tests/helpers'; -import { render, click } from '@ember/test-helpers'; +import { render, click, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import sinon from 'sinon'; import Service from '@ember/service'; import { STEP_DATA_STORAGE_KEY } from 'website-www/constants/new-join-form'; - -function clearAllStepData() { - Object.values(STEP_DATA_STORAGE_KEY).forEach((key) => - localStorage.removeItem(key), - ); - localStorage.removeItem('isValid'); - localStorage.removeItem('currentStep'); -} - -function setupFetchStub(response = {}) { - sinon.restore(); - return sinon.stub(window, 'fetch').resolves({ - ok: true, - status: 201, - json: () => Promise.resolve({ application: { id: 'test-id' } }), - ...response, - }); -} +import { + APPLICATIONS_DATA, + NEW_STEPS_APPLICATIONS_DATA, +} from 'website-www/tests/constants/application-data'; module('Integration | Component | new-stepper', function (hooks) { setupRenderingTest(hooks); + function seedFormDataToLocalStorage(formData) { + Object.entries(formData).forEach(([step, data]) => { + localStorage.setItem(STEP_DATA_STORAGE_KEY[step], JSON.stringify(data)); + }); + } + + function clearFormDataFromLocalStorage() { + Object.values(STEP_DATA_STORAGE_KEY).forEach((key) => { + localStorage.removeItem(key); + }); + localStorage.removeItem('isValid'); + localStorage.removeItem('currentStep'); + } + hooks.beforeEach(function () { - this.routerStub = { + this.routerService = { transitionTo: sinon.stub(), replaceWith: sinon.stub(), currentRoute: { queryParams: {} }, }; const testContext = this; - class ToastStub extends Service { + class ToastServiceStub extends Service { constructor(...args) { super(...args); this.success = sinon.stub(); this.error = sinon.stub(); - testContext.toastStub = this; + testContext.toastService = this; } } - this.owner.register('service:router', this.routerStub, { + class OnboardingServiceStub extends Service { + applicationData = null; + } + class JoinApplicationTermsServiceStub extends Service { + hasUserAcceptedTerms = false; + } + + this.owner.register('service:router', this.routerService, { instantiate: false, }); - this.owner.register('service:toast', ToastStub); - - this.mockStepData = { - stepOne: { - firstName: 'John', - lastName: 'Doe', - role: 'Developer', - city: 'San Francisco', - state: 'CA', - country: 'USA', - imageUrl: 'https://example.com/photo.jpg', - }, - stepTwo: { - skills: 'JavaScript, Python', - college: 'Stanford University', - introduction: 'Passionate developer with 5 years experience.', - }, - stepThree: { - forFun: 'I love hiking and photography.', - funFact: 'I have visited 20 countries.', - }, - stepFour: { - phoneNo: '+1 555-123-4567', - twitter: '@johndoe', - github: 'github.com/johndoe', - linkedin: 'linkedin.com/in/johndoe', - instagram: 'instagram.com/johndoe', - }, - stepFive: { - numberOfHours: '20', - whyRDS: 'I want to contribute to meaningful projects.', - anythingElse: 'Looking forward to collaborating!', - }, - }; + this.owner.register('service:toast', ToastServiceStub); + this.owner.register('service:onboarding', OnboardingServiceStub); + this.owner.register( + 'service:joinApplicationTerms', + JoinApplicationTermsServiceStub, + ); - Object.entries(this.mockStepData).forEach(([step, data]) => { - localStorage.setItem(STEP_DATA_STORAGE_KEY[step], JSON.stringify(data)); + seedFormDataToLocalStorage(NEW_STEPS_APPLICATIONS_DATA); + + this.apiStub = sinon.stub(window, 'fetch').resolves({ + ok: true, + status: 201, + json: () => Promise.resolve({ application: { id: 'app-123' } }), }); - this.fetchStub = setupFetchStub(); }); hooks.afterEach(function () { - clearAllStepData(); + clearFormDataFromLocalStorage(); sinon.restore(); }); - test('it renders the welcome screen at step 0', async function (assert) { - await render(hbs``); - - assert.dom('[data-test="stepper"]').exists(); - assert.dom('[data-test="welcome-screen"]').exists(); - assert - .dom('[data-test="welcome-greeting"]') - .hasText('Ready to apply to Real Dev Squad?'); - assert.dom('[data-test-button="start"]').exists(); - }); - - test('start button is disabled when terms are not accepted', async function (assert) { - await render(hbs``); - assert.dom('[data-test-button="start"]').isDisabled(); - }); - - test('start button is enabled when terms are accepted', async function (assert) { - const terms = this.owner.lookup('service:joinApplicationTerms'); - terms.hasUserAcceptedTerms = true; - - await render(hbs``); - assert.dom('[data-test-button="start"]').isNotDisabled(); - }); + module('Edit Application', function (hooks) { + hooks.beforeEach(function () { + const onboardingService = this.owner.lookup('service:onboarding'); + onboardingService.applicationData = APPLICATIONS_DATA; + }); - test('handleSubmit success - submits form data and redirects', async function (assert) { - await render(hbs``); - await click('[data-test-button="submit-review"]'); - - assert.ok(this.fetchStub.calledOnce); - assert.ok(this.fetchStub.firstCall.args[0].includes('/applications')); - assert.strictEqual(this.fetchStub.firstCall.args[1].method, 'POST'); - assert.ok( - this.toastStub.success.calledWith( - 'Application submitted successfully!', - 'Success!', - ), - ); - assert.ok( - this.routerStub.replaceWith.calledWith('join', { - queryParams: { dev: true }, - }), - ); - assert.strictEqual(localStorage.getItem('isValid'), null); - assert.strictEqual(localStorage.getItem('currentStep'), null); - }); + test('uses PATCH method for updating application', async function (assert) { + await render(hbs``); + await click('[data-test-button="submit-review"]'); - test('handleSubmit handles 409 conflict - shows already submitted error', async function (assert) { - setupFetchStub({ - ok: false, - status: 409, - message: 'Application already exists', + assert.strictEqual( + this.apiStub.firstCall.args[1].method, + 'PATCH', + 'Uses PATCH method for editing', + ); }); - await render(hbs``); - await click('[data-test-button="submit-review"]'); + test('calls correct update API endpoint with application ID', async function (assert) { + await render(hbs``); + await click('[data-test-button="submit-review"]'); - assert.ok( - this.toastStub.error.calledWith( - 'You have already submitted an application.', - 'Application Exists!', - ), - ); - assert.notOk(this.routerStub.replaceWith.called); - assert.notOk(this.toastStub.success.called); - }); + assert.ok( + this.apiStub.firstCall.args[0].includes( + `/applications/${APPLICATIONS_DATA.id}`, + ), + 'Correct update endpoint called with application ID', + ); + }); - test('handleSubmit handles API error - shows error toast', async function (assert) { - setupFetchStub({ - ok: false, - status: 500, - message: 'Internal Server Error', + test('displays success toast with edit-specific message', async function (assert) { + await render(hbs``); + await click('[data-test-button="submit-review"]'); + + assert.ok( + this.toastService.success.calledWith( + 'You have successfully edited the application', + 'Success!', + ), + 'Edit success toast displayed', + ); }); - await render(hbs``); - await click('[data-test-button="submit-review"]'); + test('handles 24hr edit restriction with 409 conflict error', async function (assert) { + sinon.restore(); + this.apiStub = sinon.stub(window, 'fetch').resolves({ + ok: false, + status: 409, + message: '24 hour restriction', + }); + + await render(hbs``); + await click('[data-test-button="submit-review"]'); + + assert.ok( + this.toastService.error.calledWith( + 'You will be able to edit after 24 hrs.', + 'Application Exists!', + ), + 'Displays 24hr restriction error toast', + ); + assert.notOk( + this.routerService.replaceWith.called, + 'Does not redirect when edit blocked', + ); + assert.notOk( + this.toastService.success.called, + 'Does not show success toast when edit blocked', + ); + }); - assert.ok(this.toastStub.error.called); - assert.notOk(this.routerStub.replaceWith.called); - }); + test('handles server error in edit mode with edit-specific message', async function (assert) { + sinon.restore(); + this.apiStub = sinon.stub(window, 'fetch').resolves({ + ok: false, + status: 500, + }); + + await render(hbs``); + await click('[data-test-button="submit-review"]'); + + assert.ok( + this.toastService.error.calledWithMatch( + 'Failed to edit application. Please try again.', + ), + 'Edit error toast displayed with edit-specific message', + ); + }); - test('handleSubmit handles network failure - shows error toast', async function (assert) { - sinon.restore(); - sinon.stub(window, 'fetch').rejects(new Error('Network error')); + test('handles network failure in edit mode', async function (assert) { + sinon.restore(); + sinon.stub(window, 'fetch').rejects(new Error('Network error')); - await render(hbs``); - await click('[data-test-button="submit-review"]'); + await render(hbs``); + await click('[data-test-button="submit-review"]'); - assert.ok( - this.toastStub.error.calledWith( - 'Failed to submit application. Please try again.', - 'Error!', - ), - ); - }); + assert.ok( + this.toastService.error.calledWith( + 'Failed to edit application. Please try again.', + 'Error!', + ), + 'Network error toast displayed in edit mode', + ); + }); - test('submit button is enabled before and after submission', async function (assert) { - await render(hbs``); + test('submit button is disabled during edit submission', async function (assert) { + let resolveApi; + const deferredPromise = new Promise((resolve) => { + resolveApi = resolve; + }); + + sinon.restore(); + sinon.stub(window, 'fetch').returns(deferredPromise); + + await render(hbs``); + + const clickPromise = click('[data-test-button="submit-review"]'); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert + .dom('[data-test-button="submit-review"]') + .isDisabled('Submit button disabled during edit submission'); + + resolveApi({ ok: true, status: 201, json: () => Promise.resolve({}) }); + await clickPromise; + await settled(); + assert + .dom('[data-test-button="submit-review"]') + .isNotDisabled('Submit button enabled after edit completes'); + }); - assert.dom('[data-test-button="submit-review"]').isNotDisabled(); - await click('[data-test-button="submit-review"]'); - assert.dom('[data-test-button="submit-review"]').isNotDisabled(); - }); + test('redirects to join page after successful edit', async function (assert) { + await render(hbs``); + await click('[data-test-button="submit-review"]'); - test('collectApplicationData merges all step data correctly', async function (assert) { - await render(hbs``); - await click('[data-test-button="submit-review"]'); - - const submittedData = JSON.parse(this.fetchStub.firstCall.args[1].body); - - assert.strictEqual(submittedData.firstName, 'John'); - assert.strictEqual(submittedData.lastName, 'Doe'); - assert.strictEqual(submittedData.role, 'developer'); - assert.strictEqual(submittedData.city, 'San Francisco'); - assert.strictEqual(submittedData.college, 'Stanford University'); - assert.strictEqual(submittedData.skills, 'JavaScript, Python'); - assert.strictEqual(submittedData.forFun, 'I love hiking and photography.'); - assert.deepEqual(submittedData.socialLink, this.mockStepData.stepFour); - assert.strictEqual(submittedData.numberOfHours, 20); + assert.ok( + this.routerService.replaceWith.calledWith('join', { + queryParams: { dev: true }, + }), + 'Redirects to join page after successful edit', + ); + }); }); - test('collectApplicationData handles empty step data gracefully', async function (assert) { - Object.values(STEP_DATA_STORAGE_KEY).forEach((key) => - localStorage.setItem(key, '{}'), - ); - - await render(hbs``); - await click('[data-test-button="submit-review"]'); + module('Data Collection and Transformation', function () { + test('collects and transforms all form data correctly', async function (assert) { + assert.expect(6); + await render(hbs``); + await click('[data-test-button="submit-review"]'); + + const submittedData = JSON.parse(this.apiStub.firstCall.args[1].body); + + assert.strictEqual( + submittedData.firstName, + NEW_STEPS_APPLICATIONS_DATA.stepOne.firstName, + 'First name collected correctly', + ); + assert.strictEqual( + submittedData.role, + NEW_STEPS_APPLICATIONS_DATA.stepOne.role.toLowerCase(), + 'Role transformed to lowercase format', + ); + assert.strictEqual( + submittedData.city, + NEW_STEPS_APPLICATIONS_DATA.stepOne.city, + 'City collected correctly', + ); + assert.strictEqual( + submittedData.country, + NEW_STEPS_APPLICATIONS_DATA.stepOne.country, + 'Country collected correctly', + ); + assert.strictEqual( + submittedData.institution, + NEW_STEPS_APPLICATIONS_DATA.stepTwo.institution, + 'Institution collected correctly', + ); + assert.strictEqual( + submittedData.skills, + NEW_STEPS_APPLICATIONS_DATA.stepTwo.skills, + 'Skills collected correctly', + ); + }); - const submittedData = JSON.parse(this.fetchStub.firstCall.args[1].body); + test('groups social links under socialLink property', async function (assert) { + await render(hbs``); + await click('[data-test-button="submit-review"]'); + + const submittedData = JSON.parse(this.apiStub.firstCall.args[1].body); + + assert.deepEqual( + submittedData.socialLink, + NEW_STEPS_APPLICATIONS_DATA.stepFour, + 'Social links grouped correctly', + ); + assert.strictEqual( + submittedData.socialLink.github, + NEW_STEPS_APPLICATIONS_DATA.stepFour.github, + 'GitHub link correct', + ); + assert.strictEqual( + submittedData.socialLink.linkedin, + NEW_STEPS_APPLICATIONS_DATA.stepFour.linkedin, + 'LinkedIn link correct', + ); + }); - assert.notOk(submittedData.role); - assert.strictEqual(submittedData.numberOfHours, 0); + test('handles empty form data gracefully', async function (assert) { + clearFormDataFromLocalStorage(); + Object.values(STEP_DATA_STORAGE_KEY).forEach((key) => { + localStorage.setItem(key, '{}'); + }); + + await render(hbs``); + await click('[data-test-button="submit-review"]'); + + const submittedData = JSON.parse(this.apiStub.firstCall.args[1].body); + + assert.notOk(submittedData.role, 'Empty role handled correctly'); + assert.strictEqual( + submittedData.numberOfHours, + 0, + 'Empty hours defaults to 0', + ); + assert.ok( + submittedData.socialLink, + 'Social link object exists even when empty', + ); + }); }); - test('clearAllStepData removes all step storage keys', async function (assert) { - assert.expect(5); + module('LocalStorage Management', function () { + test('clears all step data from localStorage after successful submission', async function (assert) { + assert.expect(5); - Object.values(STEP_DATA_STORAGE_KEY).forEach((key) => - localStorage.setItem(key, JSON.stringify({ test: 'data' })), - ); + Object.values(STEP_DATA_STORAGE_KEY).forEach((key) => { + localStorage.setItem(key, JSON.stringify({ test: 'data' })); + }); - await render(hbs``); - await click('[data-test-button="submit-review"]'); + await render(hbs``); + await click('[data-test-button="submit-review"]'); - Object.values(STEP_DATA_STORAGE_KEY).forEach((key) => { - assert.strictEqual(localStorage.getItem(key), null); + Object.values(STEP_DATA_STORAGE_KEY).forEach((key) => { + assert.strictEqual( + localStorage.getItem(key), + null, + `${key} removed from localStorage`, + ); + }); }); - }); - test('clearAllStepData clears isValid and currentStep', async function (assert) { - localStorage.setItem('isValid', 'true'); - localStorage.setItem('currentStep', '6'); - - await render(hbs``); - await click('[data-test-button="submit-review"]'); + test('clears validation and step tracking from localStorage', async function (assert) { + localStorage.setItem('isValid', 'true'); + localStorage.setItem('currentStep', '6'); + + await render(hbs``); + await click('[data-test-button="submit-review"]'); + + assert.strictEqual( + localStorage.getItem('isValid'), + null, + 'isValid removed', + ); + assert.strictEqual( + localStorage.getItem('currentStep'), + null, + 'currentStep removed', + ); + }); - assert.strictEqual(localStorage.getItem('isValid'), null); - assert.strictEqual(localStorage.getItem('currentStep'), null); + test('does not clear localStorage when submission fails', async function (assert) { + sinon.restore(); + this.apiStub = sinon.stub(window, 'fetch').resolves({ + ok: false, + status: 500, + message: 'Error', + }); + localStorage.setItem('isValid', 'true'); + + await render(hbs``); + await click('[data-test-button="submit-review"]'); + + assert.strictEqual( + localStorage.getItem('isValid'), + 'true', + 'localStorage preserved on error', + ); + }); }); }); diff --git a/tests/unit/controllers/join-test.js b/tests/unit/controllers/join-test.js index 60a612c4..27827384 100644 --- a/tests/unit/controllers/join-test.js +++ b/tests/unit/controllers/join-test.js @@ -21,7 +21,12 @@ module('Unit | Controller | join', function (hooks) { test('it has queryParams', function (assert) { let controller = this.owner.lookup('controller:join'); - assert.deepEqual(controller.queryParams, ['step', 'dev', 'oldOnboarding']); + assert.deepEqual(controller.queryParams, [ + 'step', + 'dev', + 'oldOnboarding', + 'edit', + ]); }); test('isOldOnboarding returns values correctly as per oldOnboarding query param', function (assert) {