diff --git a/.gitignore b/.gitignore index adfa1de52..44d2df4bd 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ yarn-error.log* !.yarn/releases !.yarn/sdks !.yarn/versions + +.vscode/launch.json +.vscode/settings.json \ No newline at end of file diff --git a/__tests__/client-state/mst.test.ts b/__tests__/client-state/mst.test.ts new file mode 100644 index 000000000..246d64bdc --- /dev/null +++ b/__tests__/client-state/mst.test.ts @@ -0,0 +1,164 @@ +import { getSnapshot, Instance } from 'mobx-state-tree' +import { Form } from '../../client-state/models/Form' +import { FormField } from '../../client-state/models/FormField' +import { RootStore } from '../../client-state/store' +import { + LegalStatus, + LivingCountry, + MaritalStatus, +} from '../../utils/api/definitions/enums' +import { mockPartialGetRequest } from '../pages/api/factory' + +describe('test the mobx state tree nodes', () => { + let root: Instance + beforeEach(() => { + root = RootStore.create({ + form: {}, + oas: {}, + gis: {}, + afs: {}, + allowance: {}, + summary: {}, + }) + }) + + function fillOutForm(form: Instance) { + form.fields[0].setValue('20000') // income + form.fields[1].setValue('65') //age + form.fields[2].setValue(MaritalStatus.SINGLE) + form.fields[3].setValue(LivingCountry.CANADA) + form.fields[4].setValue(LegalStatus.CANADIAN_CITIZEN) + form.fields[5].setValue('true') // Lived in Canada whole life + } + + async function instantiateFormFields() { + return await mockPartialGetRequest({ + income: '20000' as unknown as number, + }) + } + + it('can add form fields via addField', () => { + const form: Instance = root.form + + expect(form.fields).toHaveLength(0) + form.addField({ + key: 'income', + type: 'currency', + label: 'What is your net annual income?', + category: { + key: 'incomeDetails', + text: 'Income Details', + }, + order: 1, + }) + expect(form.fields).toHaveLength(1) + }) + + it('can report if a form field is filled out or not', async () => { + const res = await instantiateFormFields() + const form: Instance = root.form + form.setupForm(res.body.fieldData) + expect(form.fields[0].filled).toBe(false) + form.fields[0].setValue('10000') + expect(form.fields[0].filled).toBe(true) + }) + + it('can create a form via an api request', async () => { + const res = await instantiateFormFields() + const form: Instance = root.form + form.setupForm(res.body.fieldData) + expect(form.fields).toHaveLength(6) + }) + + it("can clear an entire form's fields", async () => { + const res = await instantiateFormFields() + const form: Instance = root.form + form.setupForm(res.body.fieldData) + expect(form.fields).toHaveLength(6) + form.removeAllFields() + expect(form.fields).toHaveLength(0) + }) + + it('can clear all values from a form', async () => { + const res = await instantiateFormFields() + const form: Instance = root.form + form.setupForm(res.body.fieldData) + expect(form.fields).toHaveLength(6) + form.clearForm() + + for (const field of form.fields) { + expect(field.value).toBeNull() + } + }) + + it("can predictably retrieve a form field by it's key", async () => { + const form: Instance = root.form + form.addField({ + key: 'income', + type: 'currency', + label: 'What is your current annual net income in Canadian Dollars?', + category: { key: 'incomeDetails', text: 'Income Details' }, + order: 1, + placeholder: '$20,000', + default: undefined, + value: null, + options: [], + error: undefined, + }) + + const field = form.getFieldByKey('income') + + expect(field.key).toEqual('income') + expect(field.label).toEqual( + 'What is your current annual net income in Canadian Dollars?' + ) + }) + + it("can sanitize a form field's value as expected", async () => { + const res = await instantiateFormFields() + const field: Instance = FormField.create( + res.body.fieldData[0] + ) + field.setValue('$20,000.00') + expect(field.sanitizeInput()).toEqual('20000.00') + }) + + it('can report on empty fields in a form as expected', async () => { + const res = await instantiateFormFields() + const form: Instance = root.form + form.setupForm(res.body.fieldData) + fillOutForm(form) + expect(form.validateAgainstEmptyFields()).toBe(false) // no errors exist + form.fields[0].setValue(null) + expect(form.validateAgainstEmptyFields()).toBe(true) + }) + + it('can report on the forms progress', async () => { + const res = await instantiateFormFields() + const form: Instance = root.form + form.setupForm(res.body.fieldData) + expect(form.progress.income).toBe(false) + expect(form.progress.personal).toBe(false) + expect(form.progress.legal).toBe(false) + fillOutForm(form) + expect(form.progress.income).toBe(true) // do not store progress in a const, there is a caching point in mst and it needs to be observed directly to re-derive it's state + expect(form.progress.personal).toBe(true) + expect(form.progress.legal).toBe(true) + }) + + it('can build a consistent querystring', async () => { + const res = await instantiateFormFields() + const form: Instance = root.form + form.setupForm(res.body.fieldData) + let qs = form.buildQueryStringWithFormData() + expect(qs).toBe('livingCountry=CAN') // Canada is selected by default + fillOutForm(form) + qs = form.buildQueryStringWithFormData() + expect(qs).toContain('income=20000') + expect(qs).toContain('age=65') + expect(qs).toContain('maritalStatus=single') + expect(qs).toContain('livingCountry=CAN') + expect(qs).toContain('legalStatus=canadianCitizen') + expect(qs).toContain('canadaWholeLife=true') + }) +}) diff --git a/__tests__/components/CurrencyField.test.tsx b/__tests__/components/CurrencyField.test.tsx new file mode 100644 index 000000000..12793fe7a --- /dev/null +++ b/__tests__/components/CurrencyField.test.tsx @@ -0,0 +1,80 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom' +import React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { LanguageProvider, StoreProvider } from '../../components/Contexts' +import { CurrencyField } from '../../components/Forms/CurrencyField' + +describe('CurrencyField component', () => { + it('should render an input component that is required component', () => { + const props = { + name: 'income', + label: 'What is your annual net income?', + required: true, + } + + const ui = ( + + + + + + ) + + render(ui) + const label = screen.getByTestId('currency-input-label') + expect(label).toBeInTheDocument() + expect(label.tagName).toBe('LABEL') + expect(label.textContent).toContain(props.label) + + const field = screen.getByTestId('currency-input') + expect(field).toBeInTheDocument() + expect(field.tagName).toBe('INPUT') + expect(field).toBeDefined() + expect(field).toBeRequired() + }) + + it('should render an input component that is required and has a custom error message', () => { + const props = { + name: 'income', + label: 'What is your annual net income?', + error: 'This field is required.', + required: true, + } + + const ui = ( + + + + + + ) + + render(ui) + const label = screen.getByTestId('currency-input-label') + expect(label).toBeInTheDocument() + expect(label.tagName).toBe('LABEL') + expect(label.textContent).toContain(props.label) + + const field = screen.getByTestId('currency-input') + expect(field).toBeInTheDocument() + expect(field.tagName).toBe('INPUT') + expect(field).toBeDefined() + expect(field).toBeRequired() + + const errorLabel = screen.getByRole('alert') + expect(errorLabel).toBeDefined() + expect(errorLabel.textContent).toBe(props.error) + }) +}) diff --git a/__tests__/components/NumberField.test.tsx b/__tests__/components/NumberField.test.tsx new file mode 100644 index 000000000..cc7ce866c --- /dev/null +++ b/__tests__/components/NumberField.test.tsx @@ -0,0 +1,80 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom' +import React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { LanguageProvider, StoreProvider } from '../../components/Contexts' +import { NumberField } from '../../components/Forms/NumberField' + +describe('NumberField component', () => { + it('should render an input component that is required component', () => { + const props = { + name: 'age', + label: 'What is your age?', + required: true, + } + + const ui = ( + + + + + + ) + + render(ui) + const label = screen.getByTestId('number-input-label') + expect(label).toBeInTheDocument() + expect(label.tagName).toBe('LABEL') + expect(label.textContent).toContain(props.label) + + const field = screen.getByTestId('number-input') + expect(field).toBeInTheDocument() + expect(field.tagName).toBe('INPUT') + expect(field).toBeDefined() + expect(field).toBeRequired() + }) + + it('should render an input component that is required and has a custom error message', () => { + const props = { + name: 'age', + label: 'What is your age?', + error: 'This field is required.', + required: true, + } + + const ui = ( + + + + + + ) + + render(ui) + const label = screen.getByTestId('number-input-label') + expect(label).toBeInTheDocument() + expect(label.tagName).toBe('LABEL') + expect(label.textContent).toContain(props.label) + + const field = screen.getByTestId('number-input') + expect(field).toBeInTheDocument() + expect(field.tagName).toBe('INPUT') + expect(field).toBeDefined() + expect(field).toBeRequired() + + const errorLabel = screen.getByRole('alert') + expect(errorLabel).toBeDefined() + expect(errorLabel.textContent).toBe(props.error) + }) +}) diff --git a/__tests__/components/ProgressBar.test.tsx b/__tests__/components/ProgressBar.test.tsx new file mode 100644 index 000000000..880d82598 --- /dev/null +++ b/__tests__/components/ProgressBar.test.tsx @@ -0,0 +1,143 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom' +import React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { LanguageProvider, StoreProvider } from '../../components/Contexts' +import ProgressBar from '../../components/ProgressBar' +import { RootStore } from '../../client-state/store' +import { Instance } from 'mobx-state-tree' +import { FieldCategory } from '../../utils/api/definitions/enums' + +describe('ProgressBar component', () => { + let root: Instance + beforeEach(() => { + root = RootStore.create({ + form: { + fields: [ + { + key: 'income', + type: 'currency', + label: + 'What is your current annual net income in Canadian Dollars?', + category: { + key: FieldCategory.INCOME_DETAILS, + text: 'Income Details', + }, + order: 1, + placeholder: '$20,000', + default: undefined, + value: null, + options: [], + error: undefined, + }, + { + key: 'fakefield', + type: 'currency', + label: + 'What is your current annual net income in Canadian Dollars?', + category: { + key: FieldCategory.PERSONAL_INFORMATION, + text: 'Personal Information', + }, + order: 1, + placeholder: '$20,000', + default: undefined, + value: null, + options: [], + error: undefined, + }, + { + key: 'fakefieldtwo', + type: 'currency', + label: + 'What is your current annual net income in Canadian Dollars?', + category: { + key: FieldCategory.LEGAL_STATUS, + text: 'Legal Status', + }, + order: 1, + placeholder: '$20,000', + default: undefined, + value: null, + options: [], + error: undefined, + }, + ], + }, + oas: {}, + gis: {}, + afs: {}, + allowance: {}, + summary: {}, + }) + }) + it('renders progress for an incomplete form', () => { + root.form.fields[0].setValue('10000') // set income to complete + + const ui = ( + + + + + + ) + render(ui) + + const progress = screen.getAllByTestId('progress') + expect(progress[0].classList).toContain('complete-progress-section') + expect(progress[1].classList).toContain('incomplete-progress-section') + expect(progress[2].classList).toContain('incomplete-progress-section') + }) + + it('renders progress for a complete form', () => { + root.form.fields[0].setValue('10000') + root.form.fields[1].setValue('10000') + root.form.fields[2].setValue('10000') + + const ui = ( + + + + + + ) + render(ui) + + const progress = screen.getAllByTestId('progress') + expect(progress[0].classList).toContain('complete-progress-section') + expect(progress[1].classList).toContain('complete-progress-section') + expect(progress[2].classList).toContain('complete-progress-section') + }) +}) diff --git a/__tests__/components/Radio.test.tsx b/__tests__/components/Radio.test.tsx new file mode 100644 index 000000000..ddd8f76d7 --- /dev/null +++ b/__tests__/components/Radio.test.tsx @@ -0,0 +1,103 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom' +import React from 'react' +import { render, screen } from '@testing-library/react' +import { LanguageProvider, StoreProvider } from '../../components/Contexts' +import { Radio } from '../../components/Forms/Radio' + +describe('Radio component', () => { + it('should render an input component that is required component', () => { + const props = { + name: 'everLivedSocialCountry', + label: 'Have you ever live in a social agreement country?', + required: true, + values: [ + { key: 'true', text: 'Yes' }, + { key: 'false', text: 'No' }, + ], + } + + const ui = ( + + + e.preventDefault()} + /> + + + ) + render(ui) + + const label = screen.getByTestId('radio-label') + expect(label).toBeInTheDocument() + expect(label.tagName).toBe('LABEL') + expect(label.textContent).toContain(props.label) + + const fields = screen.getAllByTestId('radio') + for (let index = 0; index < fields.length; index++) { + const field: Partial = fields[index] + + expect(field).toBeInTheDocument() + expect(field.tagName).toBe('INPUT') + expect(field).toBeDefined() + expect(field).toBeRequired() + expect(field.value).toEqual(props.values[index].key) + } + }) + + it('should render an input component that has a checkedValue', () => { + const props = { + name: 'everLivedSocialCountry', + label: 'Have you ever live in a social agreement country?', + required: true, + values: [ + { key: 'true', text: 'Yes' }, + { key: 'false', text: 'No' }, + ], + checkedValue: 'true', + } + + const ui = ( + + + e.preventDefault()} + /> + + + ) + render(ui) + + const label = screen.getByTestId('radio-label') + expect(label).toBeInTheDocument() + expect(label.tagName).toBe('LABEL') + expect(label.textContent).toContain(props.label) + + const fields = screen.getAllByTestId('radio') + for (let index = 0; index < fields.length; index++) { + const field: Partial = fields[index] + + expect(field).toBeInTheDocument() + expect(field.tagName).toBe('INPUT') + expect(field).toBeDefined() + expect(field).toBeRequired() + expect(field.value).toEqual(props.values[index].key) + } + + expect((fields[0] as Partial).checked).toEqual(true) + expect((fields[1] as Partial).checked).toEqual(false) + }) +}) diff --git a/__tests__/components/TextField.test.tsx b/__tests__/components/TextField.test.tsx new file mode 100644 index 000000000..4dd0b6a31 --- /dev/null +++ b/__tests__/components/TextField.test.tsx @@ -0,0 +1,80 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom' +import React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { LanguageProvider, StoreProvider } from '../../components/Contexts' +import { TextField } from '../../components/Forms/TextField' + +describe('TextField component', () => { + it('should render an input component that is required component', () => { + const props = { + name: 'legalStatusOther', + label: 'Describe your legal status?', + required: true, + } + + const ui = ( + + + + + + ) + + render(ui) + const label = screen.getByTestId('text-input-label') + expect(label).toBeInTheDocument() + expect(label.tagName).toBe('LABEL') + expect(label.textContent).toContain(props.label) + + const field = screen.getByTestId('text-input') + expect(field).toBeInTheDocument() + expect(field.tagName).toBe('TEXTAREA') + expect(field).toBeDefined() + expect(field).toBeRequired() + }) + + it('should render an input component that is required and has a custom error message', () => { + const props = { + name: 'legalStatusOther', + label: 'Describe your legal status?', + error: 'This field is required.', + required: true, + } + + const ui = ( + + + + + + ) + + render(ui) + const label = screen.getByTestId('text-input-label') + expect(label).toBeInTheDocument() + expect(label.tagName).toBe('LABEL') + expect(label.textContent).toContain(props.label) + + const field = screen.getByTestId('text-input') + expect(field).toBeInTheDocument() + expect(field.tagName).toBe('TEXTAREA') + expect(field).toBeDefined() + expect(field).toBeRequired() + + const errorLabel = screen.getByRole('alert') + expect(errorLabel).toBeDefined() + expect(errorLabel.textContent).toBe(props.error) + }) +}) diff --git a/__tests__/components/Tooltip.test.tsx b/__tests__/components/Tooltip.test.tsx new file mode 100644 index 000000000..3ff96fb8a --- /dev/null +++ b/__tests__/components/Tooltip.test.tsx @@ -0,0 +1,51 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom' +import React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { LanguageProvider, StoreProvider } from '../../components/Contexts' +import { Tooltip } from '../../components/Tooltip/tooltip' +import { fieldDefinitions } from '../../components/Tooltip/index' + +// gets data correctly and presents it +describe('Tooltip component', () => { + it('can render an input component that is required component', () => { + const props = { + field: 'income', + } + + const ui = ( + + + + + + ) + + render(ui) + + const tooltip = screen.getByTestId('tooltip') + expect(tooltip.textContent).toContain(fieldDefinitions.data[props.field][0]) // tooltip title + expect(tooltip.innerHTML).toContain(fieldDefinitions.data[props.field][0]) // tooltip content + }) + + // throws if tooltip not found + it('should throw if the tooltip cannot be found by key', () => { + const props = { + field: 'fakeKey', + } + + const ui = ( + + + + + + ) + + expect(() => render(ui)).toThrowError( + `Tooltip with key "fakeKey" not found in internationalization file.` + ) + }) +}) diff --git a/client-state/models/Form.ts b/client-state/models/Form.ts index a0a6534d0..5fd3435bd 100644 --- a/client-state/models/Form.ts +++ b/client-state/models/Form.ts @@ -1,10 +1,4 @@ -import { - flow, - getParentOfType, - Instance, - SnapshotIn, - types, -} from 'mobx-state-tree' +import { flow, getParent, Instance, SnapshotIn, types } from 'mobx-state-tree' import { FieldCategory } from '../../utils/api/definitions/enums' import { FieldData, FieldKey } from '../../utils/api/definitions/fields' import { @@ -22,12 +16,10 @@ type FormProgress = { estimation?: boolean } -/** API endpoint for eligibility*/ -const API_URL = `api/calculateEligibility` - export const Form = types .model({ fields: types.array(FormField), + API_URL: `api/calculateEligibility`, }) .views((self) => ({ get hasErrors() { @@ -47,12 +39,6 @@ export const Form = types get empty(): boolean { return self.fields.length === 0 }, - get previouslySavedValues(): { key: string; value: string }[] { - return self.fields.map((field) => ({ - key: field.key, - value: field.value, - })) - }, })) .views((self) => ({ get progress(): FormProgress { @@ -136,7 +122,7 @@ export const Form = types self.removeFields(fieldsToRemove) // remove the now invalid summary object - const parent = getParentOfType(self, RootStore) + const parent = getParent(self) as Instance parent.setSummary({}) }, clearAllErrors() { @@ -201,7 +187,7 @@ export const Form = types // build query string const queryString = self.buildQueryStringWithFormData() - const apiData = yield fetch(`${API_URL}?${queryString}`) + const apiData = yield fetch(`${self.API_URL}?${queryString}`) const data: ResponseSuccess | ResponseError = yield apiData.json() if ('error' in data) { @@ -213,7 +199,7 @@ export const Form = types } } else { self.clearAllErrors() - const parent = getParentOfType(self, RootStore) + const parent = getParent(self) as Instance parent.setOAS(data.results.oas) parent.setGIS(data.results.gis) parent.setAFS(data.results.afs) diff --git a/client-state/models/FormField.ts b/client-state/models/FormField.ts index d856eb6c2..42addff02 100644 --- a/client-state/models/FormField.ts +++ b/client-state/models/FormField.ts @@ -1,4 +1,4 @@ -import { types, flow, getParentOfType } from 'mobx-state-tree' +import { types, flow, getParent, Instance } from 'mobx-state-tree' import { FieldKey } from '../../utils/api/definitions/fields' import { Form } from './Form' @@ -7,9 +7,9 @@ export const KeyValue = types.model({ text: types.string, }) -const Category = KeyValue.named('Category') -const Options = KeyValue.named('Options') -const Default = KeyValue.named('Default') +export const Category = KeyValue.named('Category') +export const Options = KeyValue.named('Options') +export const Default = KeyValue.named('Default') export const FormField = types .model({ @@ -64,6 +64,6 @@ export const FormField = types handleChange: flow(function* (e) { const inputVal = e?.target?.value ?? { key: e.value, text: e.label } self.setValue(inputVal) - yield getParentOfType(self, Form).sendAPIRequest() + yield (getParent(self) as Instance).sendAPIRequest() }), })) diff --git a/client-state/store.ts b/client-state/store.ts index 1f8f26649..d08f75276 100644 --- a/client-state/store.ts +++ b/client-state/store.ts @@ -38,12 +38,12 @@ export const Summary = types.model({ export const RootStore = types .model({ - form: Form, - oas: OAS, - gis: GIS, - afs: AFS, - allowance: Allowance, - summary: Summary, + form: types.maybe(Form), + oas: types.maybe(OAS), + gis: types.maybe(GIS), + afs: types.maybe(AFS), + allowance: types.maybe(Allowance), + summary: types.maybe(Summary), activeTab: types.optional(types.number, 0), }) .views((self) => ({ diff --git a/components/Forms/CurrencyField.tsx b/components/Forms/CurrencyField.tsx index 82a5e3485..b4233a35a 100644 --- a/components/Forms/CurrencyField.tsx +++ b/components/Forms/CurrencyField.tsx @@ -3,7 +3,6 @@ import NumberFormat from 'react-number-format' import { Tooltip } from '../Tooltip/tooltip' import { ErrorLabel } from './validation/ErrorLabel' import { observer } from 'mobx-react' -import { FieldType } from '../../utils/api/definitions/fields' export interface CurrencyFieldProps extends InputHTMLAttributes { @@ -20,8 +19,7 @@ export interface CurrencyFieldProps */ export const CurrencyField: React.VFC = observer( (props) => { - const { name, type, label, required, value, placeholder, onChange, error } = - props + const { name, label, required, value, placeholder, onChange, error } = props // only need to run this once at component render, so no need for deps useEffect(() => { @@ -35,11 +33,11 @@ export const CurrencyField: React.VFC = observer( }, []) return ( - <> +
) } ) diff --git a/components/Forms/NumberField.tsx b/components/Forms/NumberField.tsx index 464f381c8..1559a2404 100644 --- a/components/Forms/NumberField.tsx +++ b/components/Forms/NumberField.tsx @@ -38,7 +38,7 @@ export const NumberField: React.VFC = observer((props) => {