diff --git a/src/application/components/ApplicationSubsection.js b/src/application/components/ApplicationSubsection.js index 7ba6e3322..676427fc7 100644 --- a/src/application/components/ApplicationSubsection.js +++ b/src/application/components/ApplicationSubsection.js @@ -40,8 +40,10 @@ import { } from '$src/application/selectors'; import {ApplicationSectionKeys} from '$src/application/components/enums'; import { + APPLICANT_MAIN_IDENTIFIERS, APPLICANT_SECTION_IDENTIFIER, APPLICANT_TYPE_FIELD_IDENTIFIER, + EMAIL_FIELD_IDENTIFIER, TARGET_SECTION_IDENTIFIER, } from '$src/application/constants'; import { @@ -54,6 +56,7 @@ import type { PlotApplicationFormValue, UploadedFileMeta, } from '$src/application/types'; +import {companyIdentifierValidator, emailValidator, personalIdentifierValidator} from '$src/application/formValidation'; const ApplicationFormFileField = connect( (state, props) => { @@ -351,6 +354,19 @@ const ApplicationFormSubsectionFields = connect( break; } + let validator; + switch (fieldName.substring(fieldName.lastIndexOf('.') + 1)) { + case APPLICANT_MAIN_IDENTIFIERS[ApplicantTypes.PERSON].IDENTIFIER_FIELD: + validator = personalIdentifierValidator; + break; + case APPLICANT_MAIN_IDENTIFIERS[ApplicantTypes.COMPANY].IDENTIFIER_FIELD: + validator = companyIdentifierValidator; + break; + case EMAIL_FIELD_IDENTIFIER: + validator = emailValidator; + break; + } + return ( checkSpecialValues(field, newValue)} + validate={validator} /> ); diff --git a/src/application/constants.js b/src/application/constants.js index d8100263e..dd0fecf92 100644 --- a/src/application/constants.js +++ b/src/application/constants.js @@ -4,6 +4,9 @@ import {ApplicantTypes} from '$src/application/enums'; export const APPLICANT_SECTION_IDENTIFIER = 'hakijan-tiedot'; export const TARGET_SECTION_IDENTIFIER = 'haettava-kohde'; export const APPLICANT_TYPE_FIELD_IDENTIFIER = 'hakija'; +export const CONTROL_SHARE_FIELD_IDENTIFIER = 'hallintaosuus'; +export const EMAIL_FIELD_IDENTIFIER = 'sahkoposti'; + export const APPLICANT_MAIN_IDENTIFIERS: { [type: string]: { DATA_SECTION: string, diff --git a/src/application/formValidation.js b/src/application/formValidation.js new file mode 100644 index 000000000..8339ab4e6 --- /dev/null +++ b/src/application/formValidation.js @@ -0,0 +1,176 @@ +// @flow +import {parseISO} from 'date-fns'; +import {get, set} from 'lodash/object'; + +import {CONTROL_SHARE_FIELD_IDENTIFIER} from '$src/application/constants'; + +const PERSONAL_IDENTIFIER_CHECK_CHAR_LIST = '0123456789ABCDEFHJKLMNPRSTUVWXY'; +// from the rightmost digit to the leftmost +const COMPANY_IDENTIFIER_CHECKSUM_MULTIPLIERS = [2, 4, 8, 5, 10, 9, 7]; + +export const personalIdentifierValidator = (value: any, error?: string): ?string => { + if (value === '') { + return; + } + + if (typeof value !== 'string') { + return error || 'Virheellinen henkilötunnus'; + } + + const result = /^(\d{6})([-+ABCDEFUVWXY])(\d{3})([0-9ABCDEFHJKLMNPRSTUVWXY])$/.exec(value.toUpperCase()); + + if (!result) { + return error || 'Virheellinen henkilötunnus'; + } + + const datePart = result[1]; + const separator = result[2]; + const runningNumber = result[3]; + const checkChar = result[4]; + + let century = '19'; + switch (separator) { + case '+': + century = '18'; + break; + case 'A': + case 'B': + case 'C': + case 'D': + case 'E': + case 'F': + century = '20'; + break; + default: // U-Y, - + break; + } + + try { + const year = `${century}${datePart.slice(4, 6)}`; + const month = datePart.slice(2, 4); + const day = datePart.slice(0, 2); + + const date = parseISO(`${year}-${month}-${day}T12:00:00`); + + if (date.getDate() !== parseInt(day) + || date.getMonth() !== parseInt(month) - 1 + || date.getFullYear() !== parseInt(year) + ) { + return error || 'Virheellinen henkilötunnus'; + } + } catch (e) { + return error || 'Virheellinen henkilötunnus'; + } + + const calculatedCheckChar = PERSONAL_IDENTIFIER_CHECK_CHAR_LIST[parseInt(datePart + runningNumber) % 31]; + + if (checkChar !== calculatedCheckChar) { + return error || 'Tarkistusmerkki ei täsmää'; + } +}; + +export const companyIdentifierValidator = (value: any, error?: string): ?string => { + if (value === '') { + return; + } + + if (typeof value !== 'string') { + return error || 'Virheellinen Y-tunnus'; + } + + const result = /^(\d{6,7})-(\d)$/.exec(value); + + if (!result) { + return error || 'Virheellinen Y-tunnus'; + } + + const identifier = parseInt(result[1]); + const checkNumber = parseInt(result[2]); + + let sum = 0; + let calculatedCheckNumber; + for (let i = 0; i < 7; ++i) { + const digit = Math.floor(identifier / (Math.pow(10, i)) % 10); + sum += digit * COMPANY_IDENTIFIER_CHECKSUM_MULTIPLIERS[i]; + } + + calculatedCheckNumber = sum % 11; + if (calculatedCheckNumber === 1) { + // Company identifiers that sum up to a remainder of 1 are not handed out at all, + // because non-zero values are subtracted from 11 to get the final number and + // in these cases that number would be 10 + return error || 'Virheellinen Y-tunnus'; + } else if (calculatedCheckNumber > 1) { + calculatedCheckNumber = 11 - calculatedCheckNumber; + } + + if (calculatedCheckNumber !== checkNumber) { + return error || 'Tarkistusmerkki ei täsmää'; + } +}; + +export const emailValidator = (value: any, error?: string): ?string => { + if (!value) { + return; + } + + // A relatively simple validation that catches the most egregious examples of invalid emails. + // (Also intentionally denies some technically valid but in this context exceedingly rare addresses, + // like ones with quoted strings containing spaces or a right-side value without a dot.) + if (!(/^\S+@\S+\.\S{2,}$/.exec(value))) { + return error || 'Virheellinen sähköpostiosoite'; + } +}; + +export const validateApplicationForm: (string) => (Object) => Object = (pathPrefix: string) => (values: Object) => { + let sum = 0; + const errors = {}; + const controlSharePaths = []; + + const root = get(values, pathPrefix); + + if (!root?.sections) { + return {}; + } + + const searchSingleSection = (section, path) => { + if (section.fields) { + Object.keys(section.fields).map((fieldIdentifier) => { + if (fieldIdentifier === CONTROL_SHARE_FIELD_IDENTIFIER) { + const result = /^(\d+)\s*\/\s*(\d+)$/.exec(section.fields[fieldIdentifier].value); + if (!result) { + set(errors, `${path}.fields.${fieldIdentifier}.value`, 'Virheellinen hallintaosuus'); + } else { + sum += parseInt(result[1]) / parseInt(result[2]); + controlSharePaths.push(`${path}.fields.${fieldIdentifier}.value`); + } + } + }); + } + + if (section.sections) { + Object.keys(section.sections).map((identifier) => + searchSection(section.sections[identifier], `${path}.sections.${identifier}`)); + } + }; + + const searchSection = (section, path) => { + if (section instanceof Array) { + section.forEach((singleSection, i) => searchSingleSection(singleSection, `${path}[${i}]`)); + } else { + searchSingleSection(section, path); + } + }; + + Object.keys(root.sections).map((identifier) => + searchSection(root.sections[identifier], `${pathPrefix}.sections.${identifier}`)); + + + if (Math.abs(sum - 1) > 1e-9) { + controlSharePaths.forEach((path) => { + set(errors, path, 'Hallintaosuuksien yhteismäärän on oltava 100%'); + }); + } + + return errors; +}; diff --git a/src/application/spec.js b/src/application/spec.js index 3c76e015b..f66e9cba6 100644 --- a/src/application/spec.js +++ b/src/application/spec.js @@ -12,6 +12,12 @@ import { receiveFormAttributes, receiveMethods, } from '$src/application/actions'; import mockFormAttributes from '$src/application/form-attributes-mock-data.json'; +import { + companyIdentifierValidator, emailValidator, + personalIdentifierValidator, + validateApplicationForm, +} from '$src/application/formValidation'; +import {get} from 'lodash/object'; const baseState: ApplicationState = { attributes: null, @@ -120,4 +126,282 @@ describe('Application', () => { expect(state).to.deep.equal(newState); }); }); + + describe('validators', () => { + describe('personalIdentifierValidator', () => { + it('should ignore an empty identifier', () => { + const error = personalIdentifierValidator('', 'fail'); + + expect(error).to.not.exist; + }); + + it('should not accept a non-string value', () => { + const error = personalIdentifierValidator({personalIdentifier: '180670-399C'}, 'fail'); + + expect(error).to.equal('fail'); + }); + + it('should accept a typical personal identifier from the 20th century', () => { + const error = personalIdentifierValidator('180670-399C', 'fail'); + + expect(error).to.not.exist; + }); + + it('should accept a typical personal identifier from the 19th century', () => { + const error = personalIdentifierValidator('090595+596K', 'fail'); + + expect(error).to.not.exist; + }); + + it('should accept a typical personal identifier from the 21st century', () => { + const error = personalIdentifierValidator('090710A800U', 'fail'); + + expect(error).to.not.exist; + }); + + it('should accept a personal identifier using the newly adopted additional separator characters', () => { + const error = personalIdentifierValidator('020504E347J', 'fail'); + + expect(error).to.not.exist; + }); + + it('should not accept a personal identifier using an invalid separator character', () => { + const error = personalIdentifierValidator('020504Z347J', 'fail'); + + expect(error).to.equal('fail'); + }); + + it('should not accept a personal identifier with too many characters', () => { + const error = personalIdentifierValidator('18061970-399C', 'fail'); + + expect(error).to.equal('fail'); + }); + + it('should not accept a personal identifier with too few characters', () => { + const error = personalIdentifierValidator('180670399C', 'fail'); + + expect(error).to.equal('fail'); + }); + + it('should not accept a personal identifier with an impossible date', () => { + const error = personalIdentifierValidator('310298-144J', 'fail'); + + expect(error).to.equal('fail'); + }); + }); + + describe('companyIdentifierValidator', () => { + it('should ignore an empty identifier', () => { + const error = companyIdentifierValidator('', 'fail'); + + expect(error).to.not.exist; + }); + + it('should not accept a non-string value', () => { + const error = companyIdentifierValidator({companyIdentifier: '0346848-5'}, 'fail'); + + expect(error).to.equal('fail'); + }); + + it('should accept a modern format company identifier', () => { + const error = companyIdentifierValidator('0346848-5', 'fail'); + + expect(error).to.not.exist; + }); + + it('should accept an old format company identifier', () => { + const error = companyIdentifierValidator('346848-5', 'fail'); + + expect(error).to.not.exist; + }); + + it('should not accept a company identifier with an invalid check number', () => { + const error = companyIdentifierValidator('0346848-8', 'fail'); + + expect(error).to.equal('fail'); + }); + + it('should not accept a company identifier with a check number that is too long', () => { + const error = companyIdentifierValidator('00346848-5', 'fail'); + + expect(error).to.equal('fail'); + }); + + it('should not accept a company identifier with a check number that is too short', () => { + const error = companyIdentifierValidator('62202-9', 'fail'); + + expect(error).to.equal('fail'); + }); + + it('should not accept a company identifier that is not in use due to a sum remainder of 1', () => { + const error = companyIdentifierValidator('7453796-1', 'fail'); + + expect(error).to.equal('fail'); + }); + }); + describe('emailValidator', () => { + it('should accept a regular email address', () => { + const error = emailValidator('example@gmail.com', 'fail'); + + expect(error).to.not.exist; + }); + + it('should accept a plus alias email address', () => { + const error = emailValidator('example+mvj@gmail.com', 'fail'); + + expect(error).to.not.exist; + }); + + it('should accept multiple dots on each side', () => { + const error = emailValidator('example.user.of.the.mvj.service@gmail.by.google.com', 'fail'); + + expect(error).to.not.exist; + }); + + it('should not accept an email address without anything before the at sign', () => { + const error = emailValidator('@gmail.com', 'fail'); + + expect(error).to.equal('fail'); + }); + + it('should not accept an email address without anything after the at sign', () => { + const error = emailValidator('example@', 'fail'); + + expect(error).to.equal('fail'); + }); + + it('should not accept an email address without an at sign', () => { + const error = emailValidator('example.gmail.com', 'fail'); + + expect(error).to.equal('fail'); + }); + + it('should not accept an email address with a space in it', () => { + const error = emailValidator('example @gmail.com', 'fail'); + + expect(error).to.equal('fail'); + }); + + it('should not accept an email address without a top level domain', () => { + const error = emailValidator('example@gmail', 'fail'); + + expect(error).to.equal('fail'); + }); + + it('should not accept an email address with a one-letter top level domain', () => { + const error = emailValidator('example@gmail.c', 'fail'); + + expect(error).to.equal('fail'); + }); + }); + + describe('validateApplicationForm', () => { + it('should accept a simple form with a singular control value at a 100% ratio', () => { + const error = validateApplicationForm('formEntries')({ + formEntries: { + sections: { + test: [ + { + fields: { + hallintaosuus: { + value: '1 / 1', + extraValue: '', + }, + }, + }, + ], + }, + }, + }); + + expect(Object.keys(error).length).to.be.equal(0); + }); + + it('should accept a simple form with multiple control values equaling a 100% ratio in total', () => { + const error = validateApplicationForm('formEntries')({ + formEntries: { + sections: { + test: [ + { + fields: { + hallintaosuus: { + value: '1 / 7', + extraValue: '', + }, + }, + }, + { + fields: { + hallintaosuus: { + value: '18 / 21', + extraValue: '', + }, + }, + }, + ], + }, + }, + }); + + expect(Object.keys(error).length).to.be.equal(0); + }); + + it('should not accept a simple form with multiple control values not equaling a 100% ratio in total', () => { + const error = validateApplicationForm('formEntries')({ + formEntries: { + sections: { + test: [ + { + fields: { + hallintaosuus: { + value: '3 / 8', + extraValue: '', + }, + }, + }, + { + fields: { + hallintaosuus: { + value: '4 / 8', + extraValue: '', + }, + }, + }, + ], + }, + }, + }); + + expect(get(error, 'formEntries.sections.test[0].fields.hallintaosuus.value')) + .to.equal('Hallintaosuuksien yhteismäärän on oltava 100%'); + expect(get(error, 'formEntries.sections.test[1].fields.hallintaosuus.value')) + .to.equal('Hallintaosuuksien yhteismäärän on oltava 100%'); + }); + + it('should not care about unrelated fractional fields when validating the control share ratio', () => { + const error = validateApplicationForm('formEntries')({ + formEntries: { + sections: { + test: [ + { + fields: { + hallintaosuus: { + value: '9 / 9', + extraValue: '', + }, + arvostelu: { + value: '5 / 7', + extraValue: '', + }, + }, + }, + ], + }, + }, + }); + + expect(Object.keys(error).length).to.be.equal(0); + }); + }); + }); }); diff --git a/src/areaSearch/components/AreaSearchApplicationCreateForm.js b/src/areaSearch/components/AreaSearchApplicationCreateForm.js index 84cead685..218ce1180 100644 --- a/src/areaSearch/components/AreaSearchApplicationCreateForm.js +++ b/src/areaSearch/components/AreaSearchApplicationCreateForm.js @@ -9,6 +9,7 @@ import {getInitialApplicationForm} from '$src/application/helpers'; import {getFieldTypeMapping} from '$src/application/selectors'; import {FormNames} from '$src/enums'; import {receiveFormValidFlags} from '$src/areaSearch/actions'; +import {validateApplicationForm} from '$src/application/formValidation'; type OwnProps = { formData: Object, @@ -96,5 +97,6 @@ export default (flowRight( reduxForm({ form: FormNames.AREA_SEARCH_CREATE_FORM, destroyOnUnmount: false, + validate: validateApplicationForm('form'), }), )(AreaSearchApplicationCreateForm): React$ComponentType); diff --git a/src/areaSearch/components/AreaSearchApplicationCreatePage.js b/src/areaSearch/components/AreaSearchApplicationCreatePage.js index 82c0e68b7..d275c3a2f 100644 --- a/src/areaSearch/components/AreaSearchApplicationCreatePage.js +++ b/src/areaSearch/components/AreaSearchApplicationCreatePage.js @@ -193,6 +193,7 @@ class AreaSearchApplicationCreatePage extends Component { createAreaSearchSpecs({ area_search_attachments: attachments.map((attachment) => attachment.id), ...specsFormValues, + end_date: specsFormValues.end_date || null, }); } }; diff --git a/src/areaSearch/components/AreaSearchApplicationCreateSpecs.js b/src/areaSearch/components/AreaSearchApplicationCreateSpecs.js index 5408aa7af..7122938c9 100644 --- a/src/areaSearch/components/AreaSearchApplicationCreateSpecs.js +++ b/src/areaSearch/components/AreaSearchApplicationCreateSpecs.js @@ -25,6 +25,7 @@ import AddFileButton from '$components/form/AddFileButton'; import RemoveButton from '$components/form/RemoveButton'; import type {UploadedFileMeta} from '$src/application/types'; import {nonEmptyGeometry} from '$src/areaSearch/validators'; +import {startOfToday} from 'date-fns'; type OwnProps = { onFileAdded: Function, @@ -95,6 +96,7 @@ class AreaSearchApplicationCreateSpecs extends Component { } const geometryHasError = geometryError && (isSaveClicked || formMeta.geometry?.touched); + const today = startOfToday(); return (
@@ -128,6 +130,7 @@ class AreaSearchApplicationCreateSpecs extends Component { label: 'Vuokra-ajan alkupvm', read_only: false, }} + minDate={today} /> @@ -139,11 +142,11 @@ class AreaSearchApplicationCreateSpecs extends Component { fieldAttributes={get(attributes, 'end_date')} name='end_date' overrideValues={{ - required: true, fieldType: FieldTypes.DATE, label: 'Vuokra-ajan loppupvm', read_only: false, }} + minDate={today} /> @@ -252,7 +255,7 @@ export default (flowRight( validate: (values) => { const errors = {}; - if (values.start_date && values.end_date && values.start_date >= values.end_date) { + if (values.start_date && values.end_date && values.start_date > values.end_date) { errors.start_date = 'Alkupäivämäärän on oltava ennen loppupäivämäärää'; errors.end_date = 'Loppupäivämäärän on oltava ennen alkupäivämäärää'; } diff --git a/src/components/form/FieldTypeDatePicker.js b/src/components/form/FieldTypeDatePicker.js index 79c423586..f88460894 100644 --- a/src/components/form/FieldTypeDatePicker.js +++ b/src/components/form/FieldTypeDatePicker.js @@ -14,6 +14,8 @@ type Props = { displayError: boolean, input: Object, isDirty: boolean, + minDate?: Date, + maxDate?: Date, placeholder?: string, setRefForField?: Function, } @@ -23,6 +25,8 @@ const FieldTypeDatePicker = ({ displayError = false, input: {name, onChange, value}, isDirty = false, + minDate, + maxDate, placeholder, setRefForField, }: Props): React$Node => { @@ -77,6 +81,8 @@ const FieldTypeDatePicker = ({ onChangeRaw={handleChange} onSelect={handleSelect} placeholderText={placeholder} + minDate={minDate} + maxDate={maxDate} />
); diff --git a/src/components/form/FieldTypeFractional.js b/src/components/form/FieldTypeFractional.js index 2d112f717..18643cf26 100644 --- a/src/components/form/FieldTypeFractional.js +++ b/src/components/form/FieldTypeFractional.js @@ -1,5 +1,5 @@ // @flow -import React from 'react'; +import React, {useRef} from 'react'; import classNames from 'classnames'; type Props = { @@ -17,15 +17,19 @@ const SPLITTER = ' / '; const FieldTypeFractional = ({ disabled = false, displayError = false, - input: {name, onChange, value}, + input: {name, onChange, onBlur, value}, isDirty = false, setRefForField, + input, }: Props): React$Node => { + const firstFieldRef = useRef(null); + const secondFieldRef = useRef(null); const handleSetReference = (element: any) => { - if(setRefForField) { + if (setRefForField) { setRefForField(element); } + firstFieldRef.current = element; }; const changeHandler = (newValue: string, fieldType: number): void => { @@ -34,6 +38,15 @@ const FieldTypeFractional = ({ onChange(`${values[NUMERATOR] || ''}${SPLITTER}${values[DENOMINATOR] || ''}`); }; + const handleBlur = (e: FocusEvent) => { + // Mark the redux-form field as touched by manually exiting it with onBlur() when focus moves from either + // HTML element to outside this field component. This allows the possible error state to show up properly + // only when the user has finished editing the field and not immediately after they have entered the first number. + if (e.relatedTarget !== firstFieldRef.current && e.relatedTarget !== secondFieldRef.current) { + onBlur(); + } + }; + const parseValue = (fieldType: number): string => { if (!value) { return ''; @@ -43,26 +56,31 @@ const FieldTypeFractional = ({ }; return ( -
+
{/* numerator / fin: osoittaja */} changeHandler(e.target.value, NUMERATOR)} + onBlur={handleBlur} disabled={disabled} type="number" + min="1" /> / {/* denominator / fin: nimittäjä */} changeHandler(e.target.value, DENOMINATOR)} + onBlur={handleBlur} disabled={disabled} type="number" + min="1" + ref={secondFieldRef} />
); diff --git a/src/components/form/FormField.js b/src/components/form/FormField.js index 8f2fee7a5..381b51df7 100644 --- a/src/components/form/FormField.js +++ b/src/components/form/FormField.js @@ -106,6 +106,8 @@ type InputProps = { label: ?string, language?: string, meta: Object, + minDate?: Date, + maxDate?: Date, multiSelect?: boolean, optionLabel?: string, options: ?Array, @@ -140,6 +142,8 @@ const FormFieldInput = ({ label, language, meta, + minDate, + maxDate, multiSelect, optionLabel, options, @@ -234,7 +238,7 @@ const FormFieldInput = ({ }
- {createElement(fieldComponent, {autoBlur, autoComplete, displayError, disabled, filterOption, input, isDirty, isLoading, label, language, multiSelect, optionLabel, placeholder, options, rows, setRefForField, type, valueSelectedCallback})} + {createElement(fieldComponent, {autoBlur, autoComplete, displayError, disabled, filterOption, input, isDirty, isLoading, label, language, minDate, maxDate, multiSelect, optionLabel, placeholder, options, rows, setRefForField, type, valueSelectedCallback})} {unit && {unit}}
{displayError && } @@ -285,6 +289,8 @@ type Props = { isLoading?: boolean, isMulti?: boolean, language?: string, + minDate?: Date, + maxDate?: Date, name: string, onBlur?: Function, onChange?: Function, @@ -401,6 +407,8 @@ class FormField extends PureComponent { invisibleLabel, isLoading, language, + minDate, + maxDate, name, onBlur, onChange, @@ -445,6 +453,8 @@ class FormField extends PureComponent { isLoading={isLoading} label={label} language={language} + minDate={minDate} + maxDate={maxDate} name={name} normalize={this.handleGenericNormalize} onBlur={onBlur} diff --git a/src/plotApplications/components/PlotApplicationCreate.js b/src/plotApplications/components/PlotApplicationCreate.js index addb38d37..afbe07007 100644 --- a/src/plotApplications/components/PlotApplicationCreate.js +++ b/src/plotApplications/components/PlotApplicationCreate.js @@ -63,6 +63,7 @@ import type {Attributes} from '$src/types'; import type {PlotSearchList} from '$src/plotSearch/types'; import type {PlotApplication} from '$src/plotApplications/types'; import type {UsersPermissions as UsersPermissionsType} from '$src/usersPermissions/types'; +import {validateApplicationForm} from '$src/application/formValidation'; type OwnProps = {}; @@ -381,5 +382,6 @@ export default (flowRight( ), reduxForm({ form: formName, + validate: validateApplicationForm('formEntries'), }), )(PlotApplicationCreate): React$ComponentType);