Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1670: Show form hints #1761

Merged
merged 8 commits into from
Nov 19, 2024
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) {
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
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
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,31 @@
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;
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
display: flex;
gap: 8px;
align-items: center;
font-size: 14px;
`

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

const FormErrorMessage = ({ errorMessage, style }: FormErrorMessageProps): ReactElement | null => {
if (!errorMessage) {
return null
}
return (
<Container style={style}>
<InfoOutlined />
{errorMessage}
</Container>
)
}
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved

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} />,
ztefanie marked this conversation as resolved.
Show resolved Hide resolved
timeout: 0,
intent: 'danger',
})
} else {
appToaster?.show({
Expand Down
23 changes: 23 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,28 @@ describe('Card', () => {
})
})

it.each(['$tefan Mayer', 'Karla K.', 'Karla 1234 '])(
ztefanie marked this conversation as resolved.
Show resolved Hide resolved
'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', '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
30 changes: 28 additions & 2 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 { 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 @@ -67,11 +68,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 containsNameSpecialCharacters = (fullName: string): boolean =>
/[`!@#$%^&*()_+\-=\]{};':"\\|,.<>?~0123456789]/.test(fullName)
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved

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).split(' ')
return names.length > 1 && names.every(name => name.length > 1)
steffenkleinle marked this conversation as resolved.
Show resolved Hide resolved
}

export const isFullNameValid = ({ fullName }: Card): boolean =>
hasValidNameLength(fullName) && hasNameAndForename(fullName) && !containsNameSpecialCharacters(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 @@ -182,3 +194,17 @@ export const updateCard = (oldCard: Card, updatedCard: Partial<Card>): Card => (
...(updatedCard.extensions ?? {}),
},
})

export const getFullNameValidationErrorMessage = (name: string): string | null => {
const errors: string[] = []
if (containsNameSpecialCharacters(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(' ')
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FormGroup, InputGroup, Intent } from '@blueprintjs/core'
import React, { ReactElement } from 'react'

import FormErrorMessage from '../../bp-modules/self-service/components/FormErrorMessage'
import useWindowDimensions from '../../hooks/useWindowDimensions'
import ClearInputButton from './components/ClearInputButton'
import { Extension, ExtensionComponentProps } from './extensions'
Expand All @@ -11,6 +12,9 @@ type KoblenzReferenceNumberExtensionState = { [KOBLENZ_REFERENCE_NUMBER_EXTENSIO

const KoblenzReferenceNumberMinLength = 4
const KoblenzReferenceNumberMaxLength = 15
const hasSpecialChars = (referenceNr: string): boolean => /[`!@#$%^&*()_+\-=\]{};':"\\|,<>?~]/.test(referenceNr)
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
const hasInvalidLength = (referenceNumberLength: number): boolean =>
referenceNumberLength < KoblenzReferenceNumberMinLength || referenceNumberLength > KoblenzReferenceNumberMaxLength

const KoblenzReferenceNumberExtensionForm = ({
value,
Expand All @@ -20,6 +24,16 @@ const KoblenzReferenceNumberExtensionForm = ({
const { viewportSmall } = useWindowDimensions()
const clearInput = () => setValue({ koblenzReferenceNumber: '' })

const getErrorMessage = (): string | null => {
if (hasSpecialChars(value.koblenzReferenceNumber)) {
return 'Das Aktenzeichen enthält ungültige Sonderzeichen.'
}
if (hasInvalidLength(value.koblenzReferenceNumber.length)) {
return `Das Aktenzeichen muss eine Länge zwischen ${KoblenzReferenceNumberMinLength} und ${KoblenzReferenceNumberMaxLength} haben.`
}
return null
}

return (
<FormGroup
label='Aktenzeichen'
Expand All @@ -32,18 +46,12 @@ const KoblenzReferenceNumberExtensionForm = ({
placeholder='5.012.067.281, 000D000001, 99478'
intent={isValid ? undefined : Intent.DANGER}
value={value.koblenzReferenceNumber}
minLength={KoblenzReferenceNumberMinLength}
maxLength={KoblenzReferenceNumberMaxLength}
rightElement={
<ClearInputButton viewportSmall={viewportSmall} onClick={clearInput} input={value.koblenzReferenceNumber} />
}
onChange={event => {
const value = event.target.value
if (value.length <= KoblenzReferenceNumberMaxLength) {
setValue({ koblenzReferenceNumber: value })
}
}}
onChange={event => setValue({ koblenzReferenceNumber: event.target.value })}
/>
<FormErrorMessage errorMessage={getErrorMessage()} />
</FormGroup>
)
}
Expand Down
2 changes: 1 addition & 1 deletion administration/src/errors/DefaultErrorMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const defaultErrorMap = (extensions?: ErrorExtensions): GraphQLErrorMessage => {
case GraphQlExceptionCode.UserEntitlementExpired:
return {
title:
'Sie sind nicht länger berechtigt einen KoblenzPass zu erstellen. Bitte kontaktieren Sie [email protected] für weitere Informationen.',
'Sie sind nicht länger berechtigt, einen KoblenzPass zu erstellen. Bitte kontaktieren Sie [email protected] für weitere Informationen.',
}
case GraphQlExceptionCode.MailNotSent:
return {
Expand Down
3 changes: 3 additions & 0 deletions administration/src/util/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ export const updateArrayItem = <T>(array: T[], updatedItem: T, index: number): T

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never

const multipleSpacePattern = /\s\s+/g
export const removeMultipleSpaces = (value: string): string => value.replace(multipleSpacePattern, ' ')
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved