diff --git a/src/applications/education/22-1990n/app-entry.jsx b/src/applications/education/22-1990n/app-entry.jsx new file mode 100644 index 000000000000..67d00e595c2a --- /dev/null +++ b/src/applications/education/22-1990n/app-entry.jsx @@ -0,0 +1,15 @@ +import 'platform/polyfills'; +import './sass/22-1990n.scss'; + +import startApp from 'platform/startup'; + +import routes from './routes'; +import reducer from './reducers'; +import manifest from './manifest.json'; + +startApp({ + entryName: manifest.entryName, + url: manifest.rootUrl, + reducer, + routes, +}); \ No newline at end of file diff --git a/src/applications/education/22-1990n/components/GetFormHelp.jsx b/src/applications/education/22-1990n/components/GetFormHelp.jsx new file mode 100644 index 000000000000..d54140451cbb --- /dev/null +++ b/src/applications/education/22-1990n/components/GetFormHelp.jsx @@ -0,0 +1,17 @@ +import React from 'react'; + +export const GetFormHelp = () => ( +
+

+ Call the GI Bill Hotline at (TTY:{' '} + + ). We’re here Monday through Friday, 8:00 a.m. to 7:00 p.m. ET. +

+

+ You can also{' '} + contact us online. +

+
+); + +export default GetFormHelp; diff --git a/src/applications/education/22-1990n/components/GetFormHelp.unit.spec.jsx b/src/applications/education/22-1990n/components/GetFormHelp.unit.spec.jsx new file mode 100644 index 000000000000..3c486d447005 --- /dev/null +++ b/src/applications/education/22-1990n/components/GetFormHelp.unit.spec.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { expect } from 'chai'; +import { render } from '@testing-library/react'; +import GetFormHelp from './GetFormHelp'; + +describe('GetFormHelp component', () => { + it('renders without throwing', () => { + expect(() => render()).to.not.throw(); + }); + + it('renders telephone elements', () => { + const { container } = render(); + const phoneElements = container.querySelectorAll('va-telephone'); + expect(phoneElements.length).to.be.greaterThan(0); + }); +}); diff --git a/src/applications/education/22-1990n/config/chapters/applicantInformation.js b/src/applications/education/22-1990n/config/chapters/applicantInformation.js new file mode 100644 index 000000000000..3584b3e4e131 --- /dev/null +++ b/src/applications/education/22-1990n/config/chapters/applicantInformation.js @@ -0,0 +1,174 @@ +import { + ssnUI, + ssnSchema, + currentOrPastDateUI, + currentOrPastDateSchema, + radioUI, + radioSchema, + fullNameNoSuffixUI, + fullNameNoSuffixSchema, + textUI, + textSchema, + selectUI, + selectSchema, + phoneUI, + phoneSchema, + emailUI, + emailSchema, +} from 'platform/forms-system/src/js/web-component-patterns'; + +import { states } from 'platform/forms/address'; + +// ── Screen 1: Personal Information (Items 1, 2, 3) ────────────────────────── + +export const personalInformationUiSchema = { + veteranSocialSecurityNumber: { + ...ssnUI(), + 'ui:options': { + hint: 'Enter your 9-digit Social Security number (for example: 123-45-6789)', + widgetClassNames: 'usa-input-medium', + }, + }, + veteranDateOfBirth: currentOrPastDateUI({ + title: 'Date of birth', + hint: 'For example: January 19 1970', + errorMessages: { + required: 'Please enter a valid date of birth', + futureDate: 'Date of birth must be in the past', + }, + }), + gender: radioUI({ + title: 'Sex', + labels: { + F: 'Female', + M: 'Male', + }, + required: () => true, + errorMessages: { + required: 'Please select your sex', + }, + }), +}; + +export const personalInformationSchema = { + type: 'object', + required: ['veteranSocialSecurityNumber', 'veteranDateOfBirth', 'gender'], + properties: { + veteranSocialSecurityNumber: ssnSchema, + veteranDateOfBirth: currentOrPastDateSchema, + gender: radioSchema(['F', 'M']), + }, +}; + +// ── Screen 2: Name (Item 4) ────────────────────────────────────────────────── + +export const nameUiSchema = { + veteranFullName: fullNameNoSuffixUI(title => `Your ${title}`), +}; + +export const nameSchema = { + type: 'object', + required: ['veteranFullName'], + properties: { + veteranFullName: fullNameNoSuffixSchema, + }, +}; + +// ── Screen 3: Address (Item 5) ─────────────────────────────────────────────── + +const stateLabels = states.USA.reduce((acc, { value, label }) => { + acc[value] = label; + return acc; +}, {}); + +export const addressUiSchema = { + veteranAddress: { + street: textUI({ + title: 'Street address', + autocomplete: 'address-line1', + errorMessages: { required: 'Please enter a street address' }, + }), + street2: textUI({ + title: 'Apartment or unit number', + autocomplete: 'address-line2', + }), + city: textUI({ + title: 'City', + autocomplete: 'address-level2', + errorMessages: { required: 'Please enter a city' }, + }), + state: selectUI({ + title: 'State', + labels: stateLabels, + autocomplete: 'address-level1', + errorMessages: { required: 'Please select a state' }, + }), + postalCode: textUI({ + title: 'ZIP code', + hint: 'Enter a 5-digit ZIP code (for example: 12345)', + autocomplete: 'postal-code', + inputType: 'text', + errorMessages: { required: 'Please enter a valid 5-digit ZIP code' }, + }), + }, +}; + +export const addressSchema = { + type: 'object', + required: ['veteranAddress'], + properties: { + veteranAddress: { + type: 'object', + required: ['street', 'city', 'state', 'postalCode'], + properties: { + street: { type: 'string', minLength: 1, maxLength: 50 }, + street2: { type: 'string', maxLength: 50 }, + city: { type: 'string', minLength: 1, maxLength: 30 }, + state: selectSchema(Object.keys(stateLabels)), + postalCode: { type: 'string', pattern: '^\\d{5}(-\\d{4})?$', maxLength: 10 }, + }, + }, + }, +}; + +// ── Screen 4: Contact Information (Items 6A, 6B) ───────────────────────────── + +function validateAtLeastOnePhone(errors, formData) { + if (!formData.homePhone && !formData.mobilePhone) { + errors.homePhone.addError('Please provide at least one phone number'); + } +} + +export const contactInformationUiSchema = { + 'ui:validations': [validateAtLeastOnePhone], + homePhone: phoneUI({ + title: 'Home phone number', + hint: 'Enter 10 digits (for example: 800-555-1234)', + errorMessages: { + pattern: 'Please enter a valid 10-digit phone number', + }, + }), + mobilePhone: phoneUI({ + title: 'Mobile phone number', + hint: 'Enter 10 digits (for example: 800-555-1234)', + errorMessages: { + pattern: 'Please enter a valid 10-digit phone number', + }, + }), + email: emailUI({ + title: 'Email address', + hint: 'For example: name@example.com', + errorMessages: { + pattern: 'Please enter a valid email address', + }, + }), +}; + +export const contactInformationSchema = { + type: 'object', + properties: { + homePhone: phoneSchema, + mobilePhone: { type: 'string', pattern: '^\\d{10}$', minLength: 10, maxLength: 10 }, + email: { type: 'string', format: 'email', maxLength: 255 }, + }, +}; \ No newline at end of file diff --git a/src/applications/education/22-1990n/config/chapters/applicantInformation.unit.spec.js b/src/applications/education/22-1990n/config/chapters/applicantInformation.unit.spec.js new file mode 100644 index 000000000000..9b32b4936429 --- /dev/null +++ b/src/applications/education/22-1990n/config/chapters/applicantInformation.unit.spec.js @@ -0,0 +1,95 @@ +import { expect } from 'chai'; +import { + personalInformationUiSchema, + personalInformationSchema, + nameUiSchema, + nameSchema, + addressUiSchema, + addressSchema, + contactInformationUiSchema, + contactInformationSchema, +} from './applicantInformation'; + +describe('applicantInformation chapter', () => { + describe('personalInformationUiSchema', () => { + it('exports a valid uiSchema object', () => { + expect(personalInformationUiSchema).to.be.an('object'); + expect(personalInformationUiSchema).to.have.property('veteranSocialSecurityNumber'); + expect(personalInformationUiSchema).to.have.property('veteranDateOfBirth'); + expect(personalInformationUiSchema).to.have.property('gender'); + }); + + it('gender field has required function', () => { + const genderField = personalInformationUiSchema.gender; + expect(genderField).to.be.an('object'); + }); + }); + + describe('personalInformationSchema', () => { + it('has required fields', () => { + expect(personalInformationSchema.required).to.include('veteranSocialSecurityNumber'); + expect(personalInformationSchema.required).to.include('veteranDateOfBirth'); + expect(personalInformationSchema.required).to.include('gender'); + }); + }); + + describe('nameUiSchema', () => { + it('has veteranFullName field', () => { + expect(nameUiSchema).to.have.property('veteranFullName'); + }); + }); + + describe('addressUiSchema', () => { + it('has veteranAddress with street, city, state, postalCode', () => { + expect(addressUiSchema.veteranAddress).to.have.property('street'); + expect(addressUiSchema.veteranAddress).to.have.property('city'); + expect(addressUiSchema.veteranAddress).to.have.property('state'); + expect(addressUiSchema.veteranAddress).to.have.property('postalCode'); + }); + }); + + describe('contactInformationUiSchema', () => { + it('has phone and email fields', () => { + expect(contactInformationUiSchema).to.have.property('homePhone'); + expect(contactInformationUiSchema).to.have.property('mobilePhone'); + expect(contactInformationUiSchema).to.have.property('email'); + }); + + describe('validateAtLeastOnePhone (ui:validations)', () => { + let messages; + let errors; + + beforeEach(() => { + messages = []; + errors = { + homePhone: { addError: msg => messages.push(msg || '') }, + mobilePhone: { addError: msg => messages.push(msg || '') }, + }; + }); + + it('adds an error when both phones are missing', () => { + const validationFn = contactInformationUiSchema['ui:validations'][0]; + validationFn(errors, { homePhone: '', mobilePhone: '' }); + expect(messages.length).to.be.greaterThan(0); + }); + + it('does not error when homePhone is provided', () => { + const validationFn = contactInformationUiSchema['ui:validations'][0]; + validationFn(errors, { homePhone: '8005551234', mobilePhone: '' }); + expect(messages).to.have.lengthOf(0); + }); + + it('does not error when mobilePhone is provided', () => { + const validationFn = contactInformationUiSchema['ui:validations'][0]; + validationFn(errors, { homePhone: '', mobilePhone: '8005551234' }); + expect(messages).to.have.lengthOf(0); + }); + + it('does not error when both phones are provided', () => { + const validationFn = contactInformationUiSchema['ui:validations'][0]; + validationFn(errors, { homePhone: '8005551234', mobilePhone: '8005551235' }); + expect(messages).to.have.lengthOf(0); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/applications/education/22-1990n/config/chapters/concurrentBenefits.js b/src/applications/education/22-1990n/config/chapters/concurrentBenefits.js new file mode 100644 index 000000000000..368a9252022e --- /dev/null +++ b/src/applications/education/22-1990n/config/chapters/concurrentBenefits.js @@ -0,0 +1,101 @@ +import { + yesNoUI, + yesNoSchema, + textUI, + textSchema, + radioUI, + radioSchema, +} from 'platform/forms-system/src/js/web-component-patterns'; + +// ── Screen 13: Senior ROTC Scholarship (Item 11A) ───────────────────────────── + +export const rotcScholarshipUiSchema = { + seniorRotcScholarship: yesNoUI({ + title: + 'Are you currently participating in a Senior ROTC scholarship program which pays for your tuition, fees, books and supplies under Section 2107, Title 10, U.S. Code?', + required: () => true, + errorMessages: { + required: + 'Please indicate whether you are participating in a Senior ROTC scholarship program', + }, + }), +}; + +export const rotcScholarshipSchema = { + type: 'object', + required: ['seniorRotcScholarship'], + properties: { + seniorRotcScholarship: yesNoSchema, + }, +}; + +// ── Screen 14: Federal Tuition Assistance (Item 11B) — Active Duty Only ─────── + +export const federalTuitionAssistanceUiSchema = { + federalTuitionAssistance: yesNoUI({ + title: + 'Are you receiving or do you anticipate receiving any money (including but not limited to Federal Tuition Assistance) from the Armed Forces or Public Health Service for the course for which you have applied to the VA for education benefits? If you receive such benefits during any part of your training, answer Yes.', + required: () => true, + errorMessages: { + required: + 'Please indicate whether you are receiving or anticipate receiving money from the Armed Forces or Public Health Service for this course', + }, + }), +}; + +export const federalTuitionAssistanceSchema = { + type: 'object', + required: ['federalTuitionAssistance'], + properties: { + federalTuitionAssistance: yesNoSchema, + }, +}; + +// ── Screen 15: Civilian Government Employee (Item 11C) ──────────────────────── + +export const govtEmployeeUiSchema = { + civilianGovtEmployee: yesNoUI({ + title: 'Are you a civilian employee of the U.S. Government?', + required: () => true, + errorMessages: { + required: + 'Please indicate whether you are a civilian employee of the U.S. Government', + }, + }), + govtEmployeeFunding: { + ...yesNoUI({ + title: + 'Do you expect to receive funds from your agency or department for the same course(s) for which you expect to receive VA education assistance?', + required: () => false, + errorMessages: { + required: + 'Please indicate whether you expect to receive funds from your agency for these courses', + }, + }), + 'ui:options': { + expandUnder: 'civilianGovtEmployee', + expandUnderCondition: true, + }, + }, + govtEmployeeFundingSource: { + ...textUI({ + title: 'Source of funds', + hint: 'For example: Department of Defense Tuition Assistance, agency training fund', + errorMessages: { required: 'Please describe the source of funds' }, + }), + 'ui:options': { + expandUnder: 'govtEmployeeFunding', + expandUnderCondition: true, + }, + }, +}; + +export const govtEmployeeSchema = { + type: 'object', + required: ['civilianGovtEmployee'], + properties: { + civilianGovtEmployee: yesNoSchema, + govtEmployeeFunding: yesNoSchema, + govtEmployeeFundingSource: { type: 'string', maxLength: 200 }, + }, +}; \ No newline at end of file diff --git a/src/applications/education/22-1990n/config/chapters/concurrentBenefits.unit.spec.js b/src/applications/education/22-1990n/config/chapters/concurrentBenefits.unit.spec.js new file mode 100644 index 000000000000..708802311c6f --- /dev/null +++ b/src/applications/education/22-1990n/config/chapters/concurrentBenefits.unit.spec.js @@ -0,0 +1,67 @@ +import { expect } from 'chai'; +import { + rotcScholarshipUiSchema, + rotcScholarshipSchema, + federalTuitionAssistanceUiSchema, + federalTuitionAssistanceSchema, + govtEmployeeUiSchema, + govtEmployeeSchema, +} from './concurrentBenefits'; + +describe('concurrentBenefits chapter', () => { + describe('rotcScholarshipUiSchema', () => { + it('has seniorRotcScholarship field', () => { + expect(rotcScholarshipUiSchema).to.have.property('seniorRotcScholarship'); + }); + }); + + describe('rotcScholarshipSchema', () => { + it('requires seniorRotcScholarship', () => { + expect(rotcScholarshipSchema.required).to.include('seniorRotcScholarship'); + }); + }); + + describe('federalTuitionAssistanceUiSchema', () => { + it('has federalTuitionAssistance field', () => { + expect(federalTuitionAssistanceUiSchema).to.have.property('federalTuitionAssistance'); + }); + }); + + describe('federalTuitionAssistanceSchema', () => { + it('requires federalTuitionAssistance', () => { + expect(federalTuitionAssistanceSchema.required).to.include('federalTuitionAssistance'); + }); + }); + + describe('govtEmployeeUiSchema', () => { + it('has civilianGovtEmployee, govtEmployeeFunding, govtEmployeeFundingSource fields', () => { + expect(govtEmployeeUiSchema).to.have.property('civilianGovtEmployee'); + expect(govtEmployeeUiSchema).to.have.property('govtEmployeeFunding'); + expect(govtEmployeeUiSchema).to.have.property('govtEmployeeFundingSource'); + }); + + it('govtEmployeeFunding has expandUnder option', () => { + const field = govtEmployeeUiSchema.govtEmployeeFunding; + expect(field['ui:options']).to.have.property('expandUnder'); + expect(field['ui:options'].expandUnder).to.equal('civilianGovtEmployee'); + }); + + it('govtEmployeeFundingSource has expandUnder option', () => { + const field = govtEmployeeUiSchema.govtEmployeeFundingSource; + expect(field['ui:options']).to.have.property('expandUnder'); + expect(field['ui:options'].expandUnder).to.equal('govtEmployeeFunding'); + }); + }); + + describe('govtEmployeeSchema', () => { + it('requires civilianGovtEmployee', () => { + expect(govtEmployeeSchema.required).to.include('civilianGovtEmployee'); + }); + + it('govtEmployeeFundingSource has maxLength 200', () => { + expect( + govtEmployeeSchema.properties.govtEmployeeFundingSource.maxLength, + ).to.equal(200); + }); + }); +}); \ No newline at end of file diff --git a/src/applications/education/22-1990n/config/chapters/directDeposit.js b/src/applications/education/22-1990n/config/chapters/directDeposit.js new file mode 100644 index 000000000000..078dd5869bd3 --- /dev/null +++ b/src/applications/education/22-1990n/config/chapters/directDeposit.js @@ -0,0 +1,109 @@ +import { + checkboxGroupUI, + checkboxGroupSchema, + radioUI, + radioSchema, + textUI, + textSchema, + fileInputMultipleUI, + fileInputMultipleSchema, +} from 'platform/forms-system/src/js/web-component-patterns'; + +// ── Screen 16: Direct Deposit / Payment Information (Item 7) ────────────────── + +export const paymentInformationUiSchema = { + directDeposit: { + noDirectDeposit: checkboxGroupUI({ + title: 'Direct deposit enrollment', + hint: + 'If you elect not to enroll in direct deposit, you must contact representatives handling waiver requests for the Department of the Treasury at 1-888-224-2950.', + required: false, + labels: { + declined: 'I do not want to enroll in direct deposit', + }, + }), + bankAccount: { + 'ui:options': { + hideIf: formData => + formData.directDeposit && + formData.directDeposit.noDirectDeposit && + formData.directDeposit.noDirectDeposit.declined, + }, + accountType: radioUI({ + title: 'Account type', + labels: { + checking: 'Checking', + savings: 'Savings', + }, + required: formData => + !( + formData.directDeposit && + formData.directDeposit.noDirectDeposit && + formData.directDeposit.noDirectDeposit.declined + ), + errorMessages: { required: 'Please select an account type' }, + }), + routingNumber: textUI({ + title: 'Bank routing or transit number', + hint: 'Enter the 9-digit number on the bottom left of your check', + inputType: 'text', + errorMessages: { required: 'Please enter a valid 9-digit routing number' }, + }), + accountNumber: textUI({ + title: 'Bank account number', + hint: 'Enter the account number from your check or deposit slip', + inputType: 'text', + errorMessages: { required: 'Please enter your account number' }, + }), + }, + }, +}; + +export const paymentInformationSchema = { + type: 'object', + properties: { + directDeposit: { + type: 'object', + properties: { + noDirectDeposit: checkboxGroupSchema(['declined']), + bankAccount: { + type: 'object', + properties: { + accountType: radioSchema(['checking', 'savings']), + routingNumber: { type: 'string', pattern: '^\\d{9}$', minLength: 9, maxLength: 9 }, + accountNumber: { type: 'string', pattern: '^\\d{4,17}$', minLength: 4, maxLength: 17 }, + }, + }, + }, + }, + }, +}; + +// ── Screen 17: Bank Document Upload (Conditional) ──────────────────────────── + +export const bankDocumentUploadUiSchema = { + directDeposit: { + bankDocument: fileInputMultipleUI({ + title: 'Upload a voided personal check or deposit slip', + hint: + 'To verify your account information, attach either a voided personal check or a deposit slip. Accepted file types: PDF, JPG, PNG. Maximum file size: 20MB.', + required: true, + errorMessages: { + required: + 'Please upload a voided check or deposit slip to verify your bank account information', + }, + }), + }, +}; + +export const bankDocumentUploadSchema = { + type: 'object', + properties: { + directDeposit: { + type: 'object', + properties: { + bankDocument: fileInputMultipleSchema(), + }, + }, + }, +}; \ No newline at end of file diff --git a/src/applications/education/22-1990n/config/chapters/directDeposit.unit.spec.js b/src/applications/education/22-1990n/config/chapters/directDeposit.unit.spec.js new file mode 100644 index 000000000000..4d4f743dbd13 --- /dev/null +++ b/src/applications/education/22-1990n/config/chapters/directDeposit.unit.spec.js @@ -0,0 +1,60 @@ +import { expect } from 'chai'; +import { + paymentInformationUiSchema, + paymentInformationSchema, + bankDocumentUploadUiSchema, + bankDocumentUploadSchema, +} from './directDeposit'; + +describe('directDeposit chapter', () => { + describe('paymentInformationUiSchema', () => { + it('has directDeposit field with noDirectDeposit and bankAccount', () => { + expect(paymentInformationUiSchema).to.have.property('directDeposit'); + expect(paymentInformationUiSchema.directDeposit).to.have.property('noDirectDeposit'); + expect(paymentInformationUiSchema.directDeposit).to.have.property('bankAccount'); + }); + + it('bankAccount has accountType, routingNumber, accountNumber fields', () => { + const bankAccount = paymentInformationUiSchema.directDeposit.bankAccount; + expect(bankAccount).to.have.property('accountType'); + expect(bankAccount).to.have.property('routingNumber'); + expect(bankAccount).to.have.property('accountNumber'); + }); + }); + + describe('paymentInformationSchema', () => { + it('has directDeposit with bankAccount properties', () => { + const { directDeposit } = paymentInformationSchema.properties; + expect(directDeposit).to.exist; + expect(directDeposit.properties).to.have.property('bankAccount'); + }); + + it('routingNumber has 9-digit pattern', () => { + const routingNumber = + paymentInformationSchema.properties.directDeposit.properties.bankAccount + .properties.routingNumber; + expect(routingNumber.pattern).to.equal('^\\d{9}$'); + }); + + it('accountNumber has 4-17 digit pattern', () => { + const accountNumber = + paymentInformationSchema.properties.directDeposit.properties.bankAccount + .properties.accountNumber; + expect(accountNumber.pattern).to.equal('^\\d{4,17}$'); + }); + }); + + describe('bankDocumentUploadUiSchema', () => { + it('has directDeposit.bankDocument field', () => { + expect(bankDocumentUploadUiSchema.directDeposit).to.have.property('bankDocument'); + }); + }); + + describe('bankDocumentUploadSchema', () => { + it('has directDeposit.bankDocument', () => { + expect( + bankDocumentUploadSchema.properties.directDeposit.properties, + ).to.have.property('bankDocument'); + }); + }); +}); \ No newline at end of file diff --git a/src/applications/education/22-1990n/config/chapters/educationTraining.js b/src/applications/education/22-1990n/config/chapters/educationTraining.js new file mode 100644 index 000000000000..5845464a0fb5 --- /dev/null +++ b/src/applications/education/22-1990n/config/chapters/educationTraining.js @@ -0,0 +1,226 @@ +import { + checkboxGroupUI, + checkboxGroupSchema, + yesNoUI, + yesNoSchema, + radioUI, + radioSchema, + textUI, + textSchema, + textareaUI, + textareaSchema, + selectUI, + selectSchema, +} from 'platform/forms-system/src/js/web-component-patterns'; + +import { states } from 'platform/forms/address'; + +// ── Screen 5: Type of Training (Item 8A) ───────────────────────────────────── + +const TYPE_OF_EDUCATION_LABELS = { + collegeOrOtherSchool: 'College or other school (including on-line courses)', + apprenticeshipOrOJT: 'Apprenticeship or on-the-job training', + vocationalFlightTraining: 'Vocational flight training', + correspondence: 'Correspondence', + nationalTestReimbursement: 'National test reimbursement (SAT, CLEP, etc.)', + licensingOrCertificationTest: + 'Licensing or certification test reimbursement (MCSE, CCNA, EMT, NCLEX, etc.)', +}; + +const TYPE_OF_EDUCATION_KEYS = Object.keys(TYPE_OF_EDUCATION_LABELS); + +export const typeOfTrainingUiSchema = { + typeOfEducation: checkboxGroupUI({ + title: 'Type of education or training', + hint: 'Select all that apply. See the information below for details about flight training and test reimbursement options.', + required: true, + labels: TYPE_OF_EDUCATION_LABELS, + errorMessages: { + required: 'Please select at least one type of education or training', + }, + }), +}; + +export const typeOfTrainingSchema = { + type: 'object', + required: ['typeOfEducation'], + properties: { + typeOfEducation: checkboxGroupSchema(TYPE_OF_EDUCATION_KEYS), + }, +}; + +// ── Screen 6: Flight Training Requirements (Conditional) ───────────────────── + +function validateFlightTrainingAcknowledged(errors, formData) { + if ( + formData.flightTrainingCourse && + !formData.flightTrainingCourse.requirementsAcknowledged + ) { + errors.flightTrainingCourse.requirementsAcknowledged.addError( + 'Please confirm you hold the required pilot certificate and medical certificate', + ); + } +} + +export const flightTrainingRequirementsUiSchema = { + 'ui:validations': [validateFlightTrainingAcknowledged], + flightTrainingCourse: { + isAirlineTransportPilot: radioUI({ + title: + 'Is your flight training course an Airline Transport Pilot (ATP) course?', + labels: { + true: 'Yes', + false: 'No', + }, + required: () => true, + errorMessages: { + required: + 'Please indicate whether your course is an Airline Transport Pilot course', + }, + }), + requirementsAcknowledged: checkboxGroupUI({ + title: 'Flight training requirement acknowledgment', + required: true, + labels: { + acknowledged: + 'I confirm that I currently hold the required pilot certificate and medical certificate for the type of flight training I have selected.', + }, + errorMessages: { + required: + 'Please confirm you hold the required pilot certificate and medical certificate', + }, + }), + }, +}; + +export const flightTrainingRequirementsSchema = { + type: 'object', + properties: { + flightTrainingCourse: { + type: 'object', + required: ['isAirlineTransportPilot', 'requirementsAcknowledged'], + properties: { + isAirlineTransportPilot: radioSchema(['true', 'false']), + requirementsAcknowledged: checkboxGroupSchema(['acknowledged']), + }, + }, + }, +}; + +// ── Screen 7: School Information (Item 8B) ──────────────────────────────────── + +const schoolStateLabels = states.USA.reduce((acc, { value, label }) => { + acc[value] = label; + return acc; +}, {}); + +export const schoolInformationUiSchema = { + schoolSelected: checkboxGroupUI({ + title: 'School selection', + hint: + 'If you have not yet selected a school, we will use your home address to route your application.', + required: false, + labels: { + selected: 'I have selected a school or training establishment', + }, + }), + schoolInfo: { + 'ui:options': { + expandUnder: 'schoolSelected', + expandUnderCondition: formData => + formData.schoolSelected && formData.schoolSelected.selected, + }, + name: textUI({ + title: 'Name of school or training establishment', + errorMessages: { required: 'Please enter the name of your school' }, + }), + address: { + street: textUI({ + title: 'Street address', + errorMessages: { required: 'Please enter the school\'s street address' }, + }), + city: textUI({ + title: 'City', + errorMessages: { required: 'Please enter the school\'s city' }, + }), + state: selectUI({ + title: 'State', + labels: schoolStateLabels, + errorMessages: { required: 'Please select the school\'s state' }, + }), + postalCode: textUI({ + title: 'ZIP code', + errorMessages: { required: 'Please enter the school\'s ZIP code' }, + }), + }, + }, +}; + +export const schoolInformationSchema = { + type: 'object', + properties: { + schoolSelected: checkboxGroupSchema(['selected']), + schoolInfo: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + address: { + type: 'object', + required: ['street', 'city', 'state', 'postalCode'], + properties: { + street: { type: 'string', minLength: 1, maxLength: 50 }, + city: { type: 'string', minLength: 1, maxLength: 30 }, + state: selectSchema(Object.keys(schoolStateLabels)), + postalCode: { + type: 'string', + pattern: '^\\d{5}(-\\d{4})?$', + maxLength: 10, + }, + }, + }, + }, + }, + }, +}; + +// ── Screen 8: Career Objective (Item 8C) ────────────────────────────────────── + +export const careerObjectiveUiSchema = { + educationalObjective: textareaUI({ + title: 'Educational or career objective (if known)', + hint: 'Describe your educational or career goal. For example: Bachelor of Arts in Accounting, welding certificate, police officer.', + charcount: true, + }), +}; + +export const careerObjectiveSchema = { + type: 'object', + properties: { + educationalObjective: { + type: 'string', + maxLength: 500, + }, + }, +}; + +// ── Screen 9: Benefit Authorization ────────────────────────────────────────── + +export const benefitAuthorizationUiSchema = { + highestRateAuthorization: checkboxGroupUI({ + title: 'Authorization for highest monthly rate', + hint: + 'This authorization is optional. If you do not check this box, VA will contact you if you are eligible for more than one benefit.', + required: false, + labels: { + authorized: + 'If during the review made by VA I am found to be eligible for more than one benefit, I authorize VA to pay the benefit with the highest monthly rate.', + }, + }), +}; + +export const benefitAuthorizationSchema = { + type: 'object', + properties: { + highestRateAuthorization: checkboxGroupSchema(['authorized']), + }, +}; \ No newline at end of file diff --git a/src/applications/education/22-1990n/config/chapters/educationTraining.unit.spec.js b/src/applications/education/22-1990n/config/chapters/educationTraining.unit.spec.js new file mode 100644 index 000000000000..942c1c12148f --- /dev/null +++ b/src/applications/education/22-1990n/config/chapters/educationTraining.unit.spec.js @@ -0,0 +1,98 @@ +import { expect } from 'chai'; +import { + typeOfTrainingUiSchema, + typeOfTrainingSchema, + flightTrainingRequirementsUiSchema, + flightTrainingRequirementsSchema, + schoolInformationUiSchema, + schoolInformationSchema, + careerObjectiveUiSchema, + careerObjectiveSchema, + benefitAuthorizationUiSchema, + benefitAuthorizationSchema, +} from './educationTraining'; + +describe('educationTraining chapter', () => { + describe('typeOfTrainingUiSchema', () => { + it('has typeOfEducation field', () => { + expect(typeOfTrainingUiSchema).to.have.property('typeOfEducation'); + }); + }); + + describe('typeOfTrainingSchema', () => { + it('has typeOfEducation in required array', () => { + expect(typeOfTrainingSchema.required).to.include('typeOfEducation'); + }); + }); + + describe('flightTrainingRequirementsUiSchema', () => { + it('has flightTrainingCourse field', () => { + expect(flightTrainingRequirementsUiSchema).to.have.property('flightTrainingCourse'); + }); + + it('has ui:validations array', () => { + expect(flightTrainingRequirementsUiSchema['ui:validations']).to.be.an('array'); + }); + + describe('validateFlightTrainingAcknowledged', () => { + let messages; + let errors; + + beforeEach(() => { + messages = []; + errors = { + flightTrainingCourse: { + requirementsAcknowledged: { addError: msg => messages.push(msg || '') }, + }, + }; + }); + + it('adds error when requirementsAcknowledged is false', () => { + const validationFn = flightTrainingRequirementsUiSchema['ui:validations'][0]; + validationFn(errors, { + flightTrainingCourse: { requirementsAcknowledged: false }, + }); + expect(messages.length).to.be.greaterThan(0); + }); + + it('does not add error when requirementsAcknowledged is true', () => { + const validationFn = flightTrainingRequirementsUiSchema['ui:validations'][0]; + validationFn(errors, { + flightTrainingCourse: { requirementsAcknowledged: true }, + }); + expect(messages).to.have.lengthOf(0); + }); + + it('does not add error when flightTrainingCourse is absent', () => { + const validationFn = flightTrainingRequirementsUiSchema['ui:validations'][0]; + validationFn(errors, {}); + expect(messages).to.have.lengthOf(0); + }); + }); + }); + + describe('schoolInformationUiSchema', () => { + it('has schoolSelected and schoolInfo fields', () => { + expect(schoolInformationUiSchema).to.have.property('schoolSelected'); + expect(schoolInformationUiSchema).to.have.property('schoolInfo'); + }); + }); + + describe('careerObjectiveUiSchema', () => { + it('has educationalObjective field', () => { + expect(careerObjectiveUiSchema).to.have.property('educationalObjective'); + }); + }); + + describe('careerObjectiveSchema', () => { + it('educationalObjective has maxLength 500', () => { + expect(careerObjectiveSchema.properties.educationalObjective.maxLength).to.equal(500); + }); + }); + + describe('benefitAuthorizationUiSchema', () => { + it('has highestRateAuthorization field', () => { + expect(benefitAuthorizationUiSchema).to.have.property('highestRateAuthorization'); + }); + }); +}); \ No newline at end of file diff --git a/src/applications/education/22-1990n/config/chapters/serviceInformation.js b/src/applications/education/22-1990n/config/chapters/serviceInformation.js new file mode 100644 index 000000000000..c077079228d9 --- /dev/null +++ b/src/applications/education/22-1990n/config/chapters/serviceInformation.js @@ -0,0 +1,154 @@ +import { + currentOrPastDateUI, + currentOrPastDateSchema, + textUI, + textSchema, + yesNoUI, + yesNoSchema, + fileInputMultipleUI, + fileInputMultipleSchema, +} from 'platform/forms-system/src/js/web-component-patterns'; + +// ── Screen 10: Active Duty Status (Items 9A, 9B) ────────────────────────────── + +export const activeDutyStatusUiSchema = { + activeDuty: yesNoUI({ + title: 'Are you now on active duty?', + required: () => true, + errorMessages: { + required: 'Please indicate whether you are currently on active duty', + }, + }), + terminalLeave: yesNoUI({ + title: 'Are you now on terminal leave just before discharge?', + required: () => true, + errorMessages: { + required: + 'Please indicate whether you are currently on terminal leave', + }, + }), +}; + +export const activeDutyStatusSchema = { + type: 'object', + required: ['activeDuty', 'terminalLeave'], + properties: { + activeDuty: yesNoSchema, + terminalLeave: yesNoSchema, + }, +}; + +// ── Screen 11: Military Service History (Item 10) ───────────────────────────── + +function validateServiceEntryDate(errors, value) { + if (!value) return; + const ncsDate = new Date('2003-10-01'); + const entryDate = new Date(value); + if (entryDate < ncsDate) { + errors.addError( + 'The NCS program requires first entry into military service on or after October 1, 2003. If this date is correct, you may not be eligible. Contact the GI Bill Hotline at 1-888-GI-BILL-1 with questions.', + ); + } +} + +function validateDateSeparatedAfterEntry(errors, fieldData, formData, schema, errorMessages, index) { + if (!fieldData) return; + const periods = formData.servicePeriods || []; + const period = periods[index]; + if (period && period.dateEnteredService && fieldData) { + const entered = new Date(period.dateEnteredService); + const separated = new Date(fieldData); + if (separated <= entered) { + errors.addError( + 'Date separated must be after the date entered service', + ); + } + } +} + +export const militaryServiceHistoryUiSchema = { + servicePeriods: { + 'ui:options': { + itemName: 'Service Period', + viewField: ({ formData: periodData }) => { + const comp = periodData && periodData.serviceComponent; + const entered = periodData && periodData.dateEnteredService; + return comp || entered || 'Service Period'; + }, + keepInPageOnReview: true, + }, + items: { + dateEnteredService: { + ...currentOrPastDateUI({ + title: 'Date entered service', + hint: 'For example: January 19 2003', + }), + 'ui:validations': [validateServiceEntryDate], + }, + dateSeparated: currentOrPastDateUI({ + title: 'Date separated from service', + hint: 'For example: June 1 2005 — leave blank if currently serving', + }), + serviceComponent: textUI({ + title: 'Service component', + hint: 'For example: USN, USAF, USAR, ARNG', + errorMessages: { required: 'Please enter your service component' }, + }), + serviceStatus: textUI({ + title: 'Service status', + hint: 'For example: Active duty, drilling reservist, IRR', + errorMessages: { required: 'Please enter your service status' }, + }), + }, + }, +}; + +export const militaryServiceHistorySchema = { + type: 'object', + required: ['servicePeriods'], + properties: { + servicePeriods: { + type: 'array', + minItems: 1, + items: { + type: 'object', + required: ['dateEnteredService', 'serviceComponent', 'serviceStatus'], + properties: { + dateEnteredService: currentOrPastDateSchema, + dateSeparated: { ...currentOrPastDateSchema }, + serviceComponent: { type: 'string', maxLength: 20 }, + serviceStatus: { type: 'string', maxLength: 50 }, + }, + }, + }, + }, +}; + +// ── Screen 12: Supporting Documents ────────────────────────────────────────── + +export const supportingDocumentsUiSchema = { + ddForm2863: fileInputMultipleUI({ + title: 'Upload your DD Form 2863 (NCS Election of Options)', + hint: + 'DD Form 2863 is required. Accepted file types: PDF, JPG, PNG. Maximum file size: 20MB.', + required: true, + errorMessages: { + required: 'Please upload your DD Form 2863', + }, + }), + ddForm214: fileInputMultipleUI({ + title: 'Upload your DD Form 214 (Member 4 copy)', + hint: + 'Upload your DD Form 214 Member 4 copy. If you are on terminal leave, you may upload when it is issued. Accepted file types: PDF, JPG, PNG. Maximum file size: 20MB.', + required: false, + errorMessages: {}, + }), +}; + +export const supportingDocumentsSchema = { + type: 'object', + properties: { + ddForm2863: fileInputMultipleSchema(), + ddForm214: fileInputMultipleSchema(), + }, +}; \ No newline at end of file diff --git a/src/applications/education/22-1990n/config/chapters/serviceInformation.unit.spec.js b/src/applications/education/22-1990n/config/chapters/serviceInformation.unit.spec.js new file mode 100644 index 000000000000..5441dcd9f852 --- /dev/null +++ b/src/applications/education/22-1990n/config/chapters/serviceInformation.unit.spec.js @@ -0,0 +1,101 @@ +import { expect } from 'chai'; +import { + activeDutyStatusUiSchema, + activeDutyStatusSchema, + militaryServiceHistoryUiSchema, + militaryServiceHistorySchema, + supportingDocumentsUiSchema, + supportingDocumentsSchema, +} from './serviceInformation'; + +describe('serviceInformation chapter', () => { + describe('activeDutyStatusUiSchema', () => { + it('has activeDuty and terminalLeave fields', () => { + expect(activeDutyStatusUiSchema).to.have.property('activeDuty'); + expect(activeDutyStatusUiSchema).to.have.property('terminalLeave'); + }); + }); + + describe('activeDutyStatusSchema', () => { + it('requires activeDuty and terminalLeave', () => { + expect(activeDutyStatusSchema.required).to.include('activeDuty'); + expect(activeDutyStatusSchema.required).to.include('terminalLeave'); + }); + }); + + describe('militaryServiceHistoryUiSchema', () => { + it('has servicePeriods field', () => { + expect(militaryServiceHistoryUiSchema).to.have.property('servicePeriods'); + }); + + it('servicePeriods items has required service fields', () => { + const items = militaryServiceHistoryUiSchema.servicePeriods.items; + expect(items).to.have.property('dateEnteredService'); + expect(items).to.have.property('serviceComponent'); + expect(items).to.have.property('serviceStatus'); + }); + + describe('validateServiceEntryDate', () => { + let messages; + let errors; + + beforeEach(() => { + messages = []; + errors = { addError: msg => messages.push(msg || '') }; + }); + + it('adds a warning for dates before Oct 1 2003', () => { + const dateField = militaryServiceHistoryUiSchema.servicePeriods.items.dateEnteredService; + const validations = dateField['ui:validations']; + expect(validations).to.be.an('array'); + validations.forEach(fn => { + if (typeof fn === 'function') { + fn(errors, '2002-01-01'); + } + }); + expect(messages.length).to.be.greaterThan(0); + }); + + it('does not add error for dates on or after Oct 1 2003', () => { + const dateField = militaryServiceHistoryUiSchema.servicePeriods.items.dateEnteredService; + const validations = dateField['ui:validations']; + validations.forEach(fn => { + if (typeof fn === 'function') { + fn(errors, '2004-05-01'); + } + }); + expect(messages).to.have.lengthOf(0); + }); + + it('does not error on null/undefined value', () => { + const dateField = militaryServiceHistoryUiSchema.servicePeriods.items.dateEnteredService; + const validations = dateField['ui:validations']; + expect(() => { + validations.forEach(fn => { + if (typeof fn === 'function') fn(errors, null); + }); + }).to.not.throw(); + }); + }); + }); + + describe('militaryServiceHistorySchema', () => { + it('requires servicePeriods', () => { + expect(militaryServiceHistorySchema.required).to.include('servicePeriods'); + }); + + it('servicePeriods items require dateEnteredService, serviceComponent, serviceStatus', () => { + const itemRequired = militaryServiceHistorySchema.properties.servicePeriods.items.required; + expect(itemRequired).to.include('dateEnteredService'); + expect(itemRequired).to.include('serviceComponent'); + expect(itemRequired).to.include('serviceStatus'); + }); + }); + + describe('supportingDocumentsUiSchema', () => { + it('has ddForm2863 and ddForm214 fields', () => { + expect(supportingDocumentsUiSchema).to.have.property('ddForm2863'); + expect(supportingDocumentsUiSchema).to.have.property('ddForm214'); + }); + }); +}); \ No newline at end of file diff --git a/src/applications/education/22-1990n/config/form.js b/src/applications/education/22-1990n/config/form.js new file mode 100644 index 000000000000..34035a3e41a9 --- /dev/null +++ b/src/applications/education/22-1990n/config/form.js @@ -0,0 +1,238 @@ +import environment from 'platform/utilities/environment'; +import footerContent from 'platform/forms/components/FormFooter'; +import { VA_FORM_IDS } from 'platform/forms/constants'; + +import manifest from '../manifest.json'; +import IntroductionPage from '../containers/IntroductionPage'; +import ConfirmationPage from '../containers/ConfirmationPage'; +import GetFormHelp from '../components/GetFormHelp'; +import transformForSubmit from './submitForm'; + +import { + personalInformationUiSchema, + personalInformationSchema, + nameUiSchema, + nameSchema, + addressUiSchema, + addressSchema, + contactInformationUiSchema, + contactInformationSchema, +} from './chapters/applicantInformation'; + +import { + typeOfTrainingUiSchema, + typeOfTrainingSchema, + flightTrainingRequirementsUiSchema, + flightTrainingRequirementsSchema, + schoolInformationUiSchema, + schoolInformationSchema, + careerObjectiveUiSchema, + careerObjectiveSchema, + benefitAuthorizationUiSchema, + benefitAuthorizationSchema, +} from './chapters/educationTraining'; + +import { + activeDutyStatusUiSchema, + activeDutyStatusSchema, + militaryServiceHistoryUiSchema, + militaryServiceHistorySchema, + supportingDocumentsUiSchema, + supportingDocumentsSchema, +} from './chapters/serviceInformation'; + +import { + rotcScholarshipUiSchema, + rotcScholarshipSchema, + federalTuitionAssistanceUiSchema, + federalTuitionAssistanceSchema, + govtEmployeeUiSchema, + govtEmployeeSchema, +} from './chapters/concurrentBenefits'; + +import { + paymentInformationUiSchema, + paymentInformationSchema, + bankDocumentUploadUiSchema, + bankDocumentUploadSchema, +} from './chapters/directDeposit'; + +/** @type {FormConfig} */ +const formConfig = { + rootUrl: manifest.rootUrl, + urlPrefix: '/', + submitUrl: `${environment.API_URL}/v0/education_benefits_claims/1990n`, + transformForSubmit, + trackingPrefix: 'edu-1990n-', + v3SegmentedProgressBar: true, + introduction: IntroductionPage, + confirmation: ConfirmationPage, + footerContent, + getHelp: GetFormHelp, + formId: VA_FORM_IDS.FORM_22_1990N, + saveInProgress: { + messages: { + inProgress: + 'Your NCS education benefits application (22-1990n) is in progress.', + expired: + 'Your saved NCS education benefits application has expired. If you want to apply for education benefits under the NCS program, please start a new application.', + saved: 'Your NCS education benefits application has been saved.', + }, + }, + version: 0, + prefillEnabled: true, + savedFormMessages: { + notFound: + 'Please start over to apply for NCS education benefits.', + noAuth: + 'Please sign in again to continue your NCS education benefits application.', + }, + title: + 'Apply for VA Education Benefits Under the National Call to Service Program', + subTitle: 'VA Form 22-1990n', + defaultDefinitions: {}, + chapters: { + applicantInformation: { + title: 'Applicant information', + pages: { + personalInformation: { + path: 'applicant-information/personal-information', + title: 'Personal information', + uiSchema: personalInformationUiSchema, + schema: personalInformationSchema, + }, + name: { + path: 'applicant-information/name', + title: 'Your name', + uiSchema: nameUiSchema, + schema: nameSchema, + }, + address: { + path: 'applicant-information/address', + title: 'Your address', + uiSchema: addressUiSchema, + schema: addressSchema, + }, + contactInformation: { + path: 'applicant-information/contact-information', + title: 'Contact information', + uiSchema: contactInformationUiSchema, + schema: contactInformationSchema, + }, + }, + }, + educationTraining: { + title: 'Education and training', + pages: { + typeOfTraining: { + path: 'education-training/type-of-training', + title: 'Type of education or training', + uiSchema: typeOfTrainingUiSchema, + schema: typeOfTrainingSchema, + }, + flightTrainingRequirements: { + path: 'education-training/flight-training-requirements', + title: 'Flight training requirements', + depends: formData => + Array.isArray(formData.typeOfEducation) && + formData.typeOfEducation.includes('vocationalFlightTraining'), + uiSchema: flightTrainingRequirementsUiSchema, + schema: flightTrainingRequirementsSchema, + }, + schoolInformation: { + path: 'education-training/school-information', + title: 'School information', + uiSchema: schoolInformationUiSchema, + schema: schoolInformationSchema, + }, + careerObjective: { + path: 'education-training/career-objective', + title: 'Educational or career objective', + uiSchema: careerObjectiveUiSchema, + schema: careerObjectiveSchema, + }, + benefitAuthorization: { + path: 'education-training/benefit-authorization', + title: 'Benefit authorization', + uiSchema: benefitAuthorizationUiSchema, + schema: benefitAuthorizationSchema, + }, + }, + }, + serviceInformation: { + title: 'Service information', + pages: { + activeDutyStatus: { + path: 'service-information/active-duty-status', + title: 'Active duty status', + uiSchema: activeDutyStatusUiSchema, + schema: activeDutyStatusSchema, + }, + militaryServiceHistory: { + path: 'service-information/military-service-history', + title: 'Military service history', + uiSchema: militaryServiceHistoryUiSchema, + schema: militaryServiceHistorySchema, + }, + supportingDocuments: { + path: 'service-information/supporting-documents', + title: 'Supporting documents', + uiSchema: supportingDocumentsUiSchema, + schema: supportingDocumentsSchema, + }, + }, + }, + concurrentBenefits: { + title: 'Concurrent benefits', + pages: { + rotcScholarship: { + path: 'concurrent-benefits/rotc-scholarship', + title: 'Senior ROTC scholarship', + uiSchema: rotcScholarshipUiSchema, + schema: rotcScholarshipSchema, + }, + federalTuitionAssistance: { + path: 'concurrent-benefits/federal-tuition-assistance', + title: 'Federal tuition assistance', + depends: formData => formData.activeDuty === true, + uiSchema: federalTuitionAssistanceUiSchema, + schema: federalTuitionAssistanceSchema, + }, + govtCivilianEmployment: { + path: 'concurrent-benefits/government-civilian-employment', + title: 'Government civilian employment', + uiSchema: govtEmployeeUiSchema, + schema: govtEmployeeSchema, + }, + }, + }, + directDeposit: { + title: 'Direct deposit', + pages: { + paymentInformation: { + path: 'direct-deposit/payment-information', + title: 'Payment information', + uiSchema: paymentInformationUiSchema, + schema: paymentInformationSchema, + }, + uploadBankDocument: { + path: 'direct-deposit/upload-bank-document', + title: 'Upload bank document', + depends: formData => + formData.directDeposit && + formData.directDeposit.bankAccount && + formData.directDeposit.bankAccount.routingNumber && + !( + formData.directDeposit.noDirectDeposit && + formData.directDeposit.noDirectDeposit.declined + ), + uiSchema: bankDocumentUploadUiSchema, + schema: bankDocumentUploadSchema, + }, + }, + }, + }, +}; + +export default formConfig; +export { formConfig }; \ No newline at end of file diff --git a/src/applications/education/22-1990n/config/form.unit.spec.js b/src/applications/education/22-1990n/config/form.unit.spec.js new file mode 100644 index 000000000000..9dba1c94ad4c --- /dev/null +++ b/src/applications/education/22-1990n/config/form.unit.spec.js @@ -0,0 +1,136 @@ +import { expect } from 'chai'; +import formConfig from './form'; + +describe('formConfig', () => { + it('has required top-level properties', () => { + expect(formConfig).to.have.property('formId'); + expect(formConfig).to.have.property('title'); + expect(formConfig).to.have.property('chapters'); + expect(formConfig).to.have.property('introduction'); + expect(formConfig).to.have.property('confirmation'); + expect(formConfig).to.have.property('transformForSubmit'); + expect(formConfig).to.have.property('trackingPrefix'); + }); + + it('has saveInProgress messages', () => { + expect(formConfig.saveInProgress).to.have.property('messages'); + expect(formConfig.saveInProgress.messages).to.have.property('inProgress'); + expect(formConfig.saveInProgress.messages).to.have.property('expired'); + expect(formConfig.saveInProgress.messages).to.have.property('saved'); + }); + + it('has correct formId', () => { + expect(formConfig.formId).to.be.a('string'); + expect(formConfig.formId.toLowerCase()).to.include('1990n'); + }); + + it('has prefillEnabled true', () => { + expect(formConfig.prefillEnabled).to.equal(true); + }); + + it('has rootUrl matching manifest', () => { + expect(formConfig.rootUrl).to.include('1990n'); + }); + + it('has all required chapters', () => { + const chapters = Object.keys(formConfig.chapters); + expect(chapters).to.include('applicantInformation'); + expect(chapters).to.include('educationTraining'); + expect(chapters).to.include('serviceInformation'); + expect(chapters).to.include('concurrentBenefits'); + expect(chapters).to.include('directDeposit'); + }); + + it('every page has path, title, uiSchema, and schema', () => { + Object.entries(formConfig.chapters).forEach(([chapterName, chapter]) => { + Object.entries(chapter.pages).forEach(([pageName, page]) => { + expect(page, `${chapterName}.${pageName} missing path`).to.have.property('path'); + expect(page, `${chapterName}.${pageName} missing title`).to.have.property('title'); + expect(page, `${chapterName}.${pageName} missing uiSchema`).to.have.property('uiSchema'); + expect(page, `${chapterName}.${pageName} missing schema`).to.have.property('schema'); + }); + }); + }); + + describe('depends functions', () => { + it('flightTrainingRequirements depends: returns true when vocationalFlightTraining selected', () => { + const page = formConfig.chapters.educationTraining.pages.flightTrainingRequirements; + expect(page.depends).to.be.a('function'); + expect(() => + page.depends({ typeOfEducation: ['vocationalFlightTraining'] }), + ).to.not.throw(); + expect( + page.depends({ typeOfEducation: ['vocationalFlightTraining'] }), + ).to.equal(true); + }); + + it('flightTrainingRequirements depends: returns false when not selected', () => { + const page = formConfig.chapters.educationTraining.pages.flightTrainingRequirements; + expect(page.depends({ typeOfEducation: ['collegeOrOtherSchool'] })).to.equal(false); + }); + + it('flightTrainingRequirements depends: returns false for null/undefined', () => { + const page = formConfig.chapters.educationTraining.pages.flightTrainingRequirements; + expect(() => page.depends({})).to.not.throw(); + expect(page.depends({})).to.equal(false); + expect(() => page.depends({ typeOfEducation: null })).to.not.throw(); + }); + + it('federalTuitionAssistance depends: returns true when activeDuty is true', () => { + const page = formConfig.chapters.concurrentBenefits.pages.federalTuitionAssistance; + expect(page.depends).to.be.a('function'); + expect(() => page.depends({ activeDuty: true })).to.not.throw(); + expect(page.depends({ activeDuty: true })).to.equal(true); + }); + + it('federalTuitionAssistance depends: returns false when activeDuty is false', () => { + const page = formConfig.chapters.concurrentBenefits.pages.federalTuitionAssistance; + expect(page.depends({ activeDuty: false })).to.equal(false); + }); + + it('federalTuitionAssistance depends: handles null gracefully', () => { + const page = formConfig.chapters.concurrentBenefits.pages.federalTuitionAssistance; + expect(() => page.depends({})).to.not.throw(); + expect(() => page.depends(null)).to.not.throw(); + }); + + it('uploadBankDocument depends: returns true when bankAccount with routing and no opt-out', () => { + const page = formConfig.chapters.directDeposit.pages.uploadBankDocument; + expect(page.depends).to.be.a('function'); + expect(() => + page.depends({ + directDeposit: { + bankAccount: { routingNumber: '123456789' }, + noDirectDeposit: {}, + }, + }), + ).to.not.throw(); + expect( + page.depends({ + directDeposit: { + bankAccount: { routingNumber: '123456789' }, + noDirectDeposit: {}, + }, + }), + ).to.equal(true); + }); + + it('uploadBankDocument depends: returns false when opted out', () => { + const page = formConfig.chapters.directDeposit.pages.uploadBankDocument; + expect( + page.depends({ + directDeposit: { + bankAccount: { routingNumber: '123456789' }, + noDirectDeposit: { declined: true }, + }, + }), + ).to.equal(false); + }); + + it('uploadBankDocument depends: returns false when no bankAccount', () => { + const page = formConfig.chapters.directDeposit.pages.uploadBankDocument; + expect(page.depends({})).to.equal(false); + expect(page.depends({ directDeposit: {} })).to.equal(false); + }); + }); +}); \ No newline at end of file diff --git a/src/applications/education/22-1990n/config/submitForm.js b/src/applications/education/22-1990n/config/submitForm.js new file mode 100644 index 000000000000..9a6010286cf2 --- /dev/null +++ b/src/applications/education/22-1990n/config/submitForm.js @@ -0,0 +1,14 @@ +import { transformForSubmit as platformTransformForSubmit } from 'platform/forms-system/src/js/helpers'; + +export default function transformForSubmit(formConfig, form) { + const transformedData = JSON.parse( + platformTransformForSubmit(formConfig, form), + ); + + return JSON.stringify({ + educationBenefitsClaim: { + form: JSON.stringify(transformedData), + formType: '1990n', + }, + }); +} \ No newline at end of file diff --git a/src/applications/education/22-1990n/config/submitForm.unit.spec.js b/src/applications/education/22-1990n/config/submitForm.unit.spec.js new file mode 100644 index 000000000000..d29d6faa06e8 --- /dev/null +++ b/src/applications/education/22-1990n/config/submitForm.unit.spec.js @@ -0,0 +1,41 @@ +import { expect } from 'chai'; +import transformForSubmit from './submitForm'; +import formConfig from './form'; + +describe('submitForm transformer', () => { + it('exports a function', () => { + expect(transformForSubmit).to.be.a('function'); + }); + + it('returns a JSON string wrapping educationBenefitsClaim', () => { + const mockForm = { + data: { + veteranSocialSecurityNumber: '123456789', + veteranFullName: { first: 'John', last: 'Doe' }, + typeOfEducation: ['collegeOrOtherSchool'], + activeDuty: false, + terminalLeave: false, + servicePeriods: [ + { + dateEnteredService: '2004-01-01', + serviceComponent: 'USMC', + serviceStatus: 'Active duty', + }, + ], + seniorRotcScholarship: false, + }, + pages: {}, + }; + + let result; + expect(() => { + result = transformForSubmit(formConfig, mockForm); + }).to.not.throw(); + + expect(result).to.be.a('string'); + const parsed = JSON.parse(result); + expect(parsed).to.have.property('educationBenefitsClaim'); + expect(parsed.educationBenefitsClaim).to.have.property('form'); + expect(parsed.educationBenefitsClaim).to.have.property('formType', '1990n'); + }); +}); \ No newline at end of file diff --git a/src/applications/education/22-1990n/containers/App.jsx b/src/applications/education/22-1990n/containers/App.jsx new file mode 100644 index 000000000000..ce0ad43c961d --- /dev/null +++ b/src/applications/education/22-1990n/containers/App.jsx @@ -0,0 +1,18 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import RoutedSavableApp from 'platform/forms/save-in-progress/RoutedSavableApp'; +import formConfig from '../config/form'; + +export default function App({ location, children }) { + return ( + + {children} + + ); +} + +App.propTypes = { + children: PropTypes.node, + location: PropTypes.object, +}; \ No newline at end of file diff --git a/src/applications/education/22-1990n/containers/App.unit.spec.jsx b/src/applications/education/22-1990n/containers/App.unit.spec.jsx new file mode 100644 index 000000000000..3fae5327100d --- /dev/null +++ b/src/applications/education/22-1990n/containers/App.unit.spec.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { expect } from 'chai'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import formConfig from '../config/form'; +import App from './App'; + +const createMockStore = (overrides = {}) => ({ + getState: () => ({ + user: { + login: { currentlyLoggedIn: false }, + profile: { + savedForms: [], + prefillsAvailable: [], + loa: { current: 3, highest: 3 }, + verified: true, + dob: '1990-01-01', + claims: { appeals: false }, + ...overrides.user?.profile, + }, + ...overrides.user, + }, + form: { + formId: formConfig.formId, + loadedStatus: 'success', + savedStatus: '', + loadedData: { metadata: {} }, + data: {}, + ...overrides.form, + }, + scheduledDowntime: { + globalDowntime: null, + isReady: true, + isPending: false, + serviceMap: { get() {} }, + dismissedDowntimeWarnings: [], + }, + ...overrides, + }), + subscribe: () => {}, + dispatch: () => {}, +}); + +describe('App container', () => { + it('renders without throwing', () => { + const store = createMockStore(); + expect(() => + render( + + +
child
+
+
, + ), + ).to.not.throw(); + }); + + it('renders children', () => { + const store = createMockStore(); + const { getByText } = render( + + +
test child content
+
+
, + ); + expect(getByText('test child content')).to.exist; + }); +}); \ No newline at end of file diff --git a/src/applications/education/22-1990n/containers/ConfirmationPage.jsx b/src/applications/education/22-1990n/containers/ConfirmationPage.jsx new file mode 100644 index 000000000000..7a4953b5b878 --- /dev/null +++ b/src/applications/education/22-1990n/containers/ConfirmationPage.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; + +import { ConfirmationView } from 'platform/forms-system/src/js/components/ConfirmationView'; + +export const ConfirmationPage = ({ route }) => { + const form = useSelector(state => state.form || {}); + const submission = form?.submission || {}; + const submitDate = submission?.timestamp || ''; + const confirmationNumber = + submission?.response?.confirmationNumber || ''; + + const veteranName = form?.data?.veteranFullName || {}; + + const submissionAlertContent = ( + <> +

+ We've received your application for education benefits under the + National Call to Service program. We'll review your application and + contact you if we need more information. +

+ {confirmationNumber && ( +

+ Your confirmation number is{' '} + {confirmationNumber}. +

+ )} + + ); + + return ( + + } + /> +
+ +
+ + } + item2Header="Certificate of Eligibility issued if eligible" + item2Content="If you're eligible, we'll send you a Certificate of Eligibility (COE). Present your COE to your school's Veterans Certifying Official." + item2Actions={

} + /> + + + + + ); +}; + +ConfirmationPage.propTypes = { + route: PropTypes.shape({ + formConfig: PropTypes.object, + }), +}; + +export default ConfirmationPage; \ No newline at end of file diff --git a/src/applications/education/22-1990n/containers/ConfirmationPage.unit.spec.jsx b/src/applications/education/22-1990n/containers/ConfirmationPage.unit.spec.jsx new file mode 100644 index 000000000000..a48ef8c7d35d --- /dev/null +++ b/src/applications/education/22-1990n/containers/ConfirmationPage.unit.spec.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { expect } from 'chai'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import formConfig from '../config/form'; +import { ConfirmationPage } from './ConfirmationPage'; + +const createMockStore = (overrides = {}) => ({ + getState: () => ({ + user: { + login: { currentlyLoggedIn: false }, + profile: { + savedForms: [], + prefillsAvailable: [], + loa: { current: 3, highest: 3 }, + verified: true, + dob: '1990-01-01', + claims: { appeals: false }, + }, + }, + form: { + formId: formConfig.formId, + loadedStatus: 'success', + savedStatus: '', + loadedData: { metadata: {} }, + data: { + veteranFullName: { first: 'John', last: 'Doe' }, + }, + submission: { + response: { confirmationNumber: '1234567890' }, + timestamp: new Date('2024-01-15'), + }, + ...overrides.form, + }, + scheduledDowntime: { + globalDowntime: null, + isReady: true, + isPending: false, + serviceMap: { get() {} }, + dismissedDowntimeWarnings: [], + }, + ...overrides, + }), + subscribe: () => {}, + dispatch: () => {}, +}); + +const mockRoute = { formConfig }; + +describe('ConfirmationPage', () => { + it('renders without throwing', () => { + const store = createMockStore(); + expect(() => + render( + + + , + ), + ).to.not.throw(); + }); + + it('renders a va-alert element', () => { + const store = createMockStore(); + const { container } = render( + + + , + ); + const alerts = container.querySelectorAll('va-alert'); + expect(alerts.length).to.be.greaterThan(0); + }); + + it('renders the confirmation number when present', () => { + const store = createMockStore(); + const { getByText } = render( + + + , + ); + expect(getByText('1234567890')).to.exist; + }); + + it('renders a print button', () => { + const store = createMockStore(); + const { container } = render( + + + , + ); + const buttons = container.querySelectorAll('va-button, button'); + expect(buttons.length).to.be.greaterThan(0); + }); +}); \ No newline at end of file diff --git a/src/applications/education/22-1990n/containers/IntroductionPage.jsx b/src/applications/education/22-1990n/containers/IntroductionPage.jsx new file mode 100644 index 000000000000..41df38f7d59a --- /dev/null +++ b/src/applications/education/22-1990n/containers/IntroductionPage.jsx @@ -0,0 +1,161 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; + +import FormTitle from 'platform/forms-system/src/js/components/FormTitle'; +import SaveInProgressIntro from 'platform/forms/save-in-progress/SaveInProgressIntro'; +import { focusElement, scrollToTop } from 'platform/utilities/ui'; + +const TITLE = 'Apply for VA Education Benefits Under the National Call to Service Program'; +const SUBTITLE = 'VA Form 22-1990n'; + +const OMB_RES_BURDEN = 15; +const OMB_NUMBER = '2900-0154'; +const OMB_EXP_DATE = '03/31/2026'; + +export const IntroductionPage = ({ route }) => { + const { formConfig, pageList } = route; + + useEffect(() => { + scrollToTop(); + focusElement('h1'); + }, []); + + return ( +

+ + +

+ Use this form to apply for education benefits under the National Call to + Service (NCS) program. The NCS program is for servicemembers who signed + an NCS enlistment contract with the Department of Defense. +

+ +

Eligibility requirements

+

You may be eligible for NCS education benefits if you:

+
    +
  • First entered military service on or after October 1, 2003
  • +
  • + Signed an enlistment contract with the Department of Defense under the + National Call to Service program +
  • +
  • + Elected one of the two education incentives provided by the NCS + program, as documented on DD Form 2863 +
  • +
+ + +

+ Save time — sign in to pre-fill your personal + information and save your progress as you go. +

+
+ +

What you'll need to apply

+

+ Please have the following documents available before you begin. You will + need to upload them during the application. +

+
    +
  • + DD Form 2863 (NCS Election of Options) — required +
  • +
  • + DD Form 214 (Member 4 copy) — required (may be + submitted later if you are on terminal leave) +
  • +
  • + Voided personal check or deposit slip — required if + you choose to enroll in direct deposit +
  • +
+ + + +

+ The NCS program was created under Section 510, Title 10, U.S. Code. + To be eligible, you must have completed the active duty component of + the NCS program (typically 15 months) and elected either: +

+
    +
  • + A cash bonus, followed by service in the Selected Reserve, or +
  • +
  • + Education assistance under Chapter 30 (Montgomery GI Bill), or +
  • +
  • + Student loan repayment under the College Loan Repayment Program, + or +
  • +
  • + A combination of these benefits as specified on DD Form 2863 +
  • +
+

+ Eligibility is determined by VBA adjudicators after your application + is submitted. Filing this form does not guarantee benefits. +

+
+ +

+ + PRIVACY ACT INFORMATION (Title 38, U.S.C. 3471; 38 CFR 21.9635): + {' '} + The information requested on this form is used to determine your + eligibility for VA education benefits. It is authorized by Title 38, + U.S.C., Chapter 30, and may be disclosed pursuant to a routine use + identified in the Privacy Act system of records{' '} + 58VA21/22/28 — Compensation, Pension, Education and + Veteran Readiness and Employment Records — VA. +

+

+ Providing this information is voluntary. Failure to furnish the + information will delay or may prevent a decision on your entitlement + to benefits. +

+

+ Respondent Burden: We need this information to + determine your eligibility for education benefits. Title 38 U.S.C. + 501(a) and (b) authorizes collection of this information. The + estimated burden is {OMB_RES_BURDEN} minutes to + complete this form. OMB Control No.{' '} + {OMB_NUMBER}, expires{' '} + {OMB_EXP_DATE}. +

+
+
+ + + +
+ +
+
+ ); +}; + +IntroductionPage.propTypes = { + route: PropTypes.shape({ + formConfig: PropTypes.shape({ + prefillEnabled: PropTypes.bool, + saveInProgress: PropTypes.shape({ + messages: PropTypes.shape({}), + }), + }), + pageList: PropTypes.array, + }), +}; + +export default IntroductionPage; \ No newline at end of file diff --git a/src/applications/education/22-1990n/containers/IntroductionPage.unit.spec.jsx b/src/applications/education/22-1990n/containers/IntroductionPage.unit.spec.jsx new file mode 100644 index 000000000000..aa4d6d76d25f --- /dev/null +++ b/src/applications/education/22-1990n/containers/IntroductionPage.unit.spec.jsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import * as uiUtils from 'platform/utilities/ui'; +import formConfig from '../config/form'; +import { IntroductionPage } from './IntroductionPage'; + +const createMockStore = (overrides = {}) => ({ + getState: () => ({ + user: { + login: { currentlyLoggedIn: false }, + profile: { + savedForms: [], + prefillsAvailable: [], + loa: { current: 3, highest: 3 }, + verified: true, + dob: '1990-01-01', + claims: { appeals: false }, + ...overrides.user?.profile, + }, + ...overrides.user, + }, + form: { + formId: formConfig.formId, + loadedStatus: 'success', + savedStatus: '', + loadedData: { metadata: {} }, + data: {}, + ...overrides.form, + }, + scheduledDowntime: { + globalDowntime: null, + isReady: true, + isPending: false, + serviceMap: { get() {} }, + dismissedDowntimeWarnings: [], + }, + ...overrides, + }), + subscribe: () => {}, + dispatch: () => {}, +}); + +const mockRoute = { + formConfig, + pageList: [{ path: '/introduction' }, { path: '/applicant-information/personal-information' }], +}; + +describe('IntroductionPage', () => { + let scrollStub; + let focusStub; + + beforeEach(() => { + scrollStub = sinon.stub(uiUtils, 'scrollToTop'); + focusStub = sinon.stub(uiUtils, 'focusElement'); + }); + + afterEach(() => { + scrollStub.restore(); + focusStub.restore(); + }); + + it('renders without throwing', () => { + const store = createMockStore(); + expect(() => + render( + + + , + ), + ).to.not.throw(); + }); + + it('renders the form title', () => { + const store = createMockStore(); + const { getByText } = render( + + + , + ); + expect( + getByText( + 'Apply for VA Education Benefits Under the National Call to Service Program', + ), + ).to.exist; + }); + + it('renders va-omb-info element', () => { + const store = createMockStore(); + const { container } = render( + + + , + ); + expect(container.querySelector('va-omb-info')).to.exist; + }); + + it('calls scrollToTop on mount', () => { + const store = createMockStore(); + render( + + + , + ); + expect(scrollStub.called).to.be.true; + }); +}); \ No newline at end of file diff --git a/src/applications/education/22-1990n/manifest.json b/src/applications/education/22-1990n/manifest.json new file mode 100644 index 000000000000..1d3fd4738d9c --- /dev/null +++ b/src/applications/education/22-1990n/manifest.json @@ -0,0 +1,12 @@ +{ + "appName": "Apply for VA Education Benefits Under the National Call to Service Program", + "entryFile": "./app-entry.jsx", + "entryName": "22-1990n", + "rootUrl": "/education/apply-for-education-benefits/application/1990n", + "productId": "va-form-22-1990n", + "template": { + "vagovprod": false, + "layout": "page-react.html", + "includeBreadcrumbs": true + } +} \ No newline at end of file diff --git a/src/applications/education/22-1990n/reducers/index.js b/src/applications/education/22-1990n/reducers/index.js new file mode 100644 index 000000000000..89023ae95ba9 --- /dev/null +++ b/src/applications/education/22-1990n/reducers/index.js @@ -0,0 +1,4 @@ +import { createSaveInProgressFormReducer } from 'platform/forms/save-in-progress/reducers'; +import formConfig from '../config/form'; + +export default { form: createSaveInProgressFormReducer(formConfig) }; \ No newline at end of file diff --git a/src/applications/education/22-1990n/reducers/index.unit.spec.js b/src/applications/education/22-1990n/reducers/index.unit.spec.js new file mode 100644 index 000000000000..7c8bb0e95884 --- /dev/null +++ b/src/applications/education/22-1990n/reducers/index.unit.spec.js @@ -0,0 +1,21 @@ +import { expect } from 'chai'; +import reducers from './index'; + +describe('reducers/index', () => { + it('returns an object with a form slice', () => { + expect(reducers).to.be.an('object'); + expect(reducers).to.have.property('form'); + }); + + it('form reducer is a function', () => { + expect(reducers.form).to.be.a('function'); + }); + + it('form reducer initializes state without throwing', () => { + let result; + expect(() => { + result = reducers.form(undefined, { type: '@@INIT' }); + }).to.not.throw(); + expect(result).to.be.an('object'); + }); +}); \ No newline at end of file diff --git a/src/applications/education/22-1990n/routes.js b/src/applications/education/22-1990n/routes.js new file mode 100644 index 000000000000..b0e9ebf4be70 --- /dev/null +++ b/src/applications/education/22-1990n/routes.js @@ -0,0 +1,13 @@ +import { createRoutesWithSaveInProgress } from 'platform/forms/save-in-progress/helpers'; + +import formConfig from './config/form'; +import App from './containers/App'; + +const route = { + path: '/', + component: App, + indexRoute: { onEnter: (_nextState, replace) => replace('/introduction') }, + childRoutes: createRoutesWithSaveInProgress(formConfig), +}; + +export default route; \ No newline at end of file diff --git a/src/applications/education/22-1990n/routes.unit.spec.js b/src/applications/education/22-1990n/routes.unit.spec.js new file mode 100644 index 000000000000..3640f8242bd5 --- /dev/null +++ b/src/applications/education/22-1990n/routes.unit.spec.js @@ -0,0 +1,30 @@ +import { expect } from 'chai'; +import route from './routes'; + +describe('routes', () => { + it('exports a single route object (not an array)', () => { + expect(route).to.be.an('object'); + expect(route).not.to.be.an('array'); + }); + + it('has path "/"', () => { + expect(route.path).to.equal('/'); + }); + + it('has an indexRoute that redirects to /introduction', () => { + expect(route.indexRoute).to.be.an('object'); + expect(route.indexRoute.onEnter).to.be.a('function'); + const replaceCalls = []; + route.indexRoute.onEnter({}, path => replaceCalls.push(path)); + expect(replaceCalls).to.deep.equal(['/introduction']); + }); + + it('has a component property', () => { + expect(route.component).to.be.a('function'); + }); + + it('has childRoutes array', () => { + expect(route.childRoutes).to.be.an('array'); + expect(route.childRoutes.length).to.be.greaterThan(0); + }); +}); \ No newline at end of file diff --git a/src/applications/education/22-1990n/sass/22-1990n.scss b/src/applications/education/22-1990n/sass/22-1990n.scss new file mode 100644 index 000000000000..3faeca4a9389 --- /dev/null +++ b/src/applications/education/22-1990n/sass/22-1990n.scss @@ -0,0 +1,3 @@ +@import "~@department-of-veterans-affairs/css-library/dist/stylesheets/modules/m-form-process"; +@import "../../../../platform/forms/sass/m-schemaform"; +@import "../../../../platform/forms/sass/m-form-confirmation"; \ No newline at end of file