Skip to content

Commit

Permalink
feat: add country field backend (#3770)
Browse files Browse the repository at this point in the history
* feat: add country field backend

* fix: Fix Property 'country' is missing

* fix: replace BasicField.Dropdown in CountryField story

* fix: import CountryField into FieldFactory

* fix: fix type errors due to lack of handling for new country field

Co-authored-by: Kar Rui <[email protected]>
  • Loading branch information
jia1 and karrui committed Aug 31, 2022
1 parent 50c8f92 commit e39111a
Show file tree
Hide file tree
Showing 23 changed files with 222 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -430,5 +430,7 @@ const MemoFieldRow = memo(({ field, ...rest }: MemoFieldRowProps) => {
return <YesNoField schema={field} {...rest} />
case BasicField.Table:
return <TableField schema={field} {...rest} />
default:
return <div>TODO: Add field row for {field.fieldType}</div>
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,13 @@ export const getFieldCreationMeta = (fieldType: BasicField): FieldCreateDto => {
minimumRows: 2,
}
}
case BasicField.Country: {
return {
fieldType,
...baseMeta,
fieldOptions: [],
}
}
}
}

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/features/admin-form/create/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ export const BASICFIELD_TO_DRAWER_META: {
isSubmitted: true,
},

[BasicField.Country]: {
label: 'Country',
icon: BiGlobe,
isSubmitted: true,
},

[BasicField.Email]: {
label: 'Email',
icon: BiMailSend,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FormColorTheme } from '~shared/types/form'
import {
AttachmentField,
CheckboxField,
CountryField,
DateField,
DecimalField,
DropdownField,
Expand Down Expand Up @@ -65,6 +66,8 @@ export const FieldFactory = memo(
return <YesNoField schema={field} {...rest} />
case BasicField.Dropdown:
return <DropdownField schema={field} {...rest} />
case BasicField.Country:
return <CountryField schema={field} {...rest} />
case BasicField.Date:
return <DateField schema={field} {...rest} />
case BasicField.Uen:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const baseSchema: CountryFieldSchema = {
description: 'Type or select your residential country',
required: true,
disabled: false,
fieldType: BasicField.Dropdown,
fieldType: BasicField.Country,
fieldOptions: [],
_id: 'random-id',
}
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/templates/Field/CountryField/CountryField.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { Country } from '~shared/constants/countries'
import {
BasicField,
DropdownFieldBase,
CountryFieldBase,
FormFieldWithId,
} from '~shared/types/field'

import DropdownField from '~templates/Field/Dropdown'

import { BaseFieldProps } from '../FieldContainer'

// TODO: Change to CountryFieldBase when the new CountryField type is added in future PRs
export type CountryFieldSchema = FormFieldWithId<DropdownFieldBase>
export type CountryFieldSchema = FormFieldWithId<CountryFieldBase>
export interface CountryFieldProps extends BaseFieldProps {
schema: CountryFieldSchema
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/templates/Field/CountryField/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CountryField as default } from './CountryField'
2 changes: 2 additions & 0 deletions frontend/src/templates/Field/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AttachmentField from './Attachment'
import CheckboxField from './Checkbox'
import CountryField from './CountryField'
import DateField from './Date'
import DecimalField from './Decimal'
import DropdownField from './Dropdown'
Expand All @@ -24,6 +25,7 @@ export * from './types'
export {
AttachmentField,
CheckboxField,
CountryField,
DateField,
DecimalField,
DropdownField,
Expand Down
6 changes: 6 additions & 0 deletions shared/constants/field/basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ export const types: BasicFieldBlock[] = [
submitted: true,
answerArray: false,
},
{
name: BasicField.Country,
value: 'Country',
submitted: true,
answerArray: false,
},
{
name: BasicField.YesNo,
value: 'Yes/No',
Expand Down
1 change: 1 addition & 0 deletions shared/types/field/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export enum BasicField {
ShortText = 'textfield',
LongText = 'textarea',
Dropdown = 'dropdown',
Country = 'country',
YesNo = 'yes_no',
Checkbox = 'checkbox',
Radio = 'radiobutton',
Expand Down
7 changes: 7 additions & 0 deletions shared/types/field/countryField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { BasicField, MyInfoableFieldBase } from './base'
import { Country } from '../../constants/countries'

export interface CountryFieldBase extends MyInfoableFieldBase {
fieldType: BasicField.Country
fieldOptions: Country[]
}
3 changes: 3 additions & 0 deletions shared/types/field/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CheckboxFieldBase } from './checkboxField'
import { DateFieldBase } from './dateField'
import { DecimalFieldBase } from './decimalField'
import { DropdownFieldBase } from './dropdownField'
import { CountryFieldBase } from './countryField'
import { EmailFieldBase } from './emailField'
import { HomenoFieldBase } from './homeNoField'
import { ImageFieldBase } from './imageField'
Expand All @@ -26,6 +27,7 @@ export * from './checkboxField'
export * from './dateField'
export * from './decimalField'
export * from './dropdownField'
export * from './countryField'
export * from './emailField'
export * from './homeNoField'
export * from './imageField'
Expand All @@ -49,6 +51,7 @@ export type FormField =
| DateFieldBase
| DecimalFieldBase
| DropdownFieldBase
| CountryFieldBase
| EmailFieldBase
| HomenoFieldBase
| ImageFieldBase
Expand Down
6 changes: 6 additions & 0 deletions shared/types/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ export const DropdownResponse = MyInfoableSingleResponse.extend({
})
export type DropdownResponse = z.infer<typeof DropdownResponse>

export const CountryResponse = MyInfoableSingleResponse.extend({
fieldType: z.literal(BasicField.Country),
})
export type CountryResponse = z.infer<typeof CountryResponse>

export const YesNoResponse = SingleAnswerResponse.extend({
fieldType: z.literal(BasicField.YesNo),
})
Expand Down Expand Up @@ -137,6 +142,7 @@ export type FieldResponse =
| ShortTextResponse
| LongTextResponse
| DropdownResponse
| CountryResponse
| YesNoResponse
| CheckboxResponse
| RadioResponse
Expand Down
9 changes: 9 additions & 0 deletions src/app/models/field/countryField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Schema } from 'mongoose'

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

const createCountryFieldSchema = () => {
return new Schema<ICountryFieldSchema>({})
}

export default createCountryFieldSchema
2 changes: 2 additions & 0 deletions src/app/models/field/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import createAttachmentFieldSchema from './attachmentField'
import { BaseFieldSchema } from './baseField'
import createCheckboxFieldSchema from './checkboxField'
import createCountryFieldSchema from './countryField'
import createDateFieldSchema from './dateField'
import createDecimalFieldSchema from './decimalField'
import createDropdownFieldSchema from './dropdownField'
Expand All @@ -26,6 +27,7 @@ export {
createDateFieldSchema,
createDecimalFieldSchema,
createDropdownFieldSchema,
createCountryFieldSchema,
createEmailFieldSchema,
createHomenoFieldSchema,
createImageFieldSchema,
Expand Down
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 @@ -66,6 +66,7 @@ import {
BaseFieldSchema,
createAttachmentFieldSchema,
createCheckboxFieldSchema,
createCountryFieldSchema,
createDateFieldSchema,
createDecimalFieldSchema,
createDropdownFieldSchema,
Expand Down Expand Up @@ -443,6 +444,7 @@ const compileFormModel = (db: Mongoose): IFormModel => {
createAttachmentFieldSchema(),
)
FormFieldPath.discriminator(BasicField.Dropdown, createDropdownFieldSchema())
FormFieldPath.discriminator(BasicField.Country, createCountryFieldSchema())
FormFieldPath.discriminator(BasicField.Radio, createRadioFieldSchema())
FormFieldPath.discriminator(BasicField.Checkbox, createCheckboxFieldSchema())
FormFieldPath.discriminator(
Expand Down
3 changes: 3 additions & 0 deletions src/app/utils/field-validation/answerValidator.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {

import { constructAttachmentValidator } from './validators/attachmentValidator'
import { constructCheckboxValidator } from './validators/checkboxValidator'
import { constructCountryValidator } from './validators/countryValidator'
import { constructDateValidator } from './validators/dateValidator'
import { constructDecimalValidator } from './validators/decimalValidator'
import { constructDropdownValidator } from './validators/dropdownValidator'
Expand Down Expand Up @@ -59,6 +60,8 @@ export const constructSingleAnswerValidator = (
return constructDecimalValidator(formField)
case BasicField.Dropdown:
return constructDropdownValidator(formField)
case BasicField.Country:
return constructCountryValidator()
case BasicField.Email:
return constructEmailValidator(formField)
case BasicField.Uen:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { ValidateFieldError } from 'src/app/modules/submission/submission.errors'
import { validateField } from 'src/app/utils/field-validation'

import {
generateDefaultField,
generateNewSingleAnswerResponse,
} from 'tests/unit/backend/helpers/generate-form-data'

import { Country } from '../../../../../../shared/constants/countries'
import { BasicField } from '../../../../../../shared/types'

describe('Country validation', () => {
it('should allow valid option', () => {
const formField = generateDefaultField(BasicField.Country, {})
const response = generateNewSingleAnswerResponse(BasicField.Country, {
answer: Country.Singapore,
})

const validateResult = validateField('formId', formField, response)
expect(validateResult.isOk()).toBe(true)
expect(validateResult._unsafeUnwrap()).toEqual(true)
})

it('should disallow invalid option', () => {
const formField = generateDefaultField(BasicField.Dropdown, {})
const response = generateNewSingleAnswerResponse(BasicField.Dropdown, {
answer: 'NOT A COUNTRY',
})
const validateResult = validateField('formId', formField, response)
expect(validateResult.isErr()).toBe(true)
expect(validateResult._unsafeUnwrapErr()).toEqual(
new ValidateFieldError('Invalid answer submitted'),
)
})

it('should disallow empty answer when required', () => {
const formField = generateDefaultField(BasicField.Country, {})
const response = generateNewSingleAnswerResponse(BasicField.Country, {
answer: '',
})
const validateResult = validateField('formId', formField, response)
expect(validateResult.isErr()).toBe(true)
expect(validateResult._unsafeUnwrapErr()).toEqual(
new ValidateFieldError('Invalid answer submitted'),
)
})

it('should allow empty answer when not required', () => {
const formField = generateDefaultField(BasicField.Country, {
required: false,
})
const response = generateNewSingleAnswerResponse(BasicField.Country, {
answer: '',
})
const validateResult = validateField('formId', formField, response)
expect(validateResult.isOk()).toBe(true)
expect(validateResult._unsafeUnwrap()).toEqual(true)
})

it('should allow empty answer when it is required but not visible', () => {
const formField = generateDefaultField(BasicField.Country, {})
const response = generateNewSingleAnswerResponse(BasicField.Country, {
answer: '',
isVisible: false,
})
const validateResult = validateField('formId', formField, response)
expect(validateResult.isOk()).toBe(true)
expect(validateResult._unsafeUnwrap()).toEqual(true)
})

it('should disallow empty answer when it is required and visible', () => {
const formField = generateDefaultField(BasicField.Country, {})
const response = generateNewSingleAnswerResponse(BasicField.Country, {
answer: '',
})
const validateResult = validateField('formId', formField, response)
expect(validateResult.isErr()).toBe(true)
expect(validateResult._unsafeUnwrapErr()).toEqual(
new ValidateFieldError('Invalid answer submitted'),
)
})

it('should disallow multiple answers', () => {
const formField = generateDefaultField(BasicField.Country, {})
const response = generateNewSingleAnswerResponse(BasicField.Country, {
answer: [Country.Singapore, Country.Slovak_Republic] as unknown as string,
})
const validateResult = validateField('formId', formField, response)
expect(validateResult.isErr()).toBe(true)
expect(validateResult._unsafeUnwrapErr()).toEqual(
new ValidateFieldError('Response has invalid shape'),
)
})
it('should disallow responses submitted for hidden fields', () => {
const formField = generateDefaultField(BasicField.Country, {})
const response = generateNewSingleAnswerResponse(BasicField.Country, {
answer: Country.Singapore,
isVisible: false,
})
const validateResult = validateField('formId', formField, response)
expect(validateResult.isErr()).toBe(true)
expect(validateResult._unsafeUnwrapErr()).toEqual(
new ValidateFieldError('Attempted to submit response on a hidden field'),
)
})
})
30 changes: 30 additions & 0 deletions src/app/utils/field-validation/validators/countryValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { chain, left, right } from 'fp-ts/lib/Either'
import { flow } from 'fp-ts/lib/function'

import { Country } from '../../../../../shared/constants/countries'
import { ResponseValidator } from '../../../../types/field/utils/validation'
import { ProcessedSingleAnswerResponse } from '../../../modules/submission/submission.types'

import { notEmptySingleAnswerResponse } from './common'
import { isOneOfOptions } from './options'

type CountryValidator = ResponseValidator<ProcessedSingleAnswerResponse>
type CountryValidatorConstructor = () => CountryValidator

/**
* Returns a validation function
* to check if country selection is one of the options.
*/
const makeCountryValidator: CountryValidatorConstructor = () => (response) => {
const validOptions = Object.values(Country)
const { answer } = response
return isOneOfOptions(validOptions, answer)
? right(response)
: left(`CountryValidator:\t answer is not a valid country option`)
}

/**
* Returns a validation function for a country field when called.
*/
export const constructCountryValidator: CountryValidatorConstructor = () =>
flow(notEmptySingleAnswerResponse, chain(makeCountryValidator()))
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ const get = require('lodash/get')
const {
types: basicTypes,
} = require('../../../../../shared/constants/field/basic')
const { BasicField } = require('../../../../../shared/types/field')
const {
types: myInfoTypes,
} = require('../../../../../shared/constants/field/myinfo')

angular.module('forms').service('FormFields', [FormFields])

function FormFields() {
this.basicTypes = basicTypes
// TODO: Unhide Country field once the frontend for Country field is done
this.basicTypes = basicTypes.filter(
(type) => type.name !== BasicField.Country,
)
this.myInfoTypes = myInfoTypes
this.customValFields = ['textarea', 'textfield', 'number']

Expand Down
7 changes: 7 additions & 0 deletions src/types/field/countryField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { BasicField, CountryFieldBase } from '../../../shared/types'

import { IFieldSchema } from './baseField'

export interface ICountryFieldSchema extends CountryFieldBase, IFieldSchema {
fieldType: BasicField.Country
}
Loading

0 comments on commit e39111a

Please sign in to comment.