diff --git a/.circleci/config.yml b/.circleci/config.yml index 86fa549e..56972233 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,16 +7,31 @@ version: 2 defaults: &defaults working_directory: ~/repo docker: - - image: cimg/node:21.6.1 + - image: cimg/node:lts jobs: test: <<: *defaults steps: - checkout - - run: npm ci - - run: npm run build - - run: npm run test + - run: + name: Install dependencies + command: npm ci + - run: + name: Build package + command: npm run build + - run: + name: Install JUnit coverage reporter + command: npm i -D jest-junit + - run: + name: Run tests + command: | + npx jest --listTests | circleci tests run --command="JEST_JUNIT_ADD_FILE_ATTRIBUTE=true xargs npx jest --config jest.config.ts --runInBand --reporters=default --reporters=jest-junit --" --verbose --split-by=timings + environment: + JEST_JUNIT_OUTPUT_DIR: ./reports/ + JEST_JUNIT_ADD_FILE_ATTRIBUTE: "true" + - store_test_results: + path: ./reports/ - persist_to_workspace: root: ~/repo paths: diff --git a/src/client.tsx b/src/client.tsx index bd7a3c87..e91e5f86 100644 --- a/src/client.tsx +++ b/src/client.tsx @@ -8,7 +8,7 @@ import { I18nMessages } from './core/i18n'; import { UserError } from './helpers/errors'; import { logError } from './helpers/logger'; -import { ErrorMessage } from './components/error' +import { ErrorText } from './components/miscComponent' import type { Context, I18nProps, ThemeProps } from './components/widget/widget' import authWidget, { type AuthWidgetProps } from './widgets/auth/authWidget'; @@ -154,7 +154,7 @@ export class UiClient { } } catch (error) { const message = this.adaptError(error); - root.render({message}) + root.render({message}) this.handleError(error) } } diff --git a/src/components/form/buttonComponent.tsx b/src/components/form/buttonComponent.tsx index 41450a38..46124f04 100644 --- a/src/components/form/buttonComponent.tsx +++ b/src/components/form/buttonComponent.tsx @@ -1,71 +1,53 @@ -import React, { MouseEventHandler, PropsWithChildren } from 'react'; +import React, { PropsWithChildren } from 'react'; import styled, { useTheme, type DefaultTheme } from 'styled-components'; import { darken } from 'polished'; -import classes from 'classnames'; const buttonTheme = -

({ theme, themePrefix = 'button' as ThemePrefix }: P, attr: Attr): DefaultTheme[ThemePrefix][Attr] => +>(theme: DefaultTheme, themePrefix: ThemePrefix = 'button' as ThemePrefix, attr: Attr): DefaultTheme[ThemePrefix][Attr] => theme[themePrefix][attr] -export type ButtonProps = { - tagname?: 'button' | 'div' - className?: classes.Argument - extendedClasses?: classes.Argument - title?: HTMLButtonElement['title'] - disabled?: HTMLButtonElement['disabled'] - type?: HTMLButtonElement['type'] - dataTestId?: string - onClick?: MouseEventHandler & MouseEventHandler - color?: string - background?: string - border?: string - themePrefix?: 'button' | 'socialButton' +export type ExtraButtonProps = { + $color?: string + $background?: string + $border?: string + $themePrefix?: 'button' | 'socialButton' } -export const Button = styled(({ tagname = 'button', className, extendedClasses, title, disabled, type, dataTestId, onClick, children }: PropsWithChildren) => { - const Tagname = tagname; +export type ButtonProps = React.ComponentProps - return ( - {children} - ); -})` +export const Button = styled.button` display: block; width: 100%; box-sizing: border-box; text-align: center; - font-weight: ${props => buttonTheme(props, 'fontWeight')}; + font-weight: ${props => buttonTheme(props.theme, props.$themePrefix, 'fontWeight')}; vertical-align: middle; user-select: none; touch-action: manipulation; cursor: pointer; - color: ${props => props.color ?? '#ffffff'}; - background-color: ${props => props.background}; - border: ${props => buttonTheme(props, 'borderWidth')}px solid ${props => props.border}; - padding: ${props => buttonTheme(props, 'paddingY')}px ${props => buttonTheme(props, 'paddingX')}px; - font-size: ${props => buttonTheme(props, 'fontSize')}px; - line-height: ${props => buttonTheme(props, 'lineHeight')}; - border-radius: ${props => buttonTheme(props, 'borderRadius')}px; + color: ${props => props.$color ?? '#ffffff'}; + background-color: ${props => props.$background}; + border: ${props => buttonTheme(props.theme, props.$themePrefix, 'borderWidth')}px solid ${props => props.$border}; + padding: ${props => buttonTheme(props.theme, props.$themePrefix, 'paddingY')}px ${props => buttonTheme(props.theme, props.$themePrefix, 'paddingX')}px; + font-size: ${props => buttonTheme(props.theme, props.$themePrefix, 'fontSize')}px; + line-height: ${props => buttonTheme(props.theme, props.$themePrefix, 'lineHeight')}; + border-radius: ${props => buttonTheme(props.theme, props.$themePrefix, 'borderRadius')}px; transition: all .15s ease-in-out; &:focus { outline: 0; - box-shadow: ${props => buttonTheme(props, 'focusBoxShadow')(props.border)}; + box-shadow: ${props => buttonTheme(props.theme, props.$themePrefix, 'focusBoxShadow')(props.$border)}; } &:hover, &:active { - color: ${props => props.color}; - background-color: ${props => darken(0.08, props.background ?? props.theme.backgroundColor)}; - border-color: ${props => darken(0.08, props.border ?? props.theme.borderColor)}; + color: ${props => props.$color}; + background-color: ${props => darken(0.08, props.$background ?? props.theme.backgroundColor)}; + border-color: ${props => darken(0.08, props.$border ?? props.theme.borderColor)}; } &[disabled] { @@ -75,27 +57,27 @@ export const Button = styled(({ tagname = 'button', className, extendedClasses, interface DefaultButtonProps extends Omit {} -export const DefaultButton = ({ children, ...props }: PropsWithChildren) => { +export function DefaultButton({ children, ...props }: PropsWithChildren) { const theme = useTheme() return ( - ) -}; +} interface PrimaryButtonProps extends Omit {} -export const PrimaryButton = ({ children, type = "submit", ...props }: PropsWithChildren) => { +export function PrimaryButton({ children, type = 'submit', ...props }: PropsWithChildren) { const theme = useTheme() return ( ) -}; +} \ No newline at end of file diff --git a/src/components/form/fieldCreator.d.ts b/src/components/form/fieldCreator.d.ts deleted file mode 100644 index 15d42ceb..00000000 --- a/src/components/form/fieldCreator.d.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ComponentType } from 'react'; - -import { WithI18n } from '../../contexts/i18n'; -import { I18nResolver } from '../../core/i18n'; -import { Validator } from '../../core/validation'; -import { PathMapping } from '../../core/mapping'; -import { VaildatorResult } from '../../core/validation'; -import { FormValue } from '../../helpers/utils'; -import { Prettify } from '../../types'; - -interface FieldCreateProps { - showLabel: boolean -} - -export interface FieldCreator { - path: string, - create: (options: WithI18n) => Field -} - -export interface Field { - key: string - render: (props: P & Partial> & { state: FieldValue }) => React.ReactNode - initialize: (model: M) => FieldValue - unbind: (model: M, state: FieldValue) => M - validate: (data: FieldValue, ctx: S) => VaildatorResult -} - -export type FieldValue = E & { - value?: T - isDirty?: boolean - validation?: VaildatorResult -} - -export type FieldComponentProps = P & { - inputId: string - key: string - path: string - label: string - onChange: (value: FieldValue) => void - placeholder?: string - autoComplete?: AutoFill - required?: boolean - readOnly?: boolean - i18n: I18nResolver - showLabel?: boolean - value?: FormValue - validation?: VaildatorResult -} - - -export interface Formatter { - bind: (value?: T) => FormValue - unbind: (value?: FormValue) => T | null -} - -export interface FieldProps, ExtraParams extends Record = {}> { - key: string - path?: string - type?: string - label: string - defaultValue?: F - required?: boolean - readOnly?: boolean - autoComplete?: AutoFill - validator?: Validator - mapping?: PathMapping - format?: Formatter - rawProperty?: string - component: ComponentType

- extendedParams?: Prettify>> | ((i18n: I18nResolver) => Prettify>>) -} - -export function createField, ExtraParams extends Record = {}>(props: FieldProps): FieldCreator diff --git a/src/components/form/fieldCreator.jsx b/src/components/form/fieldCreator.jsx deleted file mode 100644 index 1913a0f9..00000000 --- a/src/components/form/fieldCreator.jsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; - -import { PathMapping } from '../../core/mapping'; -import { required as requiredRule, empty as emptyRule } from '../../core/validation'; -import { isEmpty, isValued } from '../../helpers/utils'; -import generateId from '../../helpers/inputIdGenerator'; -import { camelCasePath } from '../../helpers/transformObjectProperties'; - -export const createField = ({ - key, - path = key, - label, - defaultValue, - required = true, - readOnly = false, - autoComplete, - validator = emptyRule, - mapping = new PathMapping(camelCasePath(path)), - format = { - bind: x => isValued(x) ? x : '', - unbind: x => !isEmpty(x) ? x : null - }, - rawProperty = 'raw', - component, - extendedParams = {} -}) => ({ - path: path, - create: ({ i18n, showLabel }) => { - const extParams = typeof extendedParams === 'function' ? extendedParams(i18n) : extendedParams; - const staticProps = { - inputId: generateId(key), - key, - path: key, - label: i18n(label), - required, - readOnly, - autoComplete, - i18n, - showLabel, - ...extParams - }; - - const fullValidator = (required ? requiredRule.and(validator) : validator).create(i18n); - const Component = component; - - return { - key, - render: ({ state, ...rest }) => { - const { key, ...props } = { ...staticProps, ...rest} - return - }, - initialize: model => { - const modelValue = mapping.bind(model); - const initValue = isValued(modelValue, rawProperty) ? modelValue : defaultValue; - return { - value: format.bind(initValue), - isDirty: false - }; - }, - unbind: (model, { value }) => mapping.unbind(model, format.unbind(value)), - validate: ({ value }, ctx) => ( - (required || isValued(value)) ? fullValidator(value, ctx) : {} - ) - }; - } -}); diff --git a/src/components/form/fieldCreator.tsx b/src/components/form/fieldCreator.tsx new file mode 100644 index 00000000..7185889f --- /dev/null +++ b/src/components/form/fieldCreator.tsx @@ -0,0 +1,160 @@ +import React, { type ComponentType } from 'react'; + +import type { WithI18n } from '../../contexts/i18n' +import type { I18nResolver } from '../../core/i18n'; +import { PathMapping } from '../../core/mapping'; +import { + required as requiredRule, + empty as emptyRule, + type CompoundValidator, + type Validator, + type ValidatorResult, + type ValidatorSuccess, + isValidatorSuccess +} from '../../core/validation'; +import generateId from '../../helpers/inputIdGenerator'; +import { camelCasePath } from '../../helpers/transformObjectProperties'; +import { type FormValue, isRichFormValue, isValued } from '../../helpers/utils'; +import type { FormContext } from './formComponent'; + +interface FieldCreateProps { + showLabel: boolean +} + +export interface FieldCreator = {}, K extends string = 'raw'> { + path: string, + create: (options: WithI18n) => Field +} + +export interface Field = {}, K extends string = 'raw'> { + key: string + render: (props: Partial

& Partial> & { state: FieldValue }) => React.ReactNode + initialize: >(model: M) => FieldValue + unbind: >(model: M, state: FieldValue) => M + validate: (data: FieldValue, ctx: FormContext) => ValidatorResult +} + +export type FieldValue = {}> = E & { + value?: FormValue + isDirty?: boolean + validation?: ValidatorResult +} + +export type FieldComponentProps = {}, K extends string = 'raw'> = P & { + inputId: string + key: string + path: string + label: string + onChange: (value: FieldValue) => void + placeholder?: string + autoComplete?: AutoFill + rawProperty?: K + required?: boolean + readOnly?: boolean + i18n: I18nResolver + showLabel?: boolean + value?: FormValue + validation?: ValidatorResult +} + + +export interface Formatter { + bind: (value?: T) => FormValue | undefined + unbind: (value?: FormValue) => T | null +} + +export type FieldDefinition = { + key: string + path?: string + label: string + required?: boolean + readOnly?: boolean + autoComplete?: AutoFill + defaultValue?: T + format?: Formatter + validator?: Validator | CompoundValidator +} + +export interface FieldProps< + T, F, P extends FieldComponentProps, + ExtraParams extends Record = {}, + K extends string = 'raw', + E extends Record = {} +> extends FieldDefinition { + label: string + mapping?: PathMapping + format?: Formatter + rawProperty?: K + component: ComponentType

+ extendedParams?: ExtraParams | ((i18n: I18nResolver) => ExtraParams) +} + +export function createField< + T, + F, + P extends FieldComponentProps, + ExtraParams extends Record = {}, + K extends string = 'raw', + E extends Record = {} +>({ + key, + path = key, + label, + defaultValue, + required = true, + readOnly = false, + autoComplete, + validator = emptyRule, + mapping = new PathMapping(camelCasePath(path)), + format = { + bind: x => isValued(x) ? x as F : undefined, + unbind: x => (isValued(x) && isRichFormValue(x, rawProperty) ? x[rawProperty] as T : x as T) + }, + rawProperty = 'raw' as K, + component: Component, + extendedParams = {} as ExtraParams +}: FieldProps): FieldCreator { + return ({ + path: path, + create: ({ i18n, showLabel }: WithI18n): Field => { + const extParams = typeof extendedParams === 'function' ? extendedParams(i18n) : extendedParams; + const staticProps: Partial> = { + inputId: generateId(key), + key, + path: key, + label: i18n(label), + required, + readOnly, + autoComplete, + i18n, + showLabel, + ...extParams + }; + + return { + key, + render: ({ state: { value, validation }, ...props }: Partial

& { state: FieldValue }) => ( + + ), + initialize: >(model: M): FieldValue => { + const modelValue = mapping.bind(model) as T; + const initValue = isValued(modelValue, rawProperty) ? modelValue : defaultValue; + return { + value: format.bind(initValue), + isDirty: false + }; + }, + unbind: >(model: M, { value }: FieldValue): M => ( + mapping.unbind(model, format.unbind(value)) as M + ), + validate: ({ value: formValue }: FieldValue, ctx: FormContext): ValidatorResult => { + const value = isRichFormValue(formValue, rawProperty) ? formValue[rawProperty] : formValue + const requireValidation = required ? requiredRule.create(i18n)(value, ctx) : { success: true } satisfies ValidatorSuccess + return isValidatorSuccess(requireValidation) && isValued(value) + ? validator.create(i18n)(value, ctx) + : requireValidation + } + }; + } + }) +} diff --git a/src/components/form/fields/birthdayField.d.ts b/src/components/form/fields/birthdayField.d.ts deleted file mode 100644 index 20df853f..00000000 --- a/src/components/form/fields/birthdayField.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { BaseOptions } from './simpleField' -import type { FieldCreator } from '../fieldCreator' - -export interface Options extends BaseOptions {} - -export default function birthdateField(options: Options): FieldCreator diff --git a/src/components/form/fields/birthdayField.jsx b/src/components/form/fields/birthdayField.jsx deleted file mode 100644 index 7b42b203..00000000 --- a/src/components/form/fields/birthdayField.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import { DateTime } from 'luxon' - -import dateField from './dateField' -import { Validator } from '../../../core/validation'; - -export const ageLimitValidator = (min = 6, max = 129) => new Validator({ - rule: (value) => { - const age = DateTime.now().diff(value.raw, 'years').years - return min < age && age < max - }, - hint: 'birthdate.yearLimit', - parameters: { min, max } -}) - -export default function birthdateField({ min, max, label, ...props }, config) { - return dateField({ - ...props, - label: label || 'birthdate', - validator: ageLimitValidator(min, max) - }, config) -} diff --git a/src/components/form/fields/birthdayField.tsx b/src/components/form/fields/birthdayField.tsx new file mode 100644 index 00000000..180549fc --- /dev/null +++ b/src/components/form/fields/birthdayField.tsx @@ -0,0 +1,25 @@ +import { DateTime } from 'luxon' + +import dateField from './dateField' +import { Validator } from '../../../core/validation'; +import { Config } from '../../../types'; + +export const ageLimitValidator = (min = 6, max = 129) => new Validator({ + rule: (value) => { + const age = DateTime.now().diff(value, 'years').years + return min < age && age < max + }, + hint: 'birthdate.yearLimit', + parameters: { min, max } +}) + +export default function birthdateField( + { min, max, label = 'birthdate', ...props }: Parameters[0] & { min?: number, max?: number }, + config: Config +) { + return dateField({ + ...props, + label: label, + validator: ageLimitValidator(min, max) + }, config) +} diff --git a/src/components/form/fields/checkboxField.d.ts b/src/components/form/fields/checkboxField.d.ts deleted file mode 100644 index 8ab80394..00000000 --- a/src/components/form/fields/checkboxField.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BaseOptions } from './simpleField' -import type { FieldCreator } from '../fieldCreator' - -export interface Options extends BaseOptions { -} - -export default function checkboxField(options: Options): FieldCreator diff --git a/src/components/form/fields/checkboxField.jsx b/src/components/form/fields/checkboxField.jsx deleted file mode 100644 index 96954c0e..00000000 --- a/src/components/form/fields/checkboxField.jsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; - -import { createField } from '../fieldCreator'; -import { Checkbox } from '../formControlsComponent'; - -const CheckboxField = props => { - const { value, onChange, label, path, required, validation = {} } = props - const clickUpdate = ({ value }) => ({ - value: !value, - isDirty: true - }); - return onChange(clickUpdate)} - name={path} - label={label} - {...(({ error }) => ({ error }))(validation)} - required={required} - dataTestId={path} /> -}; - -export default function checkboxField(config) { - return createField({ - ...config, - format: { - bind: x => !!x, - unbind: x => x - }, - component: CheckboxField - }); -} diff --git a/src/components/form/fields/checkboxField.tsx b/src/components/form/fields/checkboxField.tsx new file mode 100644 index 00000000..a04e4897 --- /dev/null +++ b/src/components/form/fields/checkboxField.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { createField, type FieldComponentProps, type FieldProps } from '../fieldCreator'; +import { Checkbox } from '../formControlsComponent'; +import { isRichFormValue } from '../../../helpers/utils'; + +export interface CheckboxFieldProps extends FieldComponentProps {} + +function CheckboxField({ value, onChange, label, path, required, validation = {} }: CheckboxFieldProps) { + const checked = isRichFormValue(value, 'raw') ? value.raw : value + + const onToggle = () => onChange({ + value: !value, + isDirty: true + }) + + const error = typeof validation === 'object' && 'error' in validation ? validation.error : undefined + + return ( + + ) +}; + +export default function checkboxField(props: Omit, 'format' | 'component'>) { + return createField({ + ...props, + format: { + bind: value => !!value, + unbind: value => value ? (isRichFormValue(value, 'raw') ? value.raw : value) : false + }, + component: CheckboxField + }); +} diff --git a/src/components/form/fields/consentField.d.ts b/src/components/form/fields/consentField.d.ts deleted file mode 100644 index 392b077e..00000000 --- a/src/components/form/fields/consentField.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { BaseOptions } from './simpleField' -import type { FieldCreator } from '../fieldCreator' - -export interface Options extends BaseOptions { - path: string - type: 'opt-in', - extendedParams: { - version: { - versionId: number - language: string - }, - description: string - consentCannotBeGranted: boolean - } -} - -export default function consentField(options: Options): FieldCreator diff --git a/src/components/form/fields/consentField.jsx b/src/components/form/fields/consentField.jsx deleted file mode 100644 index 835c3d4a..00000000 --- a/src/components/form/fields/consentField.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; - -import styled from 'styled-components'; - -import { Checkbox } from '../formControlsComponent'; -import { createField } from '../fieldCreator'; -import { MarkdownContent } from '../../miscComponent'; - -import { checked, empty } from '../../../core/validation'; - -const Description = styled.div` - font-size: ${props => props.theme.smallTextFontSize}px; - color: ${props => props.theme.mutedTextColor}; - margin: 5px 5px 10px 5px; - text-align: justify; - display: block; - - & > p { - margin-top: 1px; - margin-bottom: 1px; - } -`; - -const ConsentField = ({ value, onChange, label, description, path, required, validation={}, consentCannotBeGranted }) => { - const clickUpdate = ({ value }) => ({ - value: consentCannotBeGranted ? false : !value, - isDirty: true - }); - - return

- onChange(clickUpdate)} - name={path} - label={label} - data-testid={path} - required={required} - {...(({ error }) => ({ error }))(validation)} - /> - -
-}; - -export default function consentField(config) { - return createField({ - ...config, - required: !!config.required, - defaultValue: config.defaultValue && { granted: config.defaultValue }, - format: { - bind: x => !!(x?.granted), - unbind: x => ({ - granted: x, - consentType: config.type, - consentVersion: config.extendedParams.version - }) - }, - validator: config.required ? checked : empty, - rawProperty: 'granted', - component: ConsentField - }); -} diff --git a/src/components/form/fields/consentField.tsx b/src/components/form/fields/consentField.tsx new file mode 100644 index 00000000..1c681dfa --- /dev/null +++ b/src/components/form/fields/consentField.tsx @@ -0,0 +1,103 @@ +import React from 'react'; + +import styled from 'styled-components'; + +import { Checkbox } from '../formControlsComponent'; +import { createField, type FieldComponentProps, type FieldDefinition } from '../fieldCreator'; +import { MarkdownContent } from '../../miscComponent'; + +import { checked, empty } from '../../../core/validation'; +import { isRichFormValue } from '../../../helpers/utils'; +import { ConsentType } from '@reachfive/identity-core'; + +const Description = styled.div` + font-size: ${props => props.theme.smallTextFontSize}px; + color: ${props => props.theme.mutedTextColor}; + margin: 5px 5px 10px 5px; + text-align: justify; + display: block; + + & > p { + margin-top: 1px; + margin-bottom: 1px; + } +`; + +type ConsentFieldOptions = { + type: ConsentType + consentCannotBeGranted?: boolean + description: string + version: { + language: string + versionId: number + } +} + +export interface ConsentFieldProps extends FieldComponentProps {} + +const ConsentField = ({ value, onChange, label, description, path, required, validation={}, consentCannotBeGranted }: ConsentFieldProps) => { + const granted = (isRichFormValue(value, 'granted') ? value.granted : value) ?? false + + const onToggle = () => onChange({ + value: consentCannotBeGranted ? false : !granted, + isDirty: true + }); + + const error = typeof validation === 'object' && 'error' in validation ? validation.error : undefined + + return
+ + +
+}; + +type Value = { + consentType?: ConsentType + consentVersion?: { + language: string + versionId: number + } + granted: boolean +} + +export default function consentField({ + type, + required = false, + consentCannotBeGranted, + description, + version, + ...props +}: Omit, 'defaultValue'> & { defaultValue?: boolean } & ConsentFieldOptions) { + return createField({ + ...props, + required, + defaultValue: { granted: props.defaultValue ?? false }, + format: { + bind: value => value, + unbind: value => value !== undefined + ? { + granted: (isRichFormValue(value, 'granted') ? value.granted : value) ?? false, + consentType: type, + consentVersion: version + } + : null, + }, + validator: required ? checked : empty, + rawProperty: 'granted', + component: ConsentField, + extendedParams: { + consentCannotBeGranted, + description, + type, + version, + } + }); +} diff --git a/src/components/form/fields/dateField.d.ts b/src/components/form/fields/dateField.d.ts deleted file mode 100644 index c568f52b..00000000 --- a/src/components/form/fields/dateField.d.ts +++ /dev/null @@ -1,13 +0,0 @@ - -import { DateTime } from 'luxon'; - -import { BaseOptions } from './simpleField' -import type { FieldCreator } from '../fieldCreator' -import { Config } from '../../../types' - -export interface Options extends BaseOptions { - validator?: Validator> - yearDebounce?: number -} - -export default function dateField(options: Options, config: Config): FieldCreator diff --git a/src/components/form/fields/dateField.jsx b/src/components/form/fields/dateField.tsx similarity index 61% rename from src/components/form/fields/dateField.jsx rename to src/components/form/fields/dateField.tsx index 4c81ca75..7ec75a47 100644 --- a/src/components/form/fields/dateField.jsx +++ b/src/components/form/fields/dateField.tsx @@ -2,12 +2,13 @@ import React, { useEffect, useMemo, useState } from 'react' import { DateTime, Info } from 'luxon' import styled from 'styled-components'; -import { Validator } from '../../../core/validation'; +import { ValidatorResult, Validator } from '../../../core/validation'; import { useDebounce } from '../../../helpers/useDebounce'; -import { isValued } from '../../../helpers/utils'; +import { isRichFormValue } from '../../../helpers/utils'; -import { createField } from '../fieldCreator'; +import { createField, type FieldComponentProps, type FieldCreator, type FieldDefinition } from '../fieldCreator'; import { FormGroup, Input, Select } from '../formControlsComponent'; +import { Config, Optional } from '../../../types'; const inputRowGutter = 10; @@ -18,39 +19,62 @@ const InputRow = styled.div` gap: ${inputRowGutter}px; `; -const InputCol = styled.div` +const InputCol = styled.div<{ width: number }>` flex-basis: ${props => props.width}%; `; -const DateField = ({ i18n, inputId, label, locale, onChange, path, required, showLabel, validation={}, value, yearDebounce = 1000 }) => { - const [day, setDay] = useState(isValued(value) ? value.raw.day : undefined) - const [month, setMonth] = useState(isValued(value) ? value.raw.month : undefined) - const [year, setYear] = useState(isValued(value) ? value.raw.year : undefined) +type ExtraParams = { + locale: string + yearDebounce?: number +} + +export interface DateFieldProps extends FieldComponentProps {} + +const DateField = ({ + i18n, + inputId, + label, + locale, + onChange, + path, + required, + showLabel, + validation = {} as ValidatorResult, + value, + yearDebounce = 1000 +}: DateFieldProps) => { + const date = isRichFormValue(value, 'raw') ? value.raw : value + const [day, setDay] = useState(date?.day) + const [month, setMonth] = useState(date?.month) + const [year, setYear] = useState(date?.year) // debounce year value to delay value update when user is currently editing it const debouncedYear = useDebounce(year, yearDebounce) - const setDatePart = (setter, value) => { + const setDatePart = (setter: React.Dispatch>, value: string) => { if (Number.isNaN(Number(value))) return // only accept number value setter(Number(value)) } - const handleDayChange = event => setDatePart(setDay, event.target.value) + const handleDayChange = (event: React.ChangeEvent) => setDatePart(setDay, event.target.value) + + const handleMonthChange = (event: React.ChangeEvent) => setDatePart(setMonth, event.target.value) - const handleMonthChange = event => setDatePart(setMonth, event.target.value) + const handleYearChange = (event: React.ChangeEvent) => setDatePart(setYear, event.target.value) - const handleYearChange = event => setDatePart(setYear, event.target.value) + const error = typeof validation === 'object' && 'error' in validation ? validation.error : undefined useEffect(() => { if (day && month && debouncedYear) { - onChange(() => ({ - value: { raw: DateTime.fromObject({ year: debouncedYear, month, day }) }, + onChange({ + value: DateTime.fromObject({ year: debouncedYear, month, day }), isDirty: true, - })) + }) } }, [debouncedYear, month, day]) const months = useMemo(() => Info.months("long", { locale }), [locale]) + const daysInMonth = useMemo(() => [...Array(DateTime.fromObject({ year: debouncedYear, month }).daysInMonth ?? 31).keys()].map(v => v + 1), [debouncedYear, month] @@ -71,15 +95,13 @@ const DateField = ({ i18n, inputId, label, locale, onChange, path, required, sho [locale] ) - const error = typeof validation === 'object' && 'error' in validation ? validation.error : null - - const fields = { + const fields: Partial> = { day: ( DateTime.now().setLocale(locale).toLocaleParts().map(part => { +const dateFormat = (locale: string) => DateTime.now().setLocale(locale).toLocaleParts().map(part => { switch (part.type) { case 'day': return 'dd' @@ -151,27 +173,38 @@ const dateFormat = locale => DateTime.now().setLocale(locale).toLocaleParts().ma } }).join('') -export const datetimeValidator = locale => new Validator({ - rule: (value) => isValued(value) && value.raw.isValid, +export const datetimeValidator = (locale: string) => new Validator({ + rule: (value) => value.isValid, hint: 'date', parameters: { format: dateFormat(locale) } }) -export default function dateField({ yearDebounce, ...props }, config) { - return createField({ +export default function dateField( + { + key = 'date', + label = 'date', + yearDebounce, + locale, + ...props + }: Optional, 'key' | 'label'> & Optional, + config: Config +): FieldCreator { + return createField({ + key, + label, ...props, format: { bind: (value) => { const dt = value ? DateTime.fromISO(value) : DateTime.invalid('empty value') return dt.isValid ? { raw: dt } : undefined }, - unbind: (value) => value?.raw.toISODate() + unbind: (value) => isRichFormValue(value, 'raw') ? value.raw.toISODate() : value?.toISODate() ?? null }, validator: props.validator ? datetimeValidator(config.language).and(props.validator) : datetimeValidator(config.language), component: DateField, extendedParams: { - locale: config.language, - yearDebounce, + locale: locale ?? config.language, + yearDebounce } }) } diff --git a/src/components/form/fields/identifierField.d.ts b/src/components/form/fields/identifierField.d.ts deleted file mode 100644 index 8c0ce9f4..00000000 --- a/src/components/form/fields/identifierField.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Config } from '../../../types' -import { BaseOptions } from './simpleField' -import type { FieldCreator } from '../fieldCreator' - -export interface Options extends BaseOptions { - withPhoneNumber?: boolean -} - -export default function identifierField(options: Options, config?: Config): FieldCreator diff --git a/src/components/form/fields/identifierField.jsx b/src/components/form/fields/identifierField.jsx deleted file mode 100644 index 158261c5..00000000 --- a/src/components/form/fields/identifierField.jsx +++ /dev/null @@ -1,166 +0,0 @@ -import React from 'react'; - -import * as libphonenumber from 'libphonenumber-js'; - -import { email, Validator } from '../../../core/validation'; - -import { FormGroup, Input } from '../formControlsComponent'; -import { createField } from '../fieldCreator'; - -/* -* All possible Identifier data is in the `value` prop, they should all be preserved when the type changes. -* { -* raw: string, -* type: 'tel' | 'email' | 'other', -* country: string, -* formatted: string, -* isValid: boolean, -* } -*/ - -function specializeRawIdentifier(withPhoneNumber, inputValue, telCall = () => ({}), emailCall = () => ({}), otherCall = () => ({})) { - if (withPhoneNumber && (/^\+?[0-9]+$/.test(inputValue))) { - return ({ - raw: inputValue, - ...telCall(inputValue), - type: 'tel', - }) - } else if (/@/.test(inputValue)) { - return ({ - raw: inputValue, - ...emailCall(inputValue), - type: 'email', - }) - } else { - return ({ - raw: inputValue, - ...otherCall(inputValue), - type: 'text', - }) - } -} - -function specializeRefinedIdentifier(identifier, telCall = x => x, emailCall = x => x, otherCall = x => x) { - if (identifier.type === 'tel') - return telCall(identifier) - else if (identifier.type === 'email') - return emailCall(identifier) - else return otherCall(identifier) -} - -class IdentifierField extends React.Component { - componentDidMount() { - const { userInput, country } = this.props.value; - - if (!userInput) return - - try { - const parsed = libphonenumber.parse(userInput, country); - const phoneValue = country === parsed.country - ? libphonenumber.format(parsed, 'National') - : userInput; - - this.asYouType(phoneValue); - } catch (e) { - console.error(e) - } - } - - componentWillUnmount() { - this.unmounted = true - } - - asYouType = (inputValue) => { - const {value: {country}} = this.props; - - const phone = new libphonenumber.AsYouType(country).input(inputValue); - const formatted = libphonenumber.format(phone, country, 'International'); - const isValid = libphonenumber.isValidNumber(phone, country); - - return { - country, - formatted, - isValid, - raw: isValid ? phone : inputValue, - } - } - - render() { - const { - path, - value, - validation = {}, - inputId, - required = true, - label, - showLabel, - placeholder = label, - readOnly, - withPhoneNumber, - autoComplete - } = this.props; - - return ({ error }))(validation)} - showLabel={showLabel} - required={required} - > - - this.props.onChange({ - value: { - ...this.props.value, - ...specializeRawIdentifier(withPhoneNumber, event.target.value, this.asYouType) - } - }) - } - onBlur={() => this.props.onChange({ isDirty: true })} - data-testid={path}/> - - } -} - -export default function identifierField(props, config) { - return createField({ - ...props, - key: 'identifier', - label: 'identifier', - format: { - bind: x => specializeRawIdentifier(props.withPhoneNumber, - x, - () => ({ country: config.countryCode, isValid: true }), - () => ({ country: config.countryCode, isValid: true }), - () => ({ country: config.countryCode, isValid: true }), - ), - unbind: x => specializeRefinedIdentifier( - x, - v => v.formatted || v.raw, - v => v.raw, - v => v.raw) - }, - validator: new Validator({ - rule: value => specializeRefinedIdentifier(value, - v => v.isValid || !props.withPhoneNumber, - v => email.rule(v.raw), - v => v.isValid), - hint: value => specializeRefinedIdentifier(value, - () => 'phone', - () => 'email', - () => 'identifier') - }), - component: IdentifierField, - extendedParams: {withPhoneNumber: props.withPhoneNumber} - }); -} diff --git a/src/components/form/fields/identifierField.tsx b/src/components/form/fields/identifierField.tsx new file mode 100644 index 00000000..a1e25bfc --- /dev/null +++ b/src/components/form/fields/identifierField.tsx @@ -0,0 +1,188 @@ +import React from 'react'; + +import * as libphonenumber from 'libphonenumber-js'; +import type { CountryCode } from 'libphonenumber-js'; + +import { email, Validator } from '../../../core/validation'; + +import { FormGroup, Input } from '../formControlsComponent'; +import { createField, type FieldComponentProps, type FieldDefinition } from '../fieldCreator'; +import { Config, Optional } from '../../../types'; +import { isRichFormValue } from '../../../helpers/utils'; + +/* +* All possible Identifier data is in the `value` prop, they should all be preserved when the type changes. +*/ +interface IdentifierData { + value?: string, + type?: 'tel' | 'email' | 'text', + country?: CountryCode, + formatted?: string, + isValid?: boolean, +} + + +function specializeRawIdentifier( + withPhoneNumber?: boolean, + inputValue?: string, + telCall?: (value?: string) => IdentifierData, + emailCall?: (value?: string) => IdentifierData, + otherCall?: (value?: string) => IdentifierData +): IdentifierData { + if (withPhoneNumber && inputValue && (/^\+?[0-9]+$/.test(inputValue))) { + return ({ + value: inputValue, + ...(telCall?.(inputValue) ?? {}), + type: 'tel', + }) + } else if (inputValue?.includes('@')) { + return ({ + value: inputValue, + ...(emailCall?.(inputValue) ?? {}), + type: 'email', + }) + } else { + return ({ + value: inputValue, + ...(otherCall?.(inputValue) ?? {}), + type: 'text', + }) + } +} + +function specializeRefinedIdentifier( + identifier: IdentifierData, + telCall: (identifier: IdentifierData) => T, + emailCall: (identifier: IdentifierData) => T, + otherCall: (identifier: IdentifierData) => T +) { + if (identifier.type === 'tel') + return telCall(identifier) + else if (identifier.type === 'email') + return emailCall(identifier) + else return otherCall(identifier) +} + +type IdentifierFieldExtraProps = { + withPhoneNumber?: boolean +} + +export interface IdentifierFieldProps extends FieldComponentProps {} + +function IdentifierField({ + autoComplete, + inputId, + label, + onChange, + path, + placeholder = label, + required = true, + showLabel, + readOnly, + validation = {}, + value, + withPhoneNumber, +}: IdentifierFieldProps) { + const currentValue = isRichFormValue(value, 'raw') ? value.raw : value + + const asYouType = (inputValue?: string): IdentifierData => { + if (!inputValue) return {} + + const parsed = libphonenumber.parsePhoneNumberFromString(inputValue) + return parsed ? { + country: parsed.country, + formatted: parsed.formatInternational(), + isValid: parsed.isValid(), + value: inputValue + } : { + isValid: false, + value: inputValue, + } + } + + const changeHandler = (event: React.ChangeEvent) => { + const newValue: IdentifierData = { + ...currentValue, + ...specializeRawIdentifier(withPhoneNumber, event.target.value, asYouType) + } + onChange({ value: newValue, isDirty: false }) + } + + const error = typeof validation === 'object' && 'error' in validation ? validation.error : undefined + + return + onChange({ isDirty: true })} + data-testid={path} + /> + +} + +function isValidCountryCode(code?: string): code is CountryCode { + return typeof code === 'string' && libphonenumber.isSupportedCountry(code) +} + +export default function identifierField( + { + key = 'identifier', + label = 'identifier', + ...props + }: Optional, 'key' | 'label'> & IdentifierFieldExtraProps, + config: Config +) { + return createField({ + ...props, + key, + label, + format: { + bind: value => specializeRawIdentifier( + props.withPhoneNumber, + value, + () => ({ country: isValidCountryCode(config.countryCode) ? config.countryCode : undefined, isValid: true } satisfies IdentifierData), + () => ({ country: isValidCountryCode(config.countryCode) ? config.countryCode : undefined, isValid: true } satisfies IdentifierData), + () => ({ country: isValidCountryCode(config.countryCode) ? config.countryCode : undefined, isValid: true } satisfies IdentifierData), + ), + unbind: value => value ? specializeRefinedIdentifier( + isRichFormValue(value, 'raw') ? value.raw : value, + v => v?.formatted ?? v?.value ?? null, + v => v?.value ?? null, + v => v?.value ?? null + ) : null + }, + validator: new Validator({ + rule: (value, ctx) => specializeRefinedIdentifier( + value, + v => v?.isValid ?? !props.withPhoneNumber, + v => email.rule(v?.value ?? '', ctx), + v => v?.isValid + ) ?? true, + hint: value => specializeRefinedIdentifier( + value, + () => 'phone', + () => 'email', + () => 'identifier' + ) + }), + component: IdentifierField, + extendedParams: { + withPhoneNumber: props.withPhoneNumber + } + }); +} diff --git a/src/components/form/fields/passwordField.tsx b/src/components/form/fields/passwordField.tsx index 5e810b36..ae9c7273 100644 --- a/src/components/form/fields/passwordField.tsx +++ b/src/components/form/fields/passwordField.tsx @@ -1,22 +1,23 @@ -import React from 'react'; +import React, { useState } from 'react'; import { isLower, isUpper, isDigit } from 'char-info'; import type { PasswordPolicy } from '@reachfive/identity-core' import zxcvbn from '@reachfive/zxcvbn'; import styled, { DefaultTheme } from 'styled-components'; -import type { Config } from '../../../types' +import type { Config, Optional } from '../../../types' import { Input, Label, FormGroupContainer, FormError } from '../formControlsComponent'; -import type { FieldCreator, FieldComponentProps } from '../fieldCreator' +import type { FieldCreator, FieldComponentProps, FieldDefinition } from '../fieldCreator' import { PasswordPolicyRules, type PasswordRule, type PasswordStrengthScore } from './passwordPolicyRules'; import { ShowPasswordIcon, HidePasswordIcon } from './simplePasswordField'; import { useI18n } from '../../../contexts/i18n'; -import { VaildatorResult } from '../../../core/validation'; +import { ValidatorResult, Validator } from '../../../core/validation'; import { I18nResolver } from '../../../core/i18n'; -import { isEqual } from '../../../helpers/utils'; +import { createField } from '../fieldCreator'; +import { isRichFormValue } from '../../../helpers/utils'; const SPECIAL_CHARACTERS = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; const MAX_PASSWORD_LENGTH = 255; @@ -76,137 +77,96 @@ const PasswordStrength = ({ score }: PasswordStrength) => { type ExtraValues = { strength?: PasswordStrengthScore, - isTouched?: boolean, } -interface PasswordFieldProps extends FieldComponentProps { - blacklist: string[] - isDirty?: boolean - isTouched?: boolean - // onChange: (event: { value?: string, strength?: PasswordStrengthScore, isTouched?: boolean, isDirty?: boolean }) => void +type ExtraParams = { + blacklist?: string[] canShowPassword?: boolean enabledRules: Record minStrength: PasswordStrengthScore - strength: PasswordStrengthScore - value?: string } -interface PasswordFieldState { - showPassword: boolean -} - -class PasswordField extends React.Component { - protected unmounted = false - - state = { - showPassword: false - }; - - componentDidUpdate(prevProps: PasswordFieldProps) { - const blacklistUpdated = isEqual(prevProps.blacklist, this.props.blacklist); - if (!blacklistUpdated) { - const { value, onChange, blacklist } = this.props; - onChange({ - strength: getPasswordStrength(blacklist, value), - value, - }); - } - } - - componentDidMount() { - const { value, onChange, blacklist } = this.props; - this.setState({ ...this.state }); - onChange({ - strength: getPasswordStrength(blacklist, value), - value, - }); +export interface PasswordFieldProps extends FieldComponentProps {} + +function PasswordField({ + autoComplete, + blacklist = [], + canShowPassword, + enabledRules, + inputId, + label, + minStrength, + onChange, + placeholder, + required, + showLabel, + validation = {} as ValidatorResult, + value = '', +}: PasswordFieldProps) { + const [showPassword, setShowPassword] = useState(false) + + const [isTouched, setIsTouched] = useState(false) + + const currentValue = isRichFormValue(value, 'raw') ? value.raw : value + + const strength = getPasswordStrength(blacklist, currentValue) + + const toggleShowPassword = () => { + setShowPassword(showPassword => !showPassword) } - componentWillUnmount() { - this.unmounted = true; - } - - toggleShowPassword = () => { - const showPassword = !this.state.showPassword; - this.setState({ ...this.state, showPassword }); - } - - render() { - const { - autoComplete, - blacklist = [], - canShowPassword, - enabledRules, - inputId, - path, - isTouched, - label, - minStrength, - onChange, - placeholder, - required, - showLabel, - strength, - validation = {} as VaildatorResult, - value = '', - } = this.props; - - const { showPassword } = this.state; - - return ( - - -
- ) => onChange({ + return ( + + +
+ ) => { + onChange({ value: event.target.value, strength: getPasswordStrength(blacklist, event.target.value) - })} - onFocus={(event) => onChange({ - value: event.target.value, - isTouched: true - })} - onBlur={(event) => onChange({ - value: event.target.value, - isDirty: true - })} - data-testid="password" - /> - {canShowPassword && ( - showPassword - ? - : - )} -
- {isTouched && } - {typeof validation === 'object' && 'error' in validation && {validation.error}} - {isTouched && ( - + }) + }} + onFocus={() => setIsTouched(true)} + onBlur={(event) => onChange({ + value: event.target.value, + isDirty: true + })} + data-testid="password" + /> + {canShowPassword && ( + showPassword + ? + : )} -
- ) - } +
+ {isTouched && } + {typeof validation === 'object' && 'error' in validation && {validation.error}} + {isTouched && ( + + )} +
+ ) } type RuleKeys = Exclude -function listEnabledRules(i18n: I18nResolver, passwordPolicy: Config['passwordPolicy']): Record { +export function listEnabledRules(i18n: I18nResolver, passwordPolicy: Config['passwordPolicy']): Record { if (!passwordPolicy) return {} as Record; const rules: Record = { @@ -239,62 +199,65 @@ function listEnabledRules(i18n: I18nResolver, passwordPolicy: Config['passwordPo } export function getPasswordStrength(blacklist: string[], fieldValue?: string) { - const sanitized = (fieldValue ?? "").toLowerCase().trim(); + const sanitized = `${fieldValue ?? ""}`.toLowerCase().trim(); return zxcvbn(sanitized, blacklist).score; } +export function passwordStrengthValidator(passwordPolicy?: PasswordPolicy, blacklist: string[] = []) { + return new Validator({ + rule: (value) => { + const strength = getPasswordStrength(blacklist, value) + if (passwordPolicy && strength < passwordPolicy.minStrength) return false + return true + }, + hint: 'password.minStrength' + }) +} + +export const passwordLengthValidator = new Validator({ + rule: (value) => { + if (value.length > MAX_PASSWORD_LENGTH) return false + return true + }, + hint: 'password.maxLength', + parameters: { max: MAX_PASSWORD_LENGTH } +}) + +function passwordValidatorChain(passwordPolicy?: PasswordPolicy) { + return passwordLengthValidator.and(passwordStrengthValidator(passwordPolicy)) +} + export const passwordField = ( - { label = 'password', canShowPassword = false, required = true, ...staticProps }: Partial, + { + key = 'password', + label = 'password', + blacklist = [], + canShowPassword = false, + enabledRules, + minStrength, + required = true, + validator, + ...props + }: Optional, 'key' | 'label'> & Partial, { passwordPolicy }: Config -): FieldCreator => ({ - path: 'password', - create: ({ i18n, showLabel }) => { - const actualLabel = i18n(label); - - return { - key: 'password', - render: ({ state, ...props }) => ( - - ), - initialize: () => ({ - value: '', - strength: 0, - enabledRules: listEnabledRules(i18n, passwordPolicy), - minStrength: passwordPolicy?.minStrength, - isTouched: false, - isDirty: false, - blacklist: [], - }), - unbind: (model, { value }) => ({ ...model, password: value }), - validate: ({ value, strength, isDirty }, ctx) => { - if (!isDirty && !ctx.isSubmitted) return {} as VaildatorResult; - - const errors = []; - if (!value) { - errors.push(i18n('validation.required')); - } - else { - if (passwordPolicy && strength && strength < passwordPolicy.minStrength) { - errors.push(i18n('validation.password.minStrength')); - } - - if (value.length > MAX_PASSWORD_LENGTH) { - errors.push(i18n('validation.password.maxLength', { max: MAX_PASSWORD_LENGTH })); - } - } - - return errors.length == 0 ? {} as VaildatorResult : { error: errors.join(' ') } as VaildatorResult; - } - } - } -}); +): FieldCreator => + createField({ + key, + label, + required, + ...props, + component: PasswordField, + extendedParams: (i18n) => ({ + blacklist, + canShowPassword, + enabledRules: enabledRules ?? listEnabledRules(i18n, passwordPolicy), + /** + * @toto `passwordPolicy` should always be define. Remove default value when correction PR is merged. + * @see https://github.com/ReachFive/identity-web-core-sdk/pull/239 + * */ + minStrength: minStrength ?? passwordPolicy?.minStrength ?? 2, + }), + validator: validator ? validator.and(passwordValidatorChain(passwordPolicy)) : passwordValidatorChain(passwordPolicy) + }) export default passwordField; diff --git a/src/components/form/fields/passwordPolicyRules.tsx b/src/components/form/fields/passwordPolicyRules.tsx index 4749e306..ffc2baed 100644 --- a/src/components/form/fields/passwordPolicyRules.tsx +++ b/src/components/form/fields/passwordPolicyRules.tsx @@ -17,6 +17,8 @@ const RoundCheckbox = styled(({ className, ...props }: RoundCheckboxProps) =>