Skip to content

Commit

Permalink
1670: Show form hints (#1761)
Browse files Browse the repository at this point in the history
* 1670: add hints for name data privacy and referenceNr, adjust error message

* 1650: added line breaks to help texts

* 1670:  disallow multi spaces. move replace multi spaces to utils, fix small issues

* 1670: add check for emojis to name and referenceNr, allow empty space at the beginning and end of the name but trim it in generateCardInfo

* 1670: remove emoji check, add xregexp check for common and latin charset

* 1670: fix package json lock
  • Loading branch information
f1sh1918 authored Nov 19, 2024
1 parent 1f6cf24 commit ca1bd74
Show file tree
Hide file tree
Showing 11 changed files with 209 additions and 52 deletions.
4 changes: 3 additions & 1 deletion administration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,16 @@
"i18next": "^23.16.4",
"localforage": "^1.10.0",
"normalize.css": "^8.0.1",
"normalize-strings": "^1.1.1",
"notistack": "^3.0.1",
"pdf-lib": "^1.17.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-flip-move": "^3.0.5",
"react-i18next": "^15.1.0",
"react-router-dom": "^6.14.0",
"styled-components": "^5.3.11"
"styled-components": "^5.3.11",
"xregexp": "^5.1.1"
},
"devDependencies": {
"@babel/core": "^7.22.5",
Expand Down
70 changes: 41 additions & 29 deletions administration/src/bp-modules/self-service/CardSelfServiceForm.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { Checkbox, FormGroup, InputGroup, Intent } from '@blueprintjs/core'
import InfoOutlined from '@mui/icons-material/InfoOutlined'
import { Alert, styled } from '@mui/material'
import { styled } from '@mui/material'
import React, { ReactElement, useContext, useState } from 'react'

import { Card, isFullNameValid, isValid } from '../../cards/Card'
import { Card, getFullNameValidationErrorMessage, isFullNameValid, isValid } from '../../cards/Card'
import ClearInputButton from '../../cards/extensions/components/ClearInputButton'
import useWindowDimensions from '../../hooks/useWindowDimensions'
import BasicDialog from '../../mui-modules/application/BasicDialog'
import { ProjectConfigContext } from '../../project-configs/ProjectConfigContext'
import { removeMultipleSpaces } from '../../util/helper'
import { useAppToaster } from '../AppToaster'
import ExtensionForms from '../cards/ExtensionForms'
import { DataPrivacyAcceptingStatus } from './CardSelfServiceView'
import { ActionButton } from './components/ActionButton'
import FormErrorMessage from './components/FormErrorMessage'
import { IconTextButton } from './components/IconTextButton'
import { UnderlineTextButton } from './components/UnderlineTextButton'

Expand All @@ -19,34 +23,18 @@ const StyledCheckbox = styled(Checkbox)`
margin-left: 4px;
`

const StyledAlert = styled(Alert)`
margin-bottom: 24px;
white-space: pre-line;
`

const Container = styled('div')`
margin-bottom: 24px;
`

type CardSelfServiceFormProps = {
card: Card
updateCard: (card: Partial<Card>) => void
dataPrivacyAccepted: boolean
setDataPrivacyAccepted: (value: boolean) => void
dataPrivacyAccepted: DataPrivacyAcceptingStatus
setDataPrivacyAccepted: (status: DataPrivacyAcceptingStatus) => void
generateCards: () => Promise<void>
}

const getTooltipMessage = (cardsValid: boolean, dataPrivacyAccepted: boolean): string => {
const tooltipMessages: string[] = []
if (!cardsValid) {
tooltipMessages.push('Mindestens eine Ihrer Angaben ist ungültig.')
}
if (!dataPrivacyAccepted) {
tooltipMessages.push('Bitte akzeptieren Sie die Datenschutzerklärung.')
}

return tooltipMessages.join('\n')
}
const CardSelfServiceForm = ({
card,
updateCard,
Expand All @@ -59,7 +47,24 @@ const CardSelfServiceForm = ({
const [openDataPrivacy, setOpenDataPrivacy] = useState<boolean>(false)
const [openReferenceInformation, setOpenReferenceInformation] = useState<boolean>(false)
const cardValid = isValid(card, { expirationDateNullable: true })
const cardCreationDisabled = !cardValid || !dataPrivacyAccepted
const appToaster = useAppToaster()

const createKoblenzPass = async () => {
if (cardValid && dataPrivacyAccepted === DataPrivacyAcceptingStatus.accepted) {
await generateCards()
return
}
if (dataPrivacyAccepted === DataPrivacyAcceptingStatus.untouched) {
setDataPrivacyAccepted(DataPrivacyAcceptingStatus.denied)
}
appToaster?.show({
message: (
<FormErrorMessage style={{ color: 'white' }} errorMessage='Mindestens eine Ihrer Angaben ist ungültig.' />
),
timeout: 0,
intent: 'danger',
})
}

return (
<>
Expand All @@ -78,25 +83,32 @@ const CardSelfServiceForm = ({
}
intent={isFullNameValid(card) ? undefined : Intent.DANGER}
value={card.fullName}
onChange={event => updateCard({ fullName: event.target.value })}
onChange={event => updateCard({ fullName: removeMultipleSpaces(event.target.value) })}
/>
<FormErrorMessage errorMessage={getFullNameValidationErrorMessage(card.fullName)} />
</FormGroup>
<ExtensionForms card={card} updateCard={updateCard} />
<IconTextButton onClick={() => setOpenReferenceInformation(true)}>
<InfoOutlined />
Wo finde ich das Aktenzeichen?
</IconTextButton>
<StyledCheckbox checked={dataPrivacyAccepted} onChange={() => setDataPrivacyAccepted(!dataPrivacyAccepted)}>
<StyledCheckbox
checked={dataPrivacyAccepted === DataPrivacyAcceptingStatus.accepted}
onChange={() =>
setDataPrivacyAccepted(
dataPrivacyAccepted === DataPrivacyAcceptingStatus.accepted
? DataPrivacyAcceptingStatus.denied
: DataPrivacyAcceptingStatus.accepted
)
}>
Ich akzeptiere die{' '}
<UnderlineTextButton onClick={() => setOpenDataPrivacy(true)}>Datenschutzerklärung</UnderlineTextButton>.
</StyledCheckbox>
{dataPrivacyAccepted === DataPrivacyAcceptingStatus.denied && (
<FormErrorMessage errorMessage='Bitte akzeptieren sie die Datenschutzerklärung' />
)}
</Container>
{cardCreationDisabled && (
<StyledAlert variant='outlined' severity='warning'>
{getTooltipMessage(cardValid, dataPrivacyAccepted)}
</StyledAlert>
)}
<ActionButton onClick={generateCards} variant='contained' disabled={cardCreationDisabled} size='large'>
<ActionButton onClick={createKoblenzPass} variant='contained' size='large'>
KoblenzPass erstellen
</ActionButton>
<BasicDialog
Expand Down
14 changes: 11 additions & 3 deletions administration/src/bp-modules/self-service/CardSelfServiceView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,18 @@ const StyledInfoTextButton = styled(IconTextButton)`
margin: 0;
`

export enum DataPrivacyAcceptingStatus {
untouched,
accepted,
denied,
}

// TODO 1646 Add tests for CardSelfService
const CardSelfServiceView = (): ReactElement => {
const projectConfig = useContext(ProjectConfigContext)
const [dataPrivacyAccepted, setDataPrivacyAccepted] = useState<boolean>(false)
const [dataPrivacyCheckbox, setDataPrivacyCheckbox] = useState<DataPrivacyAcceptingStatus>(
DataPrivacyAcceptingStatus.untouched
)
const {
selfServiceState,
setSelfServiceState,
Expand Down Expand Up @@ -127,8 +135,8 @@ const CardSelfServiceView = (): ReactElement => {
{selfServiceState === CardSelfServiceStep.form && (
<CardSelfServiceForm
card={selfServiceCard}
dataPrivacyAccepted={dataPrivacyAccepted}
setDataPrivacyAccepted={setDataPrivacyAccepted}
dataPrivacyAccepted={dataPrivacyCheckbox}
setDataPrivacyAccepted={setDataPrivacyCheckbox}
updateCard={updatedCard => setSelfServiceCard(updateCard(selfServiceCard, updatedCard))}
generateCards={generateCards}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import InfoOutlined from '@mui/icons-material/InfoOutlined'
import { styled } from '@mui/material'
import React, { CSSProperties, ReactElement } from 'react'

const Container = styled('div')`
margin: 6px 0;
color: #ba1a1a;
display: flex;
gap: 8px;
align-items: center;
font-size: 14px;
`

type FormErrorMessageProps = {
errorMessage: string | null
style?: CSSProperties
}

const FormErrorMessage = ({ errorMessage, style }: FormErrorMessageProps): ReactElement | null =>
errorMessage ? (
<Container style={style}>
<InfoOutlined />
{errorMessage}
</Container>
) : null

export default FormErrorMessage
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ApolloError } from '@apollo/client'
import { useCallback, useContext, useState } from 'react'
import React, { useCallback, useContext, useState } from 'react'

import { Card, generateCardInfo, initializeCard } from '../../../cards/Card'
import { generatePdf } from '../../../cards/PdfFactory'
Expand All @@ -12,6 +12,7 @@ import { base64ToUint8Array, uint8ArrayToBase64 } from '../../../util/base64'
import downloadDataUri from '../../../util/downloadDataUri'
import getCustomDeepLinkFromQrCode from '../../../util/getCustomDeepLinkFromQrCode'
import { useAppToaster } from '../../AppToaster'
import FormErrorMessage from '../components/FormErrorMessage'

export enum CardSelfServiceStep {
form,
Expand Down Expand Up @@ -54,9 +55,9 @@ const useCardGeneratorSelfService = (): UseCardGeneratorSelfServiceReturn => {
if (error instanceof ApolloError) {
const { title } = getMessageFromApolloError(error)
appToaster?.show({
message: title,
intent: 'danger',
message: <FormErrorMessage style={{ color: 'white' }} errorMessage={title} />,
timeout: 0,
intent: 'danger',
})
} else {
appToaster?.show({
Expand Down
33 changes: 33 additions & 0 deletions administration/src/cards/Card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BavariaCardType } from '../generated/card_pb'
import { Region } from '../generated/graphql'
import PlainDate from '../util/PlainDate'
import {
MAX_NAME_LENGTH,
generateCardInfo,
getValueByCSVHeader,
initializeCard,
Expand Down Expand Up @@ -139,6 +140,38 @@ describe('Card', () => {
})
})

it.each(['$tefan Mayer', 'Karla K.', 'Karla Karls😀', 'إئبآء؟ؤئحجرزش'])(
'should correctly identify invalid special characters in fullname',
fullName => {
const card = initializeCard(cardConfig, region, { fullName })
expect(card.fullName).toBe(fullName)
expect(isValueValid(card, cardConfig, 'Name')).toBeFalsy()
expect(isValid(card)).toBeFalsy()
}
)

it.each([' Karla Koblenz', ' Karla Karl', ' Karla Karls '])(
'should correctly create a card even with whitespace in the beginning and end',
fullName => {
const card = initializeCard(cardConfig, region, { fullName })
expect(card.fullName).toBe(fullName)
expect(isValueValid(card, cardConfig, 'Name')).toBeTruthy()
expect(isValid(card)).toBeTruthy()
}
)

it.each(['Karla', 'Karl L'])('should correctly identify invalid fullname that is incomplete', fullName => {
const card = initializeCard(cardConfig, region, { fullName })
expect(isValueValid(card, cardConfig, 'Name')).toBeFalsy()
expect(isValid(card)).toBeFalsy()
})

it(`should correctly identify invalid fullname that exceeds max length (${MAX_NAME_LENGTH} characters)`, () => {
const card = initializeCard(cardConfig, region, { fullName: 'Karl LauterLauterLauterLauterLauterLauterLauterbach' })
expect(isValueValid(card, cardConfig, 'Name')).toBeFalsy()
expect(isValid(card)).toBeFalsy()
})

describe('self service', () => {
const cardConfig = {
defaultValidity: { years: 3 },
Expand Down
33 changes: 29 additions & 4 deletions administration/src/cards/Card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { CardExtensions, CardInfo } from '../generated/card_pb'
import { Region } from '../generated/graphql'
import { CardConfig } from '../project-configs/getProjectConfig'
import PlainDate from '../util/PlainDate'
import { containsOnlyLatinAndCommonCharset, containsSpecialCharacters, removeMultipleSpaces } from '../util/helper'
import { REGION_EXTENSION_NAME } from './extensions/RegionExtension'
import Extensions, { Extension, ExtensionKey, ExtensionState, InferExtensionStateType } from './extensions/extensions'

// Due to limited space on the cards
const MAX_NAME_LENGTH = 30
export const MAX_NAME_LENGTH = 30
// Due to limited space on the qr code
const MAX_ENCODED_NAME_LENGTH = 50

Expand Down Expand Up @@ -66,12 +67,22 @@ export const getExtensions = ({ extensions }: Card): ExtensionWithState[] => {

export const hasInfiniteLifetime = (card: Card): boolean =>
getExtensions(card).some(({ extension, state }) => extension.causesInfiniteLifetime(state))

export const isFullNameValid = ({ fullName }: Card): boolean => {
const hasValidNameLength = (fullName: string): boolean => {
const encodedName = new TextEncoder().encode(fullName)
return fullName.length > 0 && encodedName.length <= MAX_ENCODED_NAME_LENGTH && fullName.length <= MAX_NAME_LENGTH
}

const hasNameAndForename = (fullName: string): boolean => {
const names = removeMultipleSpaces(fullName).trim().split(' ')
return names.length > 1 && names.every(name => name.length > 1)
}

export const isFullNameValid = ({ fullName }: Card): boolean =>
hasValidNameLength(fullName) &&
hasNameAndForename(fullName) &&
containsOnlyLatinAndCommonCharset(fullName) &&
!containsSpecialCharacters(fullName)

export const isExpirationDateValid = (card: Card, { nullable } = { nullable: false }): boolean => {
const today = PlainDate.fromLocalDate(new Date())
const startDay = card.extensions.startDay
Expand Down Expand Up @@ -103,7 +114,7 @@ export const generateCardInfo = (card: Card): CardInfo => {
expirationDate !== null && !hasInfiniteLifetime(card) ? Math.max(expirationDate.toDaysSinceEpoch(), 0) : undefined

return new CardInfo({
fullName: card.fullName,
fullName: card.fullName.trim(),
expirationDay,
extensions: new CardExtensions(extensionsMessage),
})
Expand Down Expand Up @@ -182,3 +193,17 @@ export const updateCard = (oldCard: Card, updatedCard: Partial<Card>): Card => (
...(updatedCard.extensions ?? {}),
},
})

export const getFullNameValidationErrorMessage = (name: string): string | null => {
const errors: string[] = []
if (!containsOnlyLatinAndCommonCharset(name) || containsSpecialCharacters(name)) {
errors.push('Der Name darf keine Sonderzeichen oder Zahlen enthalten.')
}
if (!hasNameAndForename(name)) {
errors.push('Bitte geben Sie Ihren vollständigen Namen ein.')
}
if (!hasValidNameLength(name)) {
errors.push(`Der Name darf nicht länger als ${MAX_NAME_LENGTH} Zeichen sein`)
}
return errors.join(' ')
}
Loading

0 comments on commit ca1bd74

Please sign in to comment.