Skip to content

Commit

Permalink
feat: restore UEN field (#2199)
Browse files Browse the repository at this point in the history
* feat: add UEN field (#2100)

* add uen field in public frontend

* add essential uen types

* make sure uen field renders

* replace isUenValid with isUenField

* refactor: simplify isUenValid logic (#2156)

* refactor: simplify isUenValid logic

* fix: use stricter regex matching

Co-authored-by: Aniruddha Adhikary (Ani) <[email protected]>
  • Loading branch information
mantariksh and aniruddha-adhikary authored Jun 17, 2021
1 parent 7908bbc commit 7f91e48
Show file tree
Hide file tree
Showing 18 changed files with 286 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/app/models/field/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import createSectionFieldSchema from './sectionField'
import createShortTextFieldSchema from './shortTextField'
import createStatementFieldSchema from './statementField'
import createTableFieldSchema from './tableField'
import createUenFieldSchema from './uenField'
import createYesNoFieldSchema from './yesNoField'

export {
Expand All @@ -38,6 +39,7 @@ export {
createShortTextFieldSchema,
createStatementFieldSchema,
createTableFieldSchema,
createUenFieldSchema,
createYesNoFieldSchema,
BaseFieldSchema,
}
7 changes: 7 additions & 0 deletions src/app/models/field/uenField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Schema } from 'mongoose'

import { IUenFieldSchema } from '../../../types'

const createUenFieldSchema = () => new Schema<IUenFieldSchema>()

export default createUenFieldSchema
2 changes: 2 additions & 0 deletions src/app/models/form.server.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
createShortTextFieldSchema,
createStatementFieldSchema,
createTableFieldSchema,
createUenFieldSchema,
createYesNoFieldSchema,
} from './field'
import LogicSchema, {
Expand Down Expand Up @@ -405,6 +406,7 @@ const compileFormModel = (db: Mongoose): IFormModel => {
FormFieldPath.discriminator(BasicField.Image, createImageFieldSchema())
FormFieldPath.discriminator(BasicField.Date, createDateFieldSchema())
FormFieldPath.discriminator(BasicField.Nric, createNricFieldSchema())
FormFieldPath.discriminator(BasicField.Uen, createUenFieldSchema())
FormFieldPath.discriminator(BasicField.YesNo, createYesNoFieldSchema())
FormFieldPath.discriminator(
BasicField.Statement,
Expand Down
4 changes: 4 additions & 0 deletions src/app/utils/field-validation/answerValidator.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
isSectionField,
isShortTextField,
isTableField,
isUenField,
isYesNoField,
} from '../../../types/field/utils/guards'
import { ResponseValidator } from '../../../types/field/utils/validation'
Expand All @@ -43,6 +44,7 @@ import { constructRatingValidator } from './validators/ratingValidator'
import { constructSectionValidator } from './validators/sectionValidator'
import { constructTableValidator } from './validators/tableValidator'
import constructTextValidator from './validators/textValidator'
import { constructUenValidator } from './validators/uenValidator'
import { constructYesNoValidator } from './validators/yesNoValidator'

/**
Expand Down Expand Up @@ -76,6 +78,8 @@ export const constructSingleAnswerValidator = (
return constructDropdownValidator(formField)
} else if (isEmailField(formField)) {
return constructEmailValidator(formField)
} else if (isUenField(formField)) {
return constructUenValidator()
} else if (isYesNoField(formField)) {
return constructYesNoValidator()
}
Expand Down
28 changes: 28 additions & 0 deletions src/app/utils/field-validation/validators/uenValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { chain, left, right } from 'fp-ts/lib/Either'
import { flow } from 'fp-ts/lib/function'

import { ProcessedSingleAnswerResponse } from 'src/app/modules/submission/submission.types'
import { ResponseValidator } from 'src/types/field/utils/validation'

import { isUenValid } from '../../../../shared/util/uen-validation'

import { notEmptySingleAnswerResponse } from './common'

type UenValidator = ResponseValidator<ProcessedSingleAnswerResponse>
type UenValidatorConstructor = () => UenValidator

/**
* Returns a validator to check if uen
* format is correct.
*/
const uenValidator: UenValidator = (response) => {
return isUenValid(response.answer)
? right(response)
: left(`UenValidator:\tanswer is not a valid UEN`)
}

/**
* Returns a validation function for a uen field when called.
*/
export const constructUenValidator: UenValidatorConstructor = () =>
flow(notEmptySingleAnswerResponse, chain(uenValidator))
6 changes: 6 additions & 0 deletions src/public/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ require('./modules/forms/base/components/field-number.client.component.js')
require('./modules/forms/base/components/field-rating.client.component.js')
require('./modules/forms/base/components/field-section.client.component.js')
require('./modules/forms/base/components/field-statement.client.component.js')
require('./modules/forms/base/components/field-uen.client.component.js')
require('./modules/forms/base/components/field-textarea.client.component.js')

// forms base directives
Expand All @@ -239,6 +240,7 @@ require('./modules/forms/base/directives/validate-checkbox.client.directive.js')
require('./modules/forms/base/directives/validate-email-domain.client.directive.js')
require('./modules/forms/base/directives/validate-email-format.client.directive.js')
require('./modules/forms/base/directives/validate-nric.client.directive.js')
require('./modules/forms/base/directives/validate-uen.client.directive.js')
require('./modules/forms/base/directives/validate-url.client.directive.js')
require('./modules/forms/base/directives/ng-intl-tel-input.js')
require('./modules/forms/base/directives/submit-form.directive.js')
Expand Down Expand Up @@ -594,6 +596,10 @@ app.run([
'modules/forms/base/componentViews/field-textarea.client.view.html',
require('./modules/forms/base/componentViews/field-textarea.client.view.html'),
)
$templateCache.put(
'modules/forms/base/componentViews/field-uen.client.view.html',
require('./modules/forms/base/componentViews/field-uen.client.view.html'),
)

// Forms base directiveViews
$templateCache.put(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<div class="text-field field-group row">
<!-- Question -->
<label
for="{{ vm.field._id || 'defaultID'}}"
class="field-question col-xs-12"
>
<div id="label-{{ vm.field._id || 'defaultID'}}">
<span class="field-number" ng-show="vm.field.field_number"
>{{ vm.field.field_number }}.</span
>
<span class="field-title">{{ vm.field.title }}</span>
<span class="field-optional" ng-if="!vm.field.required">(optional)</span>
</div>
<div
id="description-{{ vm.field._id || 'defaultID'}}"
class="field-description"
ng-if="vm.field.description"
ng-bind-html="vm.field.description | linky:'_blank'"
></div>
</label>

<!-- Input -->
<div class="col-xs-12 field-input">
<input
id="{{ vm.field._id || 'defaultID'}}"
type="text"
name="{{ vm.field._id || 'defaultID'}}"
class="input-custom input-large"
ng-model="vm.field.fieldValue"
ng-required="vm.field.required"
ng-disabled="vm.field.disabled"
ng-model-options="{ allowInvalid: true }"
ng-keyup="vm.forms.myForm[(vm.field._id || 'defaultID')].$setTouched()"
placeholder="Enter UEN"
autocomplete="off"
ng-trim="true"
ng-class="vm.field.disabled && vm.field.fieldValue ? 'myinfo-disable' : ''"
aria-labelledby="label-{{ vm.field._id || 'defaultID'}}"
aria-describedby="description-{{ vm.field._id || 'defaultID'}}"
validate-uen
/>
</div>

<!-- Error -->
<div
class="col-xs-12"
ng-show="vm.forms.myForm[(vm.field._id || 'defaultID')].$touched"
ng-messages="vm.forms.myForm[(vm.field._id || 'defaultID')].$error"
>
<field-error-component ng-message="required"></field-error-component>
<div ng-message="uenValidator" class="alert-custom alert-error">
<i class="bx bx-exclamation bx-md icon-spacing"></i>
<span class="alert-msg">Please enter a valid UEN</span>
</div>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
'use strict'

angular.module('forms').component('uenFieldComponent', {
templateUrl: 'modules/forms/base/componentViews/field-uen.client.view.html',
bindings: {
field: '<',
forms: '<',
},
controllerAs: 'vm',
})
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,6 @@
forms="forms"
>
</textarea-field-component>
<uen-field-component ng-switch-when="uen" field="field" forms="forms">
</uen-field-component>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use strict'

const { isUenValid } = require('../../../../../shared/util/uen-validation')

angular.module('forms').directive('validateUen', validateUen)

function validateUen() {
return {
restrict: 'A',
require: 'ngModel',
link: function (_scope, _elem, _attrs, ctrl) {
ctrl.$validators.uenValidator = function (modelValue) {
return ctrl.$isEmpty(modelValue) ? true : isUenValid(modelValue)
}
},
}
}
1 change: 1 addition & 0 deletions src/public/modules/forms/base/resources/icon-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const iconTypeMap = {
decimal: 'bx bx-calculator',
image: 'bx bx-image',
nric: 'bx bx-user',
uen: 'bx bx-building',
attachment: 'bx bx-cloud-download',
radiobutton: 'bx bx-radio-circle-marked',
table: 'bx bx-table',
Expand Down
1 change: 1 addition & 0 deletions src/public/modules/forms/helpers/field-factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const getClass = (fieldType) => {
case 'textfield':
return TextField
case 'nric':
case 'uen':
case 'yes_no':
return SingleAnswerField
case 'image':
Expand Down
6 changes: 6 additions & 0 deletions src/shared/resources/basic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ export const types: IBasicFieldType[] = [
submitted: true,
answerArray: false,
},
{
name: BasicField.Uen,
value: 'UEN',
submitted: true,
answerArray: false,
},
{
name: BasicField.Table,
value: 'Table',
Expand Down
129 changes: 129 additions & 0 deletions src/shared/util/uen-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Validate entity-type indicators, as per
* https://www.uen.gov.sg/ueninternet/faces/pages/admin/aboutUEN.jspx
*/
const VALID_ENTITY_TYPE_INDICATORS = new Set([
'LP',
'LL',
'FC',
'PF',
'RF',
'MQ',
'MM',
'NB',
'CC',
'CS',
'MB',
'FM',
'GS',
'GA',
'GB',
'DP',
'CP',
'NR',
'CM',
'CD',
'MD',
'HS',
'VH',
'CH',
'MH',
'CL',
'XL',
'CX',
'RP',
'TU',
'TC',
'FB',
'FN',
'PA',
'PB',
'SS',
'MC',
'SM',
])

/**
* Helper to check whether a string is numeric
* @param s String
* @returns True if string is numeric
*/
const isNumeric = (s: string): boolean => !!s.match(/^[0-9]+$/)

/**
* Helper to check whether a string is alphabetic
* @param s string
* @returns True if string is alphabetic
*/
const isAlphabetic = (s: string): boolean => !!s.match(/^[a-zA-Z]+$/)

/**
* Validates whether a provided string value adheres to the UIN/FIN format
* as provided on the Singapore Government's National Registration Identity Card.
* @param value The value to be validated
*/
export const isUenValid = (uen: string): boolean => {
// allow lowercase strings
uen = uen.toUpperCase()

// check if uen is 9 or 10 digits
if (uen.length < 9 || uen.length > 10) {
return false
}

// (A) Businesses registered with ACRA
if (uen.length === 9) {
// check that last character is a letter
const lastChar = uen[uen.length - 1]
if (!isAlphabetic(lastChar)) {
return false
}

// check that first 8 letters are all numbers
const first8Chars = uen.slice(0, 8)
if (!isNumeric(first8Chars)) {
return false
}

// (A) Businesses registered with ACRA (SUCCESS)
return true
}

// Length is 10
// check that last character is a letter
const lastChar = uen[uen.length - 1]
if (!isAlphabetic(lastChar)) {
return false
}

// (B) Local companies registered with ACRA
const first4Chars = uen.slice(0, 4)
if (isNumeric(first4Chars)) {
// if first 4 are digits then next 5 must be digits too
const next5Chars = uen.slice(4, 9)
return isNumeric(next5Chars)
}

// (C) All other entities which will be issued new UEN
// check that 1st letter is either T or S or R
const firstChar = uen[0]
if (!['T', 'S', 'R'].includes(firstChar)) {
return false
}

// check that 2nd and 3rd letters are numbers only
const chars2And3 = uen.slice(1, 3)
if (!isNumeric(chars2And3)) {
return false
}

// check entity-type indicator
const entityTypeIndicator = uen.slice(3, 5)
if (!VALID_ENTITY_TYPE_INDICATORS.has(entityTypeIndicator)) {
return false
}

// check that 6th to 9th letters are numbers only
const chars5To8 = uen.slice(5, 9)
return isNumeric(chars5To8)
}
1 change: 1 addition & 0 deletions src/types/field/fieldTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export enum BasicField {
Rating = 'rating',
Nric = 'nric',
Table = 'table',
Uen = 'uen',
}

export enum MyInfoAttribute {
Expand Down
Loading

0 comments on commit 7f91e48

Please sign in to comment.