diff --git a/app/components/application/detail-header.hbs b/app/components/application/detail-header.hbs index 34849825e..c0f603ff6 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 b7df3dde6..04363fccc 100644 --- a/app/components/application/detail-header.js +++ b/app/components/application/detail-header.js @@ -1,7 +1,29 @@ 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'; + +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; get application() { return this.args.application; } @@ -41,20 +63,29 @@ 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 lastEditAt() { + return this.application?.lastEditAt ?? null; } get isNudgeDisabled() { - if (this.status !== 'pending') { + if (this.isLoading || this.status !== 'pending') { return true; } - if (!this.application?.lastNudgedAt) { - return false; + return isWithinCooldown(this.lastNudgeAt); + } + + get isEditDisabled() { + if (this.isLoading) { + return true; } - const now = Date.now(); - const lastNudgeTime = new Date(this.application.lastNudgedAt).getTime(); - const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; - return now - lastNudgeTime < TWENTY_FOUR_HOURS; + return isWithinCooldown(this.lastEditAt); } get socialLinks() { @@ -80,20 +111,54 @@ 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 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/join-steps/status-card.hbs b/app/components/join-steps/status-card.hbs index 6e9f32203..9970dfb5c 100644 --- a/app/components/join-steps/status-card.hbs +++ b/app/components/join-steps/status-card.hbs @@ -67,15 +67,25 @@

Here is your personalized link

- - Copy - + +
+ + Copy + + +
{{/if}} diff --git a/app/components/join-steps/status-card.js b/app/components/join-steps/status-card.js index 4a3b71c9f..54aee57f9 100644 --- a/app/components/join-steps/status-card.js +++ b/app/components/join-steps/status-card.js @@ -62,6 +62,10 @@ export default class StatusCardComponent extends Component { return USER_JOINED_LINK(this.login.userData.id); } + get applicationId() { + return this.onboarding.applicationData?.id; + } + @action async fetchStatus() { await this.onboarding.getApplicationDetails(); @@ -96,4 +100,14 @@ export default class StatusCardComponent extends Component { onError() { this.toast.error('Error in copying to clipboard', 'Error!', TOAST_OPTIONS); } + + @action + trackApplication() { + if (this.applicationId) { + // ToDo: remove dev=true once feature flag is removed from application detail apge + this.router.transitionTo('applications.detail', this.applicationId, { + queryParams: { dev: true }, + }); + } + } } diff --git a/app/components/new-join-steps/base-step.js b/app/components/new-join-steps/base-step.js index 6db506a57..f691f1ef1 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 } from 'ember-lifeline'; +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) => ({ + phoneNumber: app.socialLink?.phoneNumber || '', + 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, '']), @@ -46,7 +92,7 @@ export default class BaseStepComponent extends Component { }), ); - this.postLoadInitialize(); + runTask(this, 'postLoadInitialize'); const valid = this.isDataValid(); this.args.setIsPreValid(valid); diff --git a/app/components/new-join-steps/new-step-five.js b/app/components/new-join-steps/new-step-five.js index 71a353264..e829f7618 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 bae007588..2e8003d0f 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 = { phoneNumber: NEW_STEP_LIMITS.stepFour.phoneNumber, diff --git a/app/components/new-join-steps/new-step-one.hbs b/app/components/new-join-steps/new-step-one.hbs index 29de72c94..8625b37aa 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}} @@ -13,6 +12,7 @@ src={{this.imagePreview}} alt="Profile preview" class="image-preview" + data-test-image-preview /> diff --git a/app/components/new-join-steps/new-step-one.js b/app/components/new-join-steps/new-step-one.js index fdf2742e1..b0bfd73f7 100644 --- a/app/components/new-join-steps/new-step-one.js +++ b/app/components/new-join-steps/new-step-one.js @@ -7,6 +7,8 @@ import { ROLE_OPTIONS, STEP_DATA_STORAGE_KEY, } from '../../constants/new-join-form'; +import { USER_PROFILE_IMAGE_URL } from '../../constants/apis'; +import { TOAST_OPTIONS } from '../../constants/toast-options'; import BaseStepComponent from './base-step'; export default class NewStepOneComponent extends BaseStepComponent { @@ -31,19 +33,24 @@ export default class NewStepOneComponent extends BaseStepComponent { role: NEW_STEP_LIMITS.stepOne.role, }; + get fullName() { + const firstName = this.data.firstName || ''; + const lastName = this.data.lastName || ''; + return `${firstName} ${lastName}`.trim(); + } + postLoadInitialize() { if ( - !this.data.fullName && + !this.data.firstName && + !this.data.lastName && this.login.userData?.first_name && this.login.userData?.last_name ) { - this.updateFieldValue( - 'fullName', - `${this.login.userData.first_name} ${this.login.userData.last_name}`, - ); + this.updateFieldValue('firstName', this.login.userData.first_name); + this.updateFieldValue('lastName', this.login.userData.last_name); } - if (this.data.profileImageBase64) { - this.imagePreview = this.data.profileImageBase64; + if (this.data.imageUrl) { + this.imagePreview = this.data.imageUrl; } } @@ -67,7 +74,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( @@ -84,21 +91,50 @@ 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?.('profileImageBase64', base64String); - this.isImageUploading = false; - }; - reader.onerror = () => { + try { + const formData = new FormData(); + formData.append('type', 'application'); + formData.append('profile', file); + + 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?.image?.url || data.picture; + + 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!', + 'Success!', + TOAST_OPTIONS, + ); + } else { + const errorData = await response.json(); + this.toast.error( + errorData.message || 'Failed to upload image. Please try again.', + 'Error!', + TOAST_OPTIONS, + ); + } + } catch (error) { this.toast.error( - 'Failed to read the selected file. Please try again.', + error.message || 'Failed to upload image. Please try again.', 'Error!', ); + } finally { this.isImageUploading = false; - }; - - reader.readAsDataURL(file); + } } } diff --git a/app/components/new-join-steps/new-step-six.hbs b/app/components/new-join-steps/new-step-six.hbs index 17d2b4c2a..e44573d97 100644 --- a/app/components/new-join-steps/new-step-six.hbs +++ b/app/components/new-join-steps/new-step-six.hbs @@ -20,16 +20,9 @@ Full Name: - {{if - this.stepData.one.fullName - this.stepData.one.fullName - "Not provided" - }} + {{if this.fullName this.fullName "Not provided"}}
@@ -52,9 +45,19 @@
Profile Image: - - Not uploaded - + {{#if this.hasProfileImage}} +
+ Profile preview +
+ {{else}} + + Not uploaded + + {{/if}}
@@ -88,13 +91,16 @@ Institution/Company: - {{if - this.stepData.two.company - this.stepData.two.company - "Not provided" - }} + {{#if this.stepData.two.institution}} + {{this.stepData.two.institution}} + {{else}} + Not provided + {{/if}}
@@ -129,19 +135,19 @@
- Hobbies: + For fun: - {{if - this.stepData.three.hobbies - this.stepData.three.hobbies - "Not provided" - }} + {{#if this.stepData.three.forFun}} + {{this.stepData.three.forFun}} + {{else}} + Not provided + {{/if}}
diff --git a/app/components/new-join-steps/new-step-six.js b/app/components/new-join-steps/new-step-six.js index 9928e6631..7c8cef104 100644 --- a/app/components/new-join-steps/new-step-six.js +++ b/app/components/new-join-steps/new-step-six.js @@ -1,7 +1,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { getLocalStorageItem } from '../../utils/storage'; import { STEP_DATA_STORAGE_KEY } from '../../constants/new-join-form'; +import { safeParse } from '../../utils/storage'; export default class NewStepSixComponent extends Component { @tracked stepData = { @@ -18,21 +18,17 @@ export default class NewStepSixComponent extends Component { } loadAllStepData() { - this.stepData.one = JSON.parse( - getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepOne), - ); - this.stepData.two = JSON.parse( - getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepTwo), - ); - this.stepData.three = JSON.parse( - getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepThree), - ); - this.stepData.four = JSON.parse( - getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepFour), - ); - this.stepData.five = JSON.parse( - getLocalStorageItem(STEP_DATA_STORAGE_KEY.stepFive), - ); + this.stepData.one = safeParse(STEP_DATA_STORAGE_KEY.stepOne); + this.stepData.two = safeParse(STEP_DATA_STORAGE_KEY.stepTwo); + this.stepData.three = safeParse(STEP_DATA_STORAGE_KEY.stepThree); + this.stepData.four = safeParse(STEP_DATA_STORAGE_KEY.stepFour); + this.stepData.five = safeParse(STEP_DATA_STORAGE_KEY.stepFive); + } + + get fullName() { + const firstName = this.stepData.one.firstName || ''; + const lastName = this.stepData.one.lastName || ''; + return `${firstName} ${lastName}`.trim(); } get userRole() { @@ -54,4 +50,12 @@ export default class NewStepSixComponent extends Component { get locationDisplay() { return `${this.stepData.one.city}, ${this.stepData.one.state}, ${this.stepData.one.country}`; } + + get profileImage() { + return this.stepData.one.imageUrl || null; + } + + get hasProfileImage() { + return !!this.profileImage; + } } diff --git a/app/components/new-join-steps/new-step-three.hbs b/app/components/new-join-steps/new-step-three.hbs index 14ad2c677..141efdf4c 100644 --- a/app/components/new-join-steps/new-step-three.hbs +++ b/app/components/new-join-steps/new-step-three.hbs @@ -8,22 +8,22 @@
{{this.wordCount.hobbies}}/{{this.stepValidation.hobbies.max}} + data-test-word-count="forFun" + >{{this.wordCount.forFun}}/{{this.stepValidation.forFun.max}} words
- {{#if this.errorMessage.hobbies}} + {{#if this.errorMessage.forFun}}
{{this.errorMessage.hobbies}}
+ data-test-error="forFun" + >{{this.errorMessage.forFun}}
{{/if}}
diff --git a/app/components/new-join-steps/new-step-three.js b/app/components/new-join-steps/new-step-three.js index 0c3fd6b25..6aaba6443 100644 --- a/app/components/new-join-steps/new-step-three.js +++ b/app/components/new-join-steps/new-step-three.js @@ -5,9 +5,11 @@ 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 = { - hobbies: NEW_STEP_LIMITS.stepThree.hobbies, + 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 d0113acd3..91512ce7d 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.company}} + {{#if this.errorMessage.institution}}
{{this.errorMessage.company}}
+ 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 edb382940..c7b61423d 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, - company: NEW_STEP_LIMITS.stepTwo.company, + institution: NEW_STEP_LIMITS.stepTwo.institution, introduction: NEW_STEP_LIMITS.stepTwo.introduction, }; } diff --git a/app/components/new-join-steps/thank-you-screen.hbs b/app/components/new-join-steps/thank-you-screen.hbs deleted file mode 100644 index 071a5742f..000000000 --- a/app/components/new-join-steps/thank-you-screen.hbs +++ /dev/null @@ -1,41 +0,0 @@ -
- - -
-

{{@firstName}}, thank you for applying to RDS.

-

Great work filling up the application. - However, it takes more to join us early.

-
- -
-
- Head over to Application Tracking Page. -
- -
- Checkout AI review and and edit your application to improve application - rank. -
- -
- Complete quests to improve your ranking and increase your chances of early - reviews. -
-
- -
-

Application ID

-

{{@applicationId}}

-
- -
- -
-
\ No newline at end of file diff --git a/app/components/new-signup/checkbox.hbs b/app/components/new-signup/checkbox.hbs index dd74fb320..a6691922b 100644 --- a/app/components/new-signup/checkbox.hbs +++ b/app/components/new-signup/checkbox.hbs @@ -10,10 +10,11 @@ data-test-checkbox-label={{data.name}} > diff --git a/app/components/new-signup/checkbox.js b/app/components/new-signup/checkbox.js index e3ed7ac8d..b28c0eadc 100644 --- a/app/components/new-signup/checkbox.js +++ b/app/components/new-signup/checkbox.js @@ -13,10 +13,8 @@ export default class SignupComponent extends Component { return LABEL_TEXT[currentStep]; } - @action checkboxFieldChanged(event) { + @action checkboxFieldChanged(selectedRole, event) { const { onChange } = this.args; - const roleKey = event.target.name; - const value = event.target.checked; - onChange(roleKey, value); + onChange(selectedRole, event.target.checked); } } diff --git a/app/components/new-stepper.hbs b/app/components/new-stepper.hbs index 83a9da8fc..7d7851c54 100644 --- a/app/components/new-stepper.hbs +++ b/app/components/new-stepper.hbs @@ -1,7 +1,5 @@
- {{#if - (and (not-eq this.currentStep this.MIN_STEP) (not-eq this.currentStep 7)) - }} + {{#if (not-eq this.currentStep this.MIN_STEP)}} - - {{else if (eq this.currentStep 7)}} - {{/if}} - {{#if - (and (not-eq this.currentStep this.MIN_STEP) (not-eq this.currentStep 7)) - }} + {{#if (not-eq this.currentStep this.MIN_STEP)}}
{{#if this.showPreviousButton}}
{{/if}} diff --git a/app/components/new-stepper.js b/app/components/new-stepper.js index ec133bc9c..9032c13a9 100644 --- a/app/components/new-stepper.js +++ b/app/components/new-stepper.js @@ -1,22 +1,38 @@ -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { service } from '@ember/service'; -import { NEW_FORM_STEPS } from '../constants/new-join-form'; -import { getLocalStorageItem, setLocalStorageItem } from '../utils/storage'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +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 { 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; MAX_STEP = 6; - applicationId = '4gchuf690'; @service login; @service router; @service onboarding; @service joinApplicationTerms; + @service toast; @tracked preValid = false; @tracked isValid = getLocalStorageItem('isValid') === 'true'; + @tracked isSubmitting = false; @tracked currentStep = 0; @@ -45,6 +61,10 @@ export default class NewStepperComponent extends Component { }); } + get isEditMode() { + return this.args.isEditMode; + } + get showPreviousButton() { return this.currentStep > this.MIN_STEP + 1; } @@ -105,11 +125,138 @@ export default class NewStepperComponent extends Component { } } - @action handleSubmit() { - // ToDo: handle create application - console.log('Submit application for review'); - this.currentStep = this.MAX_STEP + 1; - setLocalStorageItem('currentStep', String(this.currentStep)); - this.updateQueryParam(this.currentStep); + @action async handleSubmit() { + 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 apiRequest(url, method, applicationData); + + if (response.status === 409) { + this.toast.error( + this.isEditMode + ? 'You will be able to edit after 24 hrs.' + : 'You have already submitted an application.', + 'Application Exists!', + TOAST_OPTIONS, + ); + this.isSubmitting = false; + return; + } + + if (!response.ok) { + this.toast.error( + response.message || + `Failed to ${this.isEditMode ? 'edit' : 'submit'} application. Please try again.`, + 'Error!', + TOAST_OPTIONS, + ); + this.isSubmitting = false; + return; + } + + await response.json(); + + this.toast.success( + this.isEditMode + ? 'You have successfully edited the application' + : 'Application submitted successfully!', + 'Success!', + TOAST_OPTIONS, + ); + + this.clearAllStepData(); + this.isSubmitting = false; + this.router.replaceWith('join', { + queryParams: { dev: true }, + }); + } catch (error) { + console.error('Error submitting application:', error); + this.toast.error( + `Failed to ${this.isEditMode ? 'edit' : 'submit'} application. Please try again.`, + 'Error!', + TOAST_OPTIONS, + ); + this.isSubmitting = false; + } + } + + collectApplicationData() { + 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, + ...stepFiveData, + socialLink: { ...stepFourData }, + 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() { + Object.values(STEP_DATA_STORAGE_KEY).forEach((key) => { + localStorage.removeItem(key); + }); + localStorage.removeItem('isValid'); + localStorage.removeItem('currentStep'); } } diff --git a/app/components/profile/image-cropper.js b/app/components/profile/image-cropper.js index 686fbaa5b..d9436c71f 100644 --- a/app/components/profile/image-cropper.js +++ b/app/components/profile/image-cropper.js @@ -12,6 +12,9 @@ export default class ImageCropperComponent extends Component { @action loadCropper() { const image = document.getElementById('image-cropper'); + if (!image) { + return; + } this.cropper = new Cropper(image, { autoCrop: true, viewMode: 1, diff --git a/app/constants/apis.js b/app/constants/apis.js index 82b1611b9..281bd00d9 100644 --- a/app/constants/apis.js +++ b/app/constants/apis.js @@ -18,6 +18,10 @@ export const APPLICATION_ID_LINK = (id) => { return `${APPS.DASHBOARD}/applications/?id=${id}`; }; +export const SELF_PROFILE_UPDATE_URL = (userId, devFlag) => { + return `${APPS.API_BACKEND}/users/${userId}?profile=true&dev=${devFlag}`; +}; + export const GENERATE_USERNAME_URL = ( sanitizedFirstname, sanitizedLastname, @@ -29,8 +33,6 @@ export const CHECK_USERNAME_AVAILABILITY = (userName) => { return `${APPS.API_BACKEND}/users/isUsernameAvailable/${userName}`; }; -export const SELF_USERS_URL = `${APPS.API_BACKEND}/users/self`; - export const SELF_USER_STATUS_URL = `${APPS.API_BACKEND}/users/status/self`; export const UPDATE_USER_STATUS = `${APPS.API_BACKEND}/users/status/self?userStatusFlag=true`; @@ -50,3 +52,19 @@ export const APPLICATIONS_URL = (size = 6) => { export const APPLICATION_BY_ID_URL = (applicationId) => { return `${APPS.API_BACKEND}/applications/${applicationId}?dev=true`; }; + +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`; +}; + +export const APPLICATIONS_BY_USER_URL = (userId) => { + return `${APPS.API_BACKEND}/applications?userId=${userId}&dev=true`; +}; + +export const USER_PROFILE_IMAGE_URL = `${APPS.API_BACKEND}/users/picture`; diff --git a/app/constants/applications.js b/app/constants/applications.js index c3fda4297..6006cef9c 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 = [ + 'phoneNumber', + '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 0cfb9fb63..004f7bde6 100644 --- a/app/constants/new-join-form.js +++ b/app/constants/new-join-form.js @@ -26,6 +26,15 @@ export const ROLE_OPTIONS = [ 'Social Media', ]; +export const USER_ROLE_MAP = { + Developer: 'developer', + Designer: 'designer', + 'Product Manager': 'product_manager', + 'Project Manager': 'project_manager', + QA: 'qa', + 'Social Media': 'social_media', +}; + export const NEW_STEP_LIMITS = { stepOne: { country: { min: 1, type: 'dropdown' }, @@ -35,11 +44,11 @@ export const NEW_STEP_LIMITS = { }, stepTwo: { skills: { min: 5, max: 20 }, - company: { min: 1 }, + institution: { min: 1 }, introduction: { min: 100, max: 500 }, }, stepThree: { - hobbies: { min: 100, max: 500 }, + forFun: { min: 100, max: 500 }, funFact: { min: 100, max: 500 }, }, stepFour: { diff --git a/app/constants/new-signup.js b/app/constants/new-signup.js index b157553a5..65aa2405a 100644 --- a/app/constants/new-signup.js +++ b/app/constants/new-signup.js @@ -1,7 +1,6 @@ const GET_STARTED = 'get-started'; const FIRST_NAME = 'firstName'; const LAST_NAME = 'lastName'; -const USERNAME = 'username'; const ROLE = 'role'; const THANK_YOU = 'thank-you'; @@ -9,7 +8,6 @@ export const NEW_SIGNUP_STEPS = [ GET_STARTED, FIRST_NAME, LAST_NAME, - USERNAME, ROLE, THANK_YOU, ]; @@ -44,9 +42,21 @@ export const CHECK_BOX_DATA = [ label: 'Maven', name: 'maven', }, + { + label: 'Social Media', + name: 'social_media', + }, { label: 'Product Manager', - name: 'productmanager', + name: 'product_manager', + }, + { + label: 'QA', + name: 'qa', + }, + { + label: 'Project Manager', + name: 'project_manager', }, ]; diff --git a/app/constants/urls.js b/app/constants/urls.js index f3fafe3c5..48d8f7d83 100644 --- a/app/constants/urls.js +++ b/app/constants/urls.js @@ -17,6 +17,7 @@ const APP_URLS = { MY_STATUS: `${SCHEME}${DOMAIN}/status`, API_BACKEND: `${SCHEME}api.${DOMAIN}`, DASHBOARD: `${SCHEME}dashboard.${DOMAIN}`, + SIGN_UP: `${SCHEME}${DOMAIN}/new-signup`, }, staging: { HOME: `${SCHEME}staging-www.${DOMAIN}`, @@ -31,9 +32,10 @@ const APP_URLS = { MY_STATUS: `${SCHEME}staging-www.${DOMAIN}/status`, API_BACKEND: `${SCHEME}staging-api.${DOMAIN}`, DASHBOARD: `${SCHEME}staging-dashboard.${DOMAIN}`, + SIGN_UP: `${SCHEME}staging-www.${DOMAIN}/new-signup`, }, development: { - HOME: '/', + HOME: `${SCHEME}dev.${DOMAIN}`, WELCOME: `${SCHEME}staging-welcome.${DOMAIN}`, GOTO: '/goto', EVENTS: '/events', @@ -45,6 +47,7 @@ const APP_URLS = { MY_STATUS: '/status', DASHBOARD: `${SCHEME}staging-dashboard.${DOMAIN}`, API_BACKEND: `${SCHEME}staging-api.${DOMAIN}`, + SIGN_UP: `${SCHEME}dev.${DOMAIN}/new-signup`, }, test: { HOME: `${SCHEME}${DOMAIN}`, @@ -59,6 +62,7 @@ const APP_URLS = { MY_STATUS: `${SCHEME}${DOMAIN}/status`, API_BACKEND: `${SCHEME}staging-api.${DOMAIN}`, DASHBOARD: `${SCHEME}staging-dashboard.${DOMAIN}`, + SIGN_UP: '/new-signup', }, }; @@ -73,7 +77,7 @@ export const ABOUT = { export const AUTH = { GITHUB_SIGN_IN: `${APPS.API_BACKEND}/auth/github/login`, GOOGLE_SIGN_IN: `${APPS.API_BACKEND}/auth/google/login?dev=true`, - SIGN_UP: `${APPS.HOME}/new-signup`, + SIGN_UP: APPS.SIGN_UP, }; export const SOCIALS = { diff --git a/app/controllers/applications/detail.js b/app/controllers/applications/detail.js index 9581f34bb..58efe28f5 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/controllers/join.js b/app/controllers/join.js index 55f7a5832..6125cbd9c 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/controllers/new-signup.js b/app/controllers/new-signup.js index 9caa97b77..7383dde34 100644 --- a/app/controllers/new-signup.js +++ b/app/controllers/new-signup.js @@ -8,7 +8,7 @@ import { CHECK_USERNAME_AVAILABILITY, GENERATE_USERNAME_URL, SELF_USER_PROFILE_URL, - SELF_USERS_URL, + SELF_PROFILE_UPDATE_URL, } from '../constants/apis'; import { SIGNUP_ERROR_MESSAGES, @@ -30,14 +30,12 @@ export default class NewSignupController extends Controller { SECOND_STEP = NEW_SIGNUP_STEPS[1]; THIRD_STEP = NEW_SIGNUP_STEPS[2]; FOURTH_STEP = NEW_SIGNUP_STEPS[3]; - FIFTH_STEP = NEW_SIGNUP_STEPS[4]; - LAST_STEP = NEW_SIGNUP_STEPS[5]; + LAST_STEP = NEW_SIGNUP_STEPS[4]; @tracked signupDetails = { firstName: '', lastName: '', - username: '', - roles: {}, + role: '', }; get isDevMode() { @@ -80,22 +78,11 @@ export default class NewSignupController extends Controller { } } - async registerUser(user) { - return await apiRequest(SELF_USERS_URL, 'PATCH', user); - } - - async newRegisterUser(signupDetails, roles) { + async registerUser(signupDetails, devFlag) { const getResponse = await apiRequest(SELF_USER_PROFILE_URL); const userData = await getResponse.json(); - - const res = await this.registerUser({ - ...signupDetails, - roles: { - ...userData.roles, - ...roles, - }, - }); - + const url = SELF_PROFILE_UPDATE_URL(userData?.id, devFlag); + const res = await apiRequest(url, 'PATCH', signupDetails); if (!res) { throw new Error(SIGNUP_ERROR_MESSAGES.others); } @@ -132,48 +119,40 @@ export default class NewSignupController extends Controller { else this.isButtonDisabled = true; } - @action handleCheckboxInputChange(key, value) { - set(this.signupDetails.roles, key, value); - if (Object.values(this.signupDetails.roles).includes(true)) { - this.isButtonDisabled = false; - } else { - this.isButtonDisabled = true; - } + @action handleRoleSelection(selectedRole) { + this.signupDetails.role = selectedRole; + this.isButtonDisabled = !selectedRole; } @action async signup() { try { let username; + const { firstName, lastName, role } = this.signupDetails; this.isLoading = true; - if (!this.isDevMode) - username = await this.generateUsername( - this.signupDetails.firstName, - this.signupDetails.lastName, - ); - const signupDetails = { - first_name: this.signupDetails.firstName, - last_name: this.signupDetails.lastName, - username: this.isDevMode ? this.signupDetails.username : username, - }; - const roles = {}; - Object.entries(this.signupDetails.roles).forEach(([key, value]) => { - if (value === true) { - roles[key] = value; - } - }); - const isUsernameAvailable = await this.checkUserName( - signupDetails.username, - ); - if (!isUsernameAvailable) { - this.isLoading = false; - this.isButtonDisabled = false; - return (this.error = SIGNUP_ERROR_MESSAGES.userName); + if (!this.isDevMode) { + username = await this.generateUsername(firstName, lastName); + + const isUsernameAvailable = await this.checkUserName(username); + + if (!isUsernameAvailable) { + this.isLoading = false; + this.isButtonDisabled = false; + return (this.error = SIGNUP_ERROR_MESSAGES.userName); + } } - const res = this.isDevMode - ? await this.newRegisterUser(signupDetails, roles) - : await this.registerUser(signupDetails); + const basePayload = { + first_name: firstName, + last_name: lastName, + }; + + const signupDetails = this.isDevMode + ? { ...basePayload, role } + : { ...basePayload, username }; + + const res = await this.registerUser(signupDetails, this.isDevMode); + if (res?.status === 204) { this.currentStep = this.LAST_STEP; } else { diff --git a/app/routes/applications/detail.js b/app/routes/applications/detail.js index eac84ecee..a2fcb3c1f 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/services/login.js b/app/services/login.js index ded53ded5..e868c45e3 100644 --- a/app/services/login.js +++ b/app/services/login.js @@ -1,6 +1,6 @@ import Service, { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; -import { APPS, AUTH } from '../constants/urls'; +import { APPS } from '../constants/urls'; export default class LoginService extends Service { @service store; @@ -9,6 +9,7 @@ export default class LoginService extends Service { @tracked isLoading = true; @service fastboot; @service featureFlag; + @service router; HeadersToCopy = ['Host', 'Cookie', 'User-Agent']; @@ -31,8 +32,13 @@ export default class LoginService extends Service { throw response; }) .then((user) => { - if (user.incompleteUserDetails && !this.featureFlag.isDevMode) - window.location.replace(AUTH.SIGN_UP); + if ( + user.incompleteUserDetails && + !this.featureFlag.isDevMode && + this.router.currentRoute?.name !== 'new-signup' + ) { + this.router.replaceWith('/new-signup'); + } this.isLoggedIn = true; this.userData = user; }) diff --git a/app/styles/new-signup.module.css b/app/styles/new-signup.module.css index 9cbc1eeb5..c03c65bf7 100644 --- a/app/styles/new-signup.module.css +++ b/app/styles/new-signup.module.css @@ -1,6 +1,7 @@ .user-details__container { margin: 0; padding: 0; + padding-block: 1rem; display: flex; flex-direction: column; width: 100%; diff --git a/app/styles/new-stepper.module.css b/app/styles/new-stepper.module.css index ff57188c7..8e9b22aae 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; @@ -401,6 +409,10 @@ color: var(--color-lightgrey); } +.review-field__image-preview { + width: 60%; +} + .review-actions { display: flex; justify-content: center; diff --git a/app/styles/status-card.module.css b/app/styles/status-card.module.css index 09bb540e4..cf5588caf 100644 --- a/app/styles/status-card.module.css +++ b/app/styles/status-card.module.css @@ -50,4 +50,12 @@ .status-card__buttons { margin-top: 20px; + display: flex; + gap: 1rem; +} + +@media (width <= 425px) { + .status-card__buttons { + flex-direction: column; + } } diff --git a/app/templates/applications/detail.hbs b/app/templates/applications/detail.hbs index 0a88f7023..214f0c591 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/app/templates/join.hbs b/app/templates/join.hbs index d562b939d..c5cdfecc0 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}} {{else if (and this.isDevMode (eq step this.FOURTH_STEP))}} - - {{else if (and this.isDevMode (eq step this.FIFTH_STEP))}} { + try { + const value = getLocalStorageItem(key, '{}'); + return JSON.parse(value || '{}'); + } catch { + return {}; + } +}; + export function getLocalStorageItem(key, defaultValue = null) { try { return localStorage.getItem(key) ?? defaultValue; diff --git a/tests/constants/application-data.js b/tests/constants/application-data.js index 435a7108f..bc072133b 100644 --- a/tests/constants/application-data.js +++ b/tests/constants/application-data.js @@ -63,3 +63,38 @@ export const APPLICATIONS_DATA = { isNew: false, notFound: false, }; + +export const NEW_STEPS_APPLICATIONS_DATA = { + stepOne: { + firstName: 'Anuj', + lastName: 'Chhikara', + role: 'Developer', + city: 'Haryana', + state: 'Haryana', + country: 'India', + imageUrl: 'https://example.com/profile.jpg', + }, + stepTwo: { + skills: 'JavaScript, TypeScript, React', + institution: 'University of Rizz', + introduction: + 'Experienced full-stack developer passionate about building scalable applications.', + }, + stepThree: { + forFun: 'I enjoy rock climbing and contributing to open source projects.', + funFact: + 'I built my first website at age 12 and have been coding ever since.', + }, + stepFour: { + phoneNumber: '+1-699-969-6969', + twitter: 'anujchhikara', + github: 'anujchhikara', + linkedin: 'anujchhikara', + instagram: 'anujchhikara', + }, + stepFive: { + numberOfHours: '15', + whyRds: 'Real Dev Squad aligns with my values of continuous learning.', + foundFrom: 'Twitter', + }, +}; diff --git a/tests/integration/components/application/detail-header-test.js b/tests/integration/components/application/detail-header-test.js index e1e3c771d..fa3f7bb56 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/integration/components/base-step-test.js b/tests/integration/components/base-step-test.js new file mode 100644 index 000000000..8dc9b4f5f --- /dev/null +++ b/tests/integration/components/base-step-test.js @@ -0,0 +1,150 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'website-www/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; +import sinon from 'sinon'; +import { STEP_DATA_STORAGE_KEY } from 'website-www/constants/new-join-form'; +import { APPLICATIONS_DATA } from '../../constants/application-data'; + +module('Integration | Component | new-join-steps/base-step', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(async function () { + this.isPreValid = () => {}; + + 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-join-steps/new-step-one-test.js b/tests/integration/components/new-join-steps/new-step-one-test.js new file mode 100644 index 000000000..ffc65bd7f --- /dev/null +++ b/tests/integration/components/new-join-steps/new-step-one-test.js @@ -0,0 +1,141 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'website-www/tests/helpers'; +import { render, triggerEvent, waitFor } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import sinon from 'sinon'; + +module( + 'Integration | Component | new-join-steps/new-step-one', + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + localStorage.removeItem('newStepOneData'); + + this.toast = this.owner.lookup('service:toast'); + sinon.stub(this.toast, 'success'); + sinon.stub(this.toast, 'error'); + + this.setIsPreValid = sinon.stub(); + this.setIsValid = sinon.stub(); + }); + + hooks.afterEach(function () { + sinon.restore(); + }); + + test('handleImageSelect rejects non-image files', async function (assert) { + await render( + hbs``, + ); + + const file = new File(['pdf content'], 'document.pdf', { + type: 'application/pdf', + }); + + await triggerEvent('input[type="file"]', 'change', { + files: [file], + }); + + assert.ok( + this.toast.error.calledWithExactly( + 'Invalid file type. Please upload an image file.', + 'Error!', + ), + '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.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) { + sinon.stub(window, 'fetch').resolves({ + ok: true, + json: async () => ({ + image: { + 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'); + + 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.calledWithExactly( + 'Profile image uploaded successfully!', + 'Success!', + sinon.match.object, + ), + 'Shows success toast', + ); + }); + + test('shows error toast on image upload API failure', async function (assert) { + 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.calledWithExactly( + 'Server error', + 'Error!', + sinon.match.object, + ), + 'Shows error toast with API message', + ); + }); + }, +); diff --git a/tests/integration/components/new-signup/checkbox-test.js b/tests/integration/components/new-signup/checkbox-test.js index 43b5d0b56..19fb020f3 100644 --- a/tests/integration/components/new-signup/checkbox-test.js +++ b/tests/integration/components/new-signup/checkbox-test.js @@ -12,7 +12,7 @@ module('Integration | Component | new-signup/checkbox', function (hooks) { this.setProperties({ onClick: function () { - this.currentStep = NEW_SIGNUP_STEPS[5]; + this.currentStep = NEW_SIGNUP_STEPS[3]; }, currentStep: 'role', isDevMode: true, @@ -32,7 +32,7 @@ module('Integration | Component | new-signup/checkbox', function (hooks) { assert.expect(2); this.setProperties({ onClick: function () { - this.currentStep = NEW_SIGNUP_STEPS[5]; + this.currentStep = NEW_SIGNUP_STEPS[3]; }, currentStep: 'role', isDevMode: true, @@ -50,7 +50,7 @@ module('Integration | Component | new-signup/checkbox', function (hooks) { }); test('render label and checkbox (under dev flag)', async function (assert) { - assert.expect(10); + assert.expect(16); this.setProperties({ onClick: function () { @@ -67,20 +67,30 @@ module('Integration | Component | new-signup/checkbox', function (hooks) { @dev={{this.isDevMode}} />`); - assert.dom('[data-test-checkbox-label]').exists({ count: 4 }); - assert.dom('[data-test-checkbox-input]').exists({ count: 4 }); + assert.dom('[data-test-checkbox-label]').exists({ count: 7 }); + assert.dom('[data-test-checkbox-input]').exists({ count: 7 }); assert.dom('[data-test-checkbox-input="developer"]').isNotChecked(); assert.dom('[data-test-checkbox-input="designer"]').isNotChecked(); + assert.dom('[data-test-checkbox-input="project_manager"]').isNotChecked(); assert.dom('[data-test-checkbox-input="maven"]').isNotChecked(); - assert.dom('[data-test-checkbox-input="productmanager"]').isNotChecked(); + assert.dom('[data-test-checkbox-input="product_manager"]').isNotChecked(); + assert.dom('[data-test-checkbox-input="qa"]').isNotChecked(); + assert.dom('[data-test-checkbox-input="social_media"]').isNotChecked(); assert.dom('[data-test-checkbox-label="developer"]').hasText('Developer'); assert.dom('[data-test-checkbox-label="designer"]').hasText('Designer'); assert.dom('[data-test-checkbox-label="maven"]').hasText('Maven'); assert - .dom('[data-test-checkbox-label="productmanager"]') + .dom('[data-test-checkbox-label="product_manager"]') .hasText('Product Manager'); + assert + .dom('[data-test-checkbox-label="project_manager"]') + .hasText('Project Manager'); + assert.dom('[data-test-checkbox-label="qa"]').hasText('QA'); + assert + .dom('[data-test-checkbox-label="social_media"]') + .hasText('Social Media'); }); test('checkbox is checked after the click (under dev flag)', async function (assert) { @@ -88,15 +98,15 @@ module('Integration | Component | new-signup/checkbox', function (hooks) { this.setProperties({ onClick: function () { - this.currentStep = NEW_SIGNUP_STEPS[5]; + this.currentStep = NEW_SIGNUP_STEPS[3]; }, currentStep: 'role', isDevMode: true, }); - this.set('onChange', function (roleKey, value) { + this.set('onChange', function (selectedRole, value) { assert.strictEqual( - roleKey, + selectedRole, 'developer', 'onChange action called with correct roleKey', ); diff --git a/tests/integration/components/new-stepper-test.js b/tests/integration/components/new-stepper-test.js index bce8ee5c0..0766e54e1 100644 --- a/tests/integration/components/new-stepper-test.js +++ b/tests/integration/components/new-stepper-test.js @@ -1,38 +1,365 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'website-www/tests/helpers'; -import { render } 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'; +import { + APPLICATIONS_DATA, + NEW_STEPS_APPLICATIONS_DATA, +} from 'website-www/tests/constants/application-data'; module('Integration | Component | new-stepper', function (hooks) { setupRenderingTest(hooks); - test('it renders the welcome screen at step 0', async function (assert) { - await render(hbs``); - - assert.dom('[data-test="stepper"]').exists('Stepper component is rendered'); - assert - .dom('[data-test="welcome-screen"]') - .exists('Welcome screen is rendered'); - assert - .dom('[data-test="welcome-greeting"]') - .hasText('Ready to apply to Real Dev Squad?'); - assert.dom('[data-test-button="start"]').exists('Start button exists'); + 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.routerService = { + transitionTo: sinon.stub(), + replaceWith: sinon.stub(), + currentRoute: { queryParams: {} }, + }; + + const testContext = this; + class ToastServiceStub extends Service { + constructor(...args) { + super(...args); + this.success = sinon.stub(); + this.error = sinon.stub(); + testContext.toastService = this; + } + } + + 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', ToastServiceStub); + this.owner.register('service:onboarding', OnboardingServiceStub); + this.owner.register( + 'service:joinApplicationTerms', + JoinApplicationTermsServiceStub, + ); + + seedFormDataToLocalStorage(NEW_STEPS_APPLICATIONS_DATA); + + this.apiStub = sinon.stub(window, 'fetch').resolves({ + ok: true, + status: 201, + json: () => Promise.resolve({ application: { id: 'app-123' } }), + }); + }); + + hooks.afterEach(function () { + clearFormDataFromLocalStorage(); + sinon.restore(); + }); + + module('Edit Application', function (hooks) { + hooks.beforeEach(function () { + const onboardingService = this.owner.lookup('service:onboarding'); + onboardingService.applicationData = APPLICATIONS_DATA; + }); + + test('uses PATCH method for updating application', async function (assert) { + await render(hbs``); + await click('[data-test-button="submit-review"]'); + + assert.strictEqual( + this.apiStub.firstCall.args[1].method, + 'PATCH', + 'Uses PATCH method for editing', + ); + }); + + 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.apiStub.firstCall.args[0].includes( + `/applications/${APPLICATIONS_DATA.id}`, + ), + 'Correct update endpoint called with application ID', + ); + }); + + 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', + ); + }); + + 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', + ); + }); + + 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('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"]'); + + 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 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'); + }); + + test('redirects to join page after successful edit', async function (assert) { + await render(hbs``); + await click('[data-test-button="submit-review"]'); + + assert.ok( + this.routerService.replaceWith.calledWith('join', { + queryParams: { dev: true }, + }), + 'Redirects to join page after successful edit', + ); + }); }); - test('start button is disabled when terms are not accepted', async function (assert) { - await render(hbs``); - assert - .dom('[data-test-button="start"]') - .isDisabled('Start button is disabled by default'); + 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', + ); + }); + + 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', + ); + }); + + 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('start button is enabled when terms are accepted', async function (assert) { - const terms = this.owner.lookup('service:joinApplicationTerms'); - terms.hasUserAcceptedTerms = true; + 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' })); + }); + + await render(hbs``); + await click('[data-test-button="submit-review"]'); + + Object.values(STEP_DATA_STORAGE_KEY).forEach((key) => { + assert.strictEqual( + localStorage.getItem(key), + null, + `${key} removed from localStorage`, + ); + }); + }); + + 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', + ); + }); + + 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"]'); - await render(hbs``); - assert - .dom('[data-test-button="start"]') - .isNotDisabled('Start button is enabled when terms are accepted'); + assert.strictEqual( + localStorage.getItem('isValid'), + 'true', + 'localStorage preserved on error', + ); + }); }); }); diff --git a/tests/integration/components/profile/upload-image-test.js b/tests/integration/components/profile/upload-image-test.js index 387fb291f..bf574bb6f 100644 --- a/tests/integration/components/profile/upload-image-test.js +++ b/tests/integration/components/profile/upload-image-test.js @@ -77,12 +77,9 @@ module('Integration | Component | image uploader', function (hooks) { dataTransfer, }); await waitFor('p.message-text__failure'); - assert .dom('p.message-text__failure') - .hasText( - 'Error occured, please try again and if the issue still exists contact administrator and create a issue on the repo with logs', - ); + .exists('Error message is shown for invalid file type'); }); test('it renders crop UI when an image is selected', async function (assert) { diff --git a/tests/integration/components/status-card-test.js b/tests/integration/components/status-card-test.js index 27676e90a..bcf6d3b99 100644 --- a/tests/integration/components/status-card-test.js +++ b/tests/integration/components/status-card-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render, waitFor } from '@ember/test-helpers'; +import { render, waitFor, click } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { ANKUSH_TWITTER } from '../../constants/urls'; import Service from '@ember/service'; @@ -9,8 +9,27 @@ import sinon from 'sinon'; class LoginStub extends Service { userData = { id: 'fZ0itx5x2ltOSMzON9kb' }; } + class OnboardingStub extends Service { - getApplicationDetails = sinon.spy; + getApplicationDetails = sinon.stub().resolves({}); + applicationData = null; +} + +class RouterStub extends Service { + transitionTo = sinon.stub(); +} + +async function renderStatusCard(context, status, feedback = null) { + context.set('status', status); + context.set('feedback', feedback); + + await render(hbs` + + `); } module('Integration | Component | status-card', function (hooks) { @@ -24,20 +43,18 @@ module('Integration | Component | status-card', function (hooks) { this.owner.register('service:login', LoginStub); this.owner.register('service:onboarding', OnboardingStub); - }); + this.owner.register('service:router', RouterStub); - test('it renders pending status', async function (assert) { - this.set('status', 'pending'); - this.set('feedback', 'Feedback for pending status'); + this.onboarding = this.owner.lookup('service:onboarding'); + this.router = this.owner.lookup('service:router'); + }); - await render(hbs` - - `); + hooks.afterEach(function () { + sinon.restore(); + }); + test('it renders pending status', async function (assert) { + await renderStatusCard(this, 'pending', 'Feedback for pending status'); await waitFor('[data-test-status-card-heading]'); assert.dom('[data-test-status-card-heading]').hasText('Pending'); @@ -54,18 +71,7 @@ module('Integration | Component | status-card', function (hooks) { }); test('it renders rejected status', async function (assert) { - assert.expect(5); - - this.set('status', 'rejected'); - this.set('feedback', 'Feedback for rejected status'); - - await render(hbs` - - `); + await renderStatusCard(this, 'rejected', 'Feedback for rejected status'); assert.dom('[data-test-status-card-heading]').hasText('Rejected'); assert.dom('[data-test-icon="rejected"]').exists(); @@ -74,7 +80,6 @@ module('Integration | Component | status-card', function (hooks) { .hasText( `We're sorry to inform you that your application has been rejected.`, ); - assert.dom('[data-test-status-card-feedback-title]').hasText('Feedback:'); assert .dom('[data-test-status-card-feedback-content]') @@ -82,18 +87,7 @@ module('Integration | Component | status-card', function (hooks) { }); test('it renders accepted status with feedback', async function (assert) { - assert.expect(5); - - this.set('status', 'accepted'); - this.set('feedback', 'Feedback for accepted status'); - - await render(hbs` - - `); + await renderStatusCard(this, 'accepted', 'Feedback for accepted status'); assert.dom('[data-test-status-card-heading]').hasText('Accepted'); assert.dom('[data-test-icon="accepted"]').exists(); @@ -107,18 +101,7 @@ module('Integration | Component | status-card', function (hooks) { }); test('it renders accepted status without feedback', async function (assert) { - assert.expect(4); - - this.set('status', 'accepted'); - this.set('feedback', null); - - await render(hbs` - - `); + await renderStatusCard(this, 'accepted', null); assert.dom('[data-test-status-card-heading]').hasText('Accepted'); assert.dom('[data-test-icon="accepted"]').exists(); @@ -129,20 +112,76 @@ module('Integration | Component | status-card', function (hooks) { }); test('it handles unknown status', async function (assert) { - assert.expect(2); - - this.set('status', 'unknown'); - this.set('feedback', 'This is unexpected'); - - await render(hbs` - - `); + await renderStatusCard(this, 'unknown', 'This is unexpected'); assert.dom('[data-test-status-card-heading]').doesNotExist(); assert.dom('[data-test-icon]').doesNotExist(); }); + + module('track application button', function (hooks) { + hooks.beforeEach(function () { + this.onboarding.applicationData = { id: 'app-123' }; + }); + + test('track application button exists when status is pending', async function (assert) { + await renderStatusCard( + this, + 'pending', + 'Your application is under review', + ); + await waitFor('[data-test-button="track-application-btn"]'); + + assert + .dom('[data-test-button="track-application-btn"]') + .exists('Track Application button exists'); + assert + .dom('[data-test-button="track-application-btn"]') + .hasText('Track Application'); + }); + + test('trackApplication navigates to application detail page', async function (assert) { + await renderStatusCard( + this, + 'pending', + 'Your application is under review', + ); + await click('[data-test-button="track-application-btn"]'); + + assert.ok( + this.router.transitionTo.calledOnce, + 'transitionTo is called once', + ); + assert.strictEqual( + this.router.transitionTo.firstCall.args[0], + 'applications.detail', + 'transitions to applications.detail route', + ); + assert.strictEqual( + this.router.transitionTo.firstCall.args[1], + 'app-123', + 'passes application ID as route parameter', + ); + assert.deepEqual( + this.router.transitionTo.firstCall.args[2], + { queryParams: { dev: true } }, + 'includes dev=true query parameter', + ); + }); + + test('trackApplication does nothing when applicationId is missing', async function (assert) { + this.onboarding.applicationData = null; + + await renderStatusCard( + this, + 'pending', + 'Your application is under review', + ); + await click('[data-test-button="track-application-btn"]'); + + assert.notOk( + this.router.transitionTo.called, + 'transitionTo is not called when applicationId is missing', + ); + }); + }); }); diff --git a/tests/unit/controllers/join-test.js b/tests/unit/controllers/join-test.js index 60a612c45..27827384b 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) { diff --git a/tests/unit/controllers/new-signup-test.js b/tests/unit/controllers/new-signup-test.js index e37b003cd..dca24d995 100644 --- a/tests/unit/controllers/new-signup-test.js +++ b/tests/unit/controllers/new-signup-test.js @@ -90,22 +90,34 @@ module('Unit | Controller | new-signup', function (hooks) { ); }); - test('handleCheckboxInputChange updates roles and toggles button state', function (assert) { - controller.send('handleCheckboxInputChange', 'developer', true); - assert.true(controller.signupDetails.roles.developer, 'Developer role set'); + test('handleRoleSelection updates role and toggles button state', function (assert) { + controller.send('handleRoleSelection', 'developer'); + assert.strictEqual(controller.signupDetails.role, 'developer'); assert.false( controller.isButtonDisabled, 'Button enabled when one role is selected', ); - controller.send('handleCheckboxInputChange', 'developer', false); + controller.send('handleRoleSelection', 'developer'); + assert.strictEqual( + controller.signupDetails.role, + 'developer', + 'Role remains selected when same role is chosen again', + ); assert.false( - controller.signupDetails.roles.developer, - 'Developer role unset', + controller.isButtonDisabled, + 'Button remains enabled when role is selected', + ); + + controller.send('handleRoleSelection', ''); + assert.strictEqual( + controller.signupDetails.role, + '', + 'Role is unset when empty value is passed', ); assert.true( controller.isButtonDisabled, - 'Button disabled when no roles selected', + 'Button disabled when no role selected', ); }); @@ -158,7 +170,6 @@ module('Unit | Controller | new-signup', function (hooks) { controller.signupDetails = { firstName: fakeUserData.first_name, lastName: fakeUserData.last_name, - roles: { developer: true }, }; sinon.stub(controller, 'generateUsername').resolves(fakeUserData.username); @@ -179,7 +190,6 @@ module('Unit | Controller | new-signup', function (hooks) { controller.signupDetails = { firstName: fakeUserData.first_name, lastName: fakeUserData.last_name, - roles: { developer: true }, }; sinon.stub(controller, 'generateUsername').resolves(fakeUserData.username); @@ -204,13 +214,12 @@ module('Unit | Controller | new-signup', function (hooks) { controller.signupDetails = { firstName: fakeUserData.first_name, lastName: fakeUserData.last_name, - username: 'mock-username', - roles: { developer: true }, + roles: 'developer', }; sinon.stub(controller, 'checkUserName').resolves(true); sinon - .stub(controller, 'newRegisterUser') + .stub(controller, 'registerUser') .throws(new Error(SIGNUP_ERROR_MESSAGES.others)); await controller.signup(); diff --git a/tests/unit/routes/applications/detail-test.js b/tests/unit/routes/applications/detail-test.js index 8335e4295..62096a0fe 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'); }); }); diff --git a/tests/unit/routes/debug-test.js b/tests/unit/routes/debug-test.js index 63c2f6697..08015350a 100644 --- a/tests/unit/routes/debug-test.js +++ b/tests/unit/routes/debug-test.js @@ -1,10 +1,21 @@ import { module, test } from 'qunit'; import { setupTest } from 'website-www/tests/helpers'; import { visit } from '@ember/test-helpers'; +import Service from '@ember/service'; + +class LoginStub extends Service { + isLoading = false; + isLoggedIn = false; + userData = null; +} module('Unit | Route | debug', function (hooks) { setupTest(hooks); + hooks.beforeEach(function () { + this.owner.register('service:login', LoginStub); + }); + test('it exists', function (assert) { let route = this.owner.lookup('route:debug'); assert.ok(route); diff --git a/tests/unit/routes/live-test.js b/tests/unit/routes/live-test.js index da8239e73..a81a7dab3 100644 --- a/tests/unit/routes/live-test.js +++ b/tests/unit/routes/live-test.js @@ -1,9 +1,20 @@ import { module, test } from 'qunit'; import { setupTest } from 'website-www/tests/helpers'; import { visit } from '@ember/test-helpers'; +import Service from '@ember/service'; + +class LoginStub extends Service { + isLoading = false; + isLoggedIn = false; + userData = null; +} module('Unit | Route | live', function (hooks) { setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:login', LoginStub); + }); // TODO - remove tests for dev mode when it goes to prod test('it exists', function (assert) { let route = this.owner.lookup('route:live'); diff --git a/tests/unit/routes/login-test.js b/tests/unit/routes/login-test.js index 253d0cec2..81747fa77 100644 --- a/tests/unit/routes/login-test.js +++ b/tests/unit/routes/login-test.js @@ -1,10 +1,21 @@ import { module, test } from 'qunit'; import { setupTest } from 'website-www/tests/helpers'; import { visit, currentURL } from '@ember/test-helpers'; +import Service from '@ember/service'; + +class LoginStub extends Service { + isLoading = false; + isLoggedIn = false; + userData = null; +} module('Unit | Route | login', function (hooks) { setupTest(hooks); + hooks.beforeEach(function () { + this.owner.register('service:login', LoginStub); + }); + test('it exists', function (assert) { let route = this.owner.lookup('route:login'); assert.ok(route); diff --git a/tests/unit/routes/page-not-found-test.js b/tests/unit/routes/page-not-found-test.js index f2384c331..9598fc904 100644 --- a/tests/unit/routes/page-not-found-test.js +++ b/tests/unit/routes/page-not-found-test.js @@ -1,9 +1,21 @@ import { module, test } from 'qunit'; import { setupTest } from 'website-www/tests/helpers'; import { visit } from '@ember/test-helpers'; +import Service from '@ember/service'; + +class LoginStub extends Service { + isLoading = false; + isLoggedIn = false; + userData = null; +} + module('Unit | Route | page-not-found', function (hooks) { setupTest(hooks); + hooks.beforeEach(function () { + this.owner.register('service:login', LoginStub); + }); + test('it exists', function (assert) { let route = this.owner.lookup('route:page-not-found'); assert.ok(route); diff --git a/tests/unit/services/onboarding-test.js b/tests/unit/services/onboarding-test.js index 2dbefd047..ef53a3ffb 100644 --- a/tests/unit/services/onboarding-test.js +++ b/tests/unit/services/onboarding-test.js @@ -67,7 +67,7 @@ module('Unit | Service | onboarding', function (hooks) { }); test('signup method for non-Developer role', async function (assert) { - assert.expect(2); + assert.expect(4); let service = this.owner.lookup('service:onboarding'); let store = this.owner.lookup('service:store'); @@ -94,14 +94,16 @@ module('Unit | Service | onboarding', function (hooks) { let dataToUpdate = { username: 'testuser', - roles: { - maven: true, - }, + role: 'maven', }; await service.signup(dataToUpdate); - assert.verifySteps(['store.createRecord called']); + assert.verifySteps([ + 'store.createRecord called', + 'setProperties called', + 'save called', + ]); }); test('discordInvite method', async function (assert) {