diff --git a/.eslintrc.json b/.eslintrc.json index a3c5faa..59ec63d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -20,5 +20,6 @@ "@typescript-eslint" ], "rules": { + "max-len": 100 } } diff --git a/package-lock.json b/package-lock.json index 0853335..44fe255 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,14 @@ } } }, + "@babel/runtime": { + "version": "7.10.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.2.tgz", + "integrity": "sha512-6sF3uQw2ivImfVIl62RZ7MXhO2tap69WeWK57vAaimT6AZbE4FbqjdEJIN1UqoD6wI6B+1n9UiagafH1sxjOtg==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, "@babel/runtime-corejs3": { "version": "7.10.2", "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.10.2.tgz", @@ -108,6 +116,16 @@ "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==", "dev": true }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "dev": true, + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/html-minifier-terser": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.0.tgz", @@ -163,6 +181,27 @@ "@types/react": "*" } }, + "@types/react-redux": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.9.tgz", + "integrity": "sha512-mpC0jqxhP4mhmOl3P4ipRsgTgbNofMRXJb08Ms6gekViLj61v1hOZEKWDCyWsdONr6EjEA6ZHXC446wdywDe0w==", + "dev": true, + "requires": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, + "@types/redux": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@types/redux/-/redux-3.6.0.tgz", + "integrity": "sha1-8evh5UEVGAcuT9/KXHbhbnTBOZo=", + "dev": true, + "requires": { + "redux": "*" + } + }, "@types/source-list-map": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz", @@ -3772,6 +3811,14 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "requires": { + "react-is": "^16.7.0" + } + }, "homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -6246,6 +6293,18 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-redux": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.0.tgz", + "integrity": "sha512-EvCAZYGfOLqwV7gh849xy9/pt55rJXPwmYvI4lilPM5rUT/1NxuuN59ipdBksRVSvz0KInbPnp4IfoXJXCqiDA==", + "requires": { + "@babel/runtime": "^7.5.5", + "hoist-non-react-statics": "^3.3.0", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^16.9.0" + } + }, "react-test-renderer": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.13.1.tgz", @@ -6310,6 +6369,16 @@ "strip-indent": "^1.0.1" } }, + "redux": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz", + "integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==", + "dev": true, + "requires": { + "loose-envify": "^1.4.0", + "symbol-observable": "^1.2.0" + } + }, "reflect.ownkeys": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", @@ -6319,8 +6388,7 @@ "regenerator-runtime": { "version": "0.13.5", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", - "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==", - "dev": true + "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==" }, "regex-not": { "version": "1.0.2", @@ -7557,6 +7625,12 @@ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", "dev": true }, + "symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "dev": true + }, "table": { "version": "5.4.6", "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz", diff --git a/package.json b/package.json index 7df3217..a66049a 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "homepage": "https://github.com/JamesChristie/chronicle#readme", "dependencies": { "react": "16.13.1", - "react-dom": "16.13.1" + "react-dom": "16.13.1", + "react-redux": "7.2.0" }, "devDependencies": { "@types/enzyme": "3.10.5", @@ -28,6 +29,8 @@ "@types/react": "16.9.35", "@types/react-devtools": "3.6.0", "@types/react-dom": "16.9.8", + "@types/react-redux": "7.1.9", + "@types/redux": "3.6.0", "@typescript-eslint/eslint-plugin": "3.1.0", "@typescript-eslint/parser": "3.1.0", "enzyme": "3.11.0", diff --git a/spec/components/DiaryCard.spec.tsx b/spec/components/DiaryCard.spec.tsx index 20f4f8a..e700755 100644 --- a/spec/components/DiaryCard.spec.tsx +++ b/spec/components/DiaryCard.spec.tsx @@ -1,65 +1,62 @@ import * as React from 'react' import 'jasmine' -import { shallow } from 'enzyme' +import { shallow, ShallowWrapper } from 'enzyme' + import { DiaryCard } from '../../src/components/DiaryCard' +import { PrescriptionMedicationSection } from '../../src/components/PrescriptionMedicationSection' describe('Diary card form', () => { - it('displays an field for naming a target behavior', () => { - const wrapper = shallow() - const targetBehaviorField = wrapper.find('input[type="text"]#target-behavior-name') + var elementWrapper: ShallowWrapper | undefined - expect(targetBehaviorField.length).toEqual(1) + beforeEach(() => { + elementWrapper = shallow() }) - it('displays a localized label for the target behavior name', () => { - const wrapper = shallow() - const targetBehaviorFieldLabel = wrapper.find('label[htmlFor="target-behavior-name"]') + it('displays an field for naming a target behavior', () => { + const targetBehaviorField = elementWrapper?.find('input[type="text"]#target-behavior-name') + expect(targetBehaviorField?.length).toEqual(1) + }) - expect(targetBehaviorFieldLabel.length).toEqual(1) + it('displays a localized label for the target behavior name', () => { + const targetBehaviorFieldLabel = elementWrapper?.find('label[htmlFor="target-behavior-name"]') + expect(targetBehaviorFieldLabel?.length).toEqual(1) }) it('displays an field for rating the urge to engage in target behavior', () => { - const wrapper = shallow() - const targetBehaviorUrgeField = wrapper.find('div#target-behavior-urge') - - expect(targetBehaviorUrgeField.length).toEqual(1) + const targetBehaviorUrgeField = elementWrapper?.find('div#target-behavior-urge') + expect(targetBehaviorUrgeField?.length).toEqual(1) }) it('allows rating of target behavior urge from zero to five', () => { - const wrapper = shallow() - const targetBehaviorUrgeMagnitudeFields = wrapper - .find('input[type="radio"][name="target-behavior-urge-magnitude"]') - - expect(targetBehaviorUrgeMagnitudeFields.length).toEqual(6) + const targetBehaviorUrgeMagnitudeFields = elementWrapper?.find( + 'input[type="radio"][name="target-behavior-urge-magnitude"]') + expect(targetBehaviorUrgeMagnitudeFields?.length).toEqual(6) }) it('displays a localized label for rating the urge to engage in target behavior', () => { - const wrapper = shallow() - const targetBehaviorUrgeLabel = wrapper.find('label[htmlFor="target-behavior-urge"]') - - expect(targetBehaviorUrgeLabel.length).toEqual(1) + const targetBehaviorUrgeLabel = elementWrapper?.find('label[htmlFor="target-behavior-urge"]') + expect(targetBehaviorUrgeLabel?.length).toEqual(1) }) it('displays a field for rating the target behavior engagement', () => { - const wrapper = shallow() - const targetBehaviorEngagementField = wrapper.find('div#target-behavior-engagement') - - expect(targetBehaviorEngagementField.length).toEqual(1) + const targetBehaviorEngagementField = elementWrapper?.find('div#target-behavior-engagement') + expect(targetBehaviorEngagementField?.length).toEqual(1) }) it('allows rating of target behavior engagement from zero to five', () => { - const wrapper = shallow() - const targetBehaviorEngagementMagnitudeFields = wrapper - .find('input[type="radio"][name="target-behavior-engagement-magnitude"]') - - expect(targetBehaviorEngagementMagnitudeFields.length).toEqual(6) + const targetBehaviorEngagementMagnitudeFields = elementWrapper + ?.find('input[type="radio"][name="target-behavior-engagement-magnitude"]') + expect(targetBehaviorEngagementMagnitudeFields?.length).toEqual(6) }) it('displays a localized label for rating the target behavior engagement', () => { - const wrapper = shallow() - const targetBehaviorEngagementLabel = wrapper - .find('label[htmlFor="target-behavior-engagement"]') + const targetBehaviorEngagementLabel = elementWrapper + ?.find('label[htmlFor="target-behavior-engagement"]') + expect(targetBehaviorEngagementLabel?.length).toEqual(1) + }) - expect(targetBehaviorEngagementLabel.length).toEqual(1) + it('displays a form section for recording prescription medication', () => { + const prescriptionMedicationSection = elementWrapper?.find(PrescriptionMedicationSection) + expect(prescriptionMedicationSection?.length).toEqual(1) }) }) diff --git a/spec/components/PrescriptionMedicationSection.spec.tsx b/spec/components/PrescriptionMedicationSection.spec.tsx new file mode 100644 index 0000000..5820d5c --- /dev/null +++ b/spec/components/PrescriptionMedicationSection.spec.tsx @@ -0,0 +1,120 @@ +import 'jasmine' +import * as React from 'react' +import { shallow } from 'enzyme' + +import { + buildMockStore, + MockStore +} from '../mocks/redux_store' + +import { + PrescriptionMedication, + PrescriptionMedicationSection +} from '../../src/components/PrescriptionMedicationSection' + +import { + DiaryCardFormActionTypes, + PrescriptionMedicationFormAction, + PrescriptionMedicationFormActionValue +} from '../../src/redux/store' + +describe('Prescription medication section', () => { + var mockStore: MockStore + var medicationsList: PrescriptionMedication[] + + beforeEach(() => { + mockStore = buildMockStore() + medicationsList = [{ name: 'Aspirin' }] + }) + + it('displays a field for entering a prescription medication', () => { + const elementWrapper = shallow( + ) + const prescriptionNameField = elementWrapper?.find('input[type="text"][name="prescription-name"]') + + expect(prescriptionNameField?.length).toEqual(1) + }) + + it('displays existing form fields for medication', () => { + const medicationsList = [ + { name: 'Aspirin' } + ] + + const elementWrapper = shallow( + ) + const prescriptionNameField = elementWrapper?.find('input[type="text"][name="prescription-name"]') + + expect(prescriptionNameField?.length).toEqual(2) + expect(prescriptionNameField?.at(0).prop('value')).toEqual('Aspirin') + expect(prescriptionNameField?.at(1).prop('value')).toEqual('') + }) + + it('dispatches to store upon input change to empty text field', () => { + const elementWrapper = shallow( + ) + + const medicationField = elementWrapper.find('input[type="text"][name="prescription-name"]').at(0) + medicationField?.simulate('change', { target: { value: 'a-prescription-medication-name' } }) + + const mockDispatches = mockStore.getDispatchCalls() as PrescriptionMedicationFormAction[] + + expect(mockDispatches.length).toEqual(1) + expect(mockDispatches[0].type).toEqual(DiaryCardFormActionTypes.AddPrescriptionMedication) + expect(mockDispatches[0].value) + .toEqual({ currentName: '', updatedName: 'a-prescription-medication-name' }) + }) + + it('dispatches to store upon unput change to existing text field', () => { + const elementWrapper = shallow( + ) + + const medicationField = elementWrapper.find('input[type="text"][name="prescription-name"]').at(0) + medicationField?.simulate('change', { target: { value: 'a-prescription-medication-name' } }) + + const mockDispatches = mockStore.getDispatchCalls() as PrescriptionMedicationFormAction[] + const expectedActionValue: PrescriptionMedicationFormActionValue = { + currentName: 'Aspirin', + updatedName: 'a-prescription-medication-name' + } + + expect(mockDispatches.length).toEqual(1) + expect(mockDispatches[0].type).toEqual(DiaryCardFormActionTypes.UpdatePrescriptionMedication) + expect(mockDispatches[0].value).toEqual(expectedActionValue) + }) + + it('dispatches to store upon clearing text from an existing text field', () => { + const elementWrapper = shallow( + ) + + const medicationField = elementWrapper.find('input[type="text"][name="prescription-name"]').at(0) + medicationField?.simulate('change', { target: { value: '' } }) + + const mockDispatches = mockStore.getDispatchCalls() as PrescriptionMedicationFormAction[] + const expectedActionValue: PrescriptionMedicationFormActionValue = { + currentName: 'Aspirin', + updatedName: '' + } + + expect(mockDispatches.length).toEqual(1) + expect(mockDispatches[0].type).toEqual(DiaryCardFormActionTypes.DeletePrescriptionMedication) + expect(mockDispatches[0].value).toEqual(expectedActionValue) + }) + + it('dispatches to store upon click of delete action for medication field', () => { + const elementWrapper = shallow( + ) + + const deleteButton = elementWrapper.find('#delete-medication-action-0').at(0) + deleteButton?.simulate('click') + + const mockDispatches = mockStore.getDispatchCalls() as PrescriptionMedicationFormAction[] + const expectedActionValue: PrescriptionMedicationFormActionValue = { + currentName: 'Aspirin', + updatedName: '' + } + + expect(mockDispatches.length).toEqual(1) + expect(mockDispatches[0].type).toEqual(DiaryCardFormActionTypes.DeletePrescriptionMedication) + expect(mockDispatches[0].value).toEqual(expectedActionValue) + }) +}) diff --git a/spec/mocks/redux_store.ts b/spec/mocks/redux_store.ts new file mode 100644 index 0000000..76c5856 --- /dev/null +++ b/spec/mocks/redux_store.ts @@ -0,0 +1,32 @@ +import { Store, Action, AnyAction, Unsubscribe, Observable } from "redux"; + +import { PrescriptionMedicationFormAction } from '../../src/redux/store' + +export interface MockStore extends Store { + getDispatchCalls: () => Action[] +} + +export const buildMockStore = (): MockStore => { + const dispatchCalls: Action[] = [] + + const dispatch = (action: T): T => { + dispatchCalls.push(action) + return action + } + + const getState = () => { } + const subscribe = (listener: () => void): Unsubscribe => { return () => {} } + const replaceReducer = () => { } + const getObserver = (): Observable => { throw 'not implemented' } + + const getDispatchCalls = (): Action[] => dispatchCalls + + return { + dispatch, + getState, + subscribe, + replaceReducer, + [Symbol.observable]: getObserver, + getDispatchCalls + } +} diff --git a/src/components/DiaryCard.tsx b/src/components/DiaryCard.tsx index 50bac65..efe2c47 100644 --- a/src/components/DiaryCard.tsx +++ b/src/components/DiaryCard.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { PrescriptionMedicationSection } from './PrescriptionMedicationSection' export const DiaryCard = () => { return
@@ -24,5 +25,7 @@ export const DiaryCard = () => {
+ + } diff --git a/src/components/PrescriptionMedicationSection.tsx b/src/components/PrescriptionMedicationSection.tsx new file mode 100644 index 0000000..de4098c --- /dev/null +++ b/src/components/PrescriptionMedicationSection.tsx @@ -0,0 +1,67 @@ +import * as React from 'react' +import { ChangeEvent } from 'react' +import { Store } from 'redux'; +import { PrescriptionMedicationFormAction, DiaryCardFormActionTypes } from '../redux/store' + +export type PrescriptionMedication = { + name: string +} + +export type PrescriptionMedicationSectionProps = { + medications: PrescriptionMedication[], + // TODO (james.aaron.christie@gmail.com) Remove optionality on this argument + // once consuming component issues have been resolved + store?: Store +} + +const onEditMedication = (currentName: string, updatedName: string, store?: Store) => { + var actionType: DiaryCardFormActionTypes | null = null + const isAddition = !currentName && updatedName + const isUpdate = currentName && updatedName + + if (isAddition) actionType = DiaryCardFormActionTypes.AddPrescriptionMedication + if (isUpdate) actionType = DiaryCardFormActionTypes.UpdatePrescriptionMedication + + const action: PrescriptionMedicationFormAction = { + type: actionType || DiaryCardFormActionTypes.DeletePrescriptionMedication, + value: { currentName, updatedName } + } + + store?.dispatch(action) +} + +const getMedicationFields = (medications: PrescriptionMedication[], store?: Store): JSX.Element[] => { + return medications.map((medication: PrescriptionMedication, index: number) => { + const onChange = (event: ChangeEvent) => { + onEditMedication(medication.name, event.target.value, store) + } + + const medicationInput =
+ +
onEditMedication(medication.name, '', store)} /> +
+ + return medicationInput + }); +} + +export const PrescriptionMedicationSection = ({ + medications, + store +}: PrescriptionMedicationSectionProps): React.ReactElement => { + const onChange = (event: ChangeEvent) => { + onEditMedication('', event.target.value, store) + } + + return
+ {getMedicationFields(medications, store)} + +
+ +
+
+} diff --git a/src/index.tsx b/src/index.tsx index b603ee5..703d10b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,9 +1,13 @@ import * as React from 'react' import * as ReactDOM from 'react-dom' +import { Provider } from 'react-redux' +import { DiaryCardFormStore } from './redux/store' import { DiaryCard } from './components/DiaryCard' ReactDOM.render( - , + + + , document.getElementById('app-root') ) diff --git a/src/redux/initialState/diary_card_form_initial_state.ts b/src/redux/initialState/diary_card_form_initial_state.ts new file mode 100644 index 0000000..4b4a46e --- /dev/null +++ b/src/redux/initialState/diary_card_form_initial_state.ts @@ -0,0 +1,3 @@ +export type DiaryCardFormState = {} + +export const DiaryCardFormInitialState: DiaryCardFormState = {} diff --git a/src/redux/mappers/diary_card_form_mappers.ts b/src/redux/mappers/diary_card_form_mappers.ts new file mode 100644 index 0000000..e69bf0e --- /dev/null +++ b/src/redux/mappers/diary_card_form_mappers.ts @@ -0,0 +1 @@ +export const mapDiaryCardFormStateToProps = () => {} \ No newline at end of file diff --git a/src/redux/reducers/diary_card_form_reducer.ts b/src/redux/reducers/diary_card_form_reducer.ts new file mode 100644 index 0000000..04ecaad --- /dev/null +++ b/src/redux/reducers/diary_card_form_reducer.ts @@ -0,0 +1,12 @@ +import { Action, Reducer } from "redux" +import { + DiaryCardFormState, + DiaryCardFormInitialState +} from "../initialState/diary_card_form_initial_state" + +export const DiaryCardFormReducer: Reducer = ( + state: DiaryCardFormState = DiaryCardFormInitialState, + action: Action +): DiaryCardFormState => { + return {} +} diff --git a/src/redux/store.ts b/src/redux/store.ts new file mode 100644 index 0000000..c444c44 --- /dev/null +++ b/src/redux/store.ts @@ -0,0 +1,22 @@ +import { createStore, Action } from 'redux' + +import { DiaryCardFormReducer } from './reducers/diary_card_form_reducer' +import { DiaryCardFormInitialState } from './initialState/diary_card_form_initial_state' + +export enum DiaryCardFormActionTypes { + AddPrescriptionMedication = 'ADD_MEDICATION', + UpdatePrescriptionMedication = 'UPDATE_MEDICATION', + DeletePrescriptionMedication = 'DELETE_MEDICATION' +} + +export interface PrescriptionMedicationFormActionValue { + currentName: String, + updatedName: String +} + +export interface PrescriptionMedicationFormAction extends Action { + type: DiaryCardFormActionTypes, + value: PrescriptionMedicationFormActionValue +} + +export const DiaryCardFormStore = createStore(DiaryCardFormReducer, DiaryCardFormInitialState)