diff --git a/src/sections/ui/assets/styles/bootstrap-customized.scss b/src/sections/ui/assets/styles/bootstrap-customized.scss index 39f47e1cc..482406df1 100644 --- a/src/sections/ui/assets/styles/bootstrap-customized.scss +++ b/src/sections/ui/assets/styles/bootstrap-customized.scss @@ -54,9 +54,16 @@ $link-hover-color: $dv-link-hover-color; // Badge @import "bootstrap/scss/badge"; +// Forms +$form-label-font-weight: $dv-font-weight-bold; + +@import "bootstrap/scss/forms"; + + // Table @import "bootstrap/scss/tables"; + // Accordion @import "bootstrap/scss/accordion"; diff --git a/src/sections/ui/assets/styles/design-tokens/colors.module.scss b/src/sections/ui/assets/styles/design-tokens/colors.module.scss index 7138abc5c..86355ec58 100644 --- a/src/sections/ui/assets/styles/design-tokens/colors.module.scss +++ b/src/sections/ui/assets/styles/design-tokens/colors.module.scss @@ -1,7 +1,7 @@ // Base colors $dv-brand-color: #C55B28; $dv-primary-color: #337AB7; -$dv-secondary-color: #e3e4ec; +$dv-secondary-color: #e0e0e0; $dv-success-color: #3c763d; $dv-danger-color: #a94442; $dv-warning-color: #8a6d3b; diff --git a/src/sections/ui/button/Button.tsx b/src/sections/ui/button/Button.tsx index 489229619..6045c8406 100644 --- a/src/sections/ui/button/Button.tsx +++ b/src/sections/ui/button/Button.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { MouseEvent, ReactNode } from 'react' import styles from './Button.module.scss' import { Button as ButtonBS } from 'react-bootstrap' import { Icon } from '../icon.enum' @@ -8,7 +8,7 @@ type ButtonVariant = 'primary' | 'secondary' | 'link' interface ButtonProps { variant?: ButtonVariant disabled?: boolean - onClick?: () => void + onClick?: (event: MouseEvent) => void icon?: Icon withSpacing?: boolean children: ReactNode diff --git a/src/sections/ui/form/Form.tsx b/src/sections/ui/form/Form.tsx new file mode 100644 index 000000000..e585602af --- /dev/null +++ b/src/sections/ui/form/Form.tsx @@ -0,0 +1,22 @@ +import { FormEvent, PropsWithChildren } from 'react' +import { FormGroup } from './form-group/FormGroup' +import { Form as FormBS } from 'react-bootstrap' +import { FormGroupWithMultipleFields } from './form-group-multiple-fields/FormGroupWithMultipleFields' + +interface FormProps { + validated?: boolean + onSubmit?: (event: FormEvent) => void +} + +function Form({ validated, onSubmit, children }: PropsWithChildren) { + return ( + + {children} + + ) +} + +Form.Group = FormGroup +Form.GroupWithMultipleFields = FormGroupWithMultipleFields + +export { Form } diff --git a/src/sections/ui/form/form-group-multiple-fields/FormGroupWithMultipleFields.module.scss b/src/sections/ui/form/form-group-multiple-fields/FormGroupWithMultipleFields.module.scss new file mode 100644 index 000000000..09d1f4a40 --- /dev/null +++ b/src/sections/ui/form/form-group-multiple-fields/FormGroupWithMultipleFields.module.scss @@ -0,0 +1,7 @@ +@import "src/sections/ui/assets/styles/design-tokens/typography.module"; + +.title { + padding-top: calc(0.375rem + 1px); + padding-bottom: calc(0.375rem + 1px); + font-weight: $dv-font-weight-bold; +} \ No newline at end of file diff --git a/src/sections/ui/form/form-group-multiple-fields/FormGroupWithMultipleFields.tsx b/src/sections/ui/form/form-group-multiple-fields/FormGroupWithMultipleFields.tsx new file mode 100644 index 000000000..18aa98bee --- /dev/null +++ b/src/sections/ui/form/form-group-multiple-fields/FormGroupWithMultipleFields.tsx @@ -0,0 +1,58 @@ +import { Row } from '../../grid/Row' +import { Col } from '../../grid/Col' +import { PropsWithChildren } from 'react' +import styles from './FormGroupWithMultipleFields.module.scss' +import { RequiredInputSymbol } from '../required-input-symbol/RequiredInputSymbol' +import { DynamicFieldsButtons } from './dynamic-fields-buttons/DynamicFieldsButtons' +import { useFields } from './useFields' +import { Tooltip } from '../../tooltip/Tooltip' + +interface FormGroupWithMultipleFieldsProps { + title: string + withDynamicFields?: boolean + required?: boolean + message?: string +} + +const Title = ({ title, required, message }: Partial) => ( + + {title} {required && }{' '} + {message && } + +) + +export function FormGroupWithMultipleFields({ + title, + withDynamicFields, + required, + message, + children +}: PropsWithChildren) { + const { fields, addField, removeField } = useFields(children, withDynamicFields) + + return ( + <> + {fields.map((field, index) => { + const isFirstField = index == 0 + + return ( + + + {isFirstField && } + </Col> + <Col sm={6}>{field}</Col> + <Col sm={3}> + {withDynamicFields && ( + <DynamicFieldsButtons + originalField={isFirstField} + onAddButtonClick={() => addField(field)} + onRemoveButtonClick={() => removeField(index)} + /> + )} + </Col> + </Row> + ) + })} + </> + ) +} diff --git a/src/sections/ui/form/form-group-multiple-fields/dynamic-fields-buttons/DynamicFieldsButtons.module.scss b/src/sections/ui/form/form-group-multiple-fields/dynamic-fields-buttons/DynamicFieldsButtons.module.scss new file mode 100644 index 000000000..c38fea37a --- /dev/null +++ b/src/sections/ui/form/form-group-multiple-fields/dynamic-fields-buttons/DynamicFieldsButtons.module.scss @@ -0,0 +1,13 @@ +.container { + display: flex; + margin: 1.8em; +} + +.icon { + display: inline-block; + vertical-align: -0.125em; +} + +.overlay-container { + width: fit-content; +} \ No newline at end of file diff --git a/src/sections/ui/form/form-group-multiple-fields/dynamic-fields-buttons/DynamicFieldsButtons.tsx b/src/sections/ui/form/form-group-multiple-fields/dynamic-fields-buttons/DynamicFieldsButtons.tsx new file mode 100644 index 000000000..6b22ee9e3 --- /dev/null +++ b/src/sections/ui/form/form-group-multiple-fields/dynamic-fields-buttons/DynamicFieldsButtons.tsx @@ -0,0 +1,38 @@ +import { Button } from '../../../button/Button' +import styles from './DynamicFieldsButtons.module.scss' +import { MouseEvent } from 'react' +import { Dash, Plus } from 'react-bootstrap-icons' +import { OverlayTrigger } from '../../../tooltip/overlay-trigger/OverlayTrigger' + +interface AddFieldButtonsProps { + originalField?: boolean + onAddButtonClick: (event: MouseEvent<HTMLButtonElement>) => void + onRemoveButtonClick: (event: MouseEvent<HTMLButtonElement>) => void +} + +export function DynamicFieldsButtons({ + originalField, + onAddButtonClick, + onRemoveButtonClick +}: AddFieldButtonsProps) { + return ( + <div className={styles.container}> + <OverlayTrigger placement="top" message="Add"> + <div className={styles['overlay-container']}> + <Button variant="secondary" onClick={onAddButtonClick}> + <Plus className={styles.icon} title="Add" /> + </Button> + </div> + </OverlayTrigger> + {!originalField && ( + <OverlayTrigger placement="top" message="Delete"> + <div className={styles['overlay-container']}> + <Button variant="secondary" withSpacing onClick={onRemoveButtonClick}> + <Dash className={styles.icon} title="Delete" /> + </Button> + </div> + </OverlayTrigger> + )} + </div> + ) +} diff --git a/src/sections/ui/form/form-group-multiple-fields/useFields.tsx b/src/sections/ui/form/form-group-multiple-fields/useFields.tsx new file mode 100644 index 000000000..0b4fc2b44 --- /dev/null +++ b/src/sections/ui/form/form-group-multiple-fields/useFields.tsx @@ -0,0 +1,52 @@ +import React, { ReactElement, ReactNode, useState } from 'react' +import { FormGroup } from '../form-group/FormGroup' + +function getFieldsWithIndex(fields: Array<ReactNode>) { + return fields.map((field, index) => getFieldWithIndex(field, index)) +} + +function getFieldWithIndex(field: ReactNode, fieldIndex: number) { + return React.Children.map(field, (child: ReactNode) => { + if (!React.isValidElement(child)) { + return child + } + + /* eslint-disable @typescript-eslint/no-unsafe-assignment */ + const childProps = getPropsWithFieldIndex(child, fieldIndex) + + /* eslint-disable @typescript-eslint/no-unsafe-member-access */ + if (child.props.children) { + /* eslint-disable @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-argument */ + childProps.children = getFieldWithIndex(child.props.children, fieldIndex) + } + + /* eslint-disable @typescript-eslint/no-unsafe-argument */ + return React.cloneElement(child, childProps) + }) +} + +function getPropsWithFieldIndex(child: ReactElement, fieldIndex: number) { + const isFormGroup = (child: ReactNode) => { + return React.isValidElement(child) && child.type === FormGroup + } + + /* eslint-disable @typescript-eslint/no-unsafe-return */ + return isFormGroup(child) + ? { ...child.props, fieldIndex: fieldIndex.toString() } + : { ...child.props } +} + +export function useFields(initialField: ReactNode | undefined, withDynamicFields?: boolean) { + const initialFieldWithIndex = withDynamicFields + ? getFieldWithIndex(initialField, 0) + : initialField + const [fields, setFields] = useState([initialFieldWithIndex]) + + const addField = (field: ReactNode | undefined) => + setFields(getFieldsWithIndex([...fields, field])) + + const removeField = (fieldIndex: number) => + setFields(getFieldsWithIndex(fields.filter((_, i) => i !== fieldIndex))) + + return { fields, addField, removeField } +} diff --git a/src/sections/ui/form/form-group/FormGroup.tsx b/src/sections/ui/form/form-group/FormGroup.tsx new file mode 100644 index 000000000..0718a1245 --- /dev/null +++ b/src/sections/ui/form/form-group/FormGroup.tsx @@ -0,0 +1,52 @@ +import React, { PropsWithChildren } from 'react' +import { Form as FormBS } from 'react-bootstrap' +import { FormInput } from './form-element/FormInput' +import { FormLabel } from './form-element/FormLabel' +import { FormText } from './form-element/FormText' +import { FormSelect } from './form-element/FormSelect' +import { FormTextArea } from './form-element/FormTextArea' +import { Col, ColProps } from '../../grid/Col' +import { Row } from '../../grid/Row' +import { FormCheckbox } from './form-element/FormCheckbox' + +interface FormGroupProps extends ColProps { + as?: typeof Col | typeof Row + required?: boolean + controlId: string + fieldIndex?: string +} + +function FormGroup({ + as = Row, + required, + controlId, + fieldIndex, + children, + ...props +}: PropsWithChildren<FormGroupProps>) { + const childrenWithRequiredProp = React.Children.map(children as JSX.Element, (child) => { + return React.cloneElement(child, { + required: required, + withinMultipleFieldsGroup: as === Col + }) + }) + + return ( + <FormBS.Group + controlId={fieldIndex ? `${controlId}-${fieldIndex}` : controlId} + className="mb-3" + as={as} + {...props}> + {childrenWithRequiredProp} + </FormBS.Group> + ) +} + +FormGroup.Label = FormLabel +FormGroup.Input = FormInput +FormGroup.Select = FormSelect +FormGroup.TextArea = FormTextArea +FormGroup.Text = FormText +FormGroup.Checkbox = FormCheckbox + +export { FormGroup } diff --git a/src/sections/ui/form/form-group/form-element/FormCheckbox.tsx b/src/sections/ui/form/form-group/form-element/FormCheckbox.tsx new file mode 100644 index 000000000..b3773583b --- /dev/null +++ b/src/sections/ui/form/form-group/form-element/FormCheckbox.tsx @@ -0,0 +1,12 @@ +import { Form as FormBS } from 'react-bootstrap' +import * as React from 'react' + +interface FormCheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> { + id: string + label: string + name: string +} + +export function FormCheckbox({ label, name, id, ...props }: FormCheckboxProps) { + return <FormBS.Check label={label} name={name} type="checkbox" id={id} {...props} /> +} diff --git a/src/sections/ui/form/form-group/form-element/FormElementLayout.tsx b/src/sections/ui/form/form-group/form-element/FormElementLayout.tsx new file mode 100644 index 000000000..92895b4fc --- /dev/null +++ b/src/sections/ui/form/form-group/form-element/FormElementLayout.tsx @@ -0,0 +1,12 @@ +import { PropsWithChildren } from 'react' +import { Col } from '../../../grid/Col' + +interface LayoutFormGroupElementProps { + withinMultipleFieldsGroup?: boolean +} +export function FormElementLayout({ + withinMultipleFieldsGroup, + children +}: PropsWithChildren<LayoutFormGroupElementProps>) { + return withinMultipleFieldsGroup ? <>{children}</> : <Col sm={9}>{children}</Col> +} diff --git a/src/sections/ui/form/form-group/form-element/FormInput.tsx b/src/sections/ui/form/form-group/form-element/FormInput.tsx new file mode 100644 index 000000000..e6506d571 --- /dev/null +++ b/src/sections/ui/form/form-group/form-element/FormInput.tsx @@ -0,0 +1,40 @@ +import { Form as FormBS, InputGroup } from 'react-bootstrap' +import { FormElementLayout } from './FormElementLayout' +import { PropsWithChildren } from 'react' +import * as React from 'react' + +export type FormInputElement = HTMLInputElement | HTMLTextAreaElement + +interface FormInputProps extends React.HTMLAttributes<FormInputElement> { + type?: 'text' | 'email' | 'password' + readOnly?: boolean + prefix?: string + withinMultipleFieldsGroup?: boolean +} + +export function FormInput({ + type = 'text', + readOnly, + prefix, + withinMultipleFieldsGroup, + ...props +}: FormInputProps) { + const FormInputPrefix = ({ children }: PropsWithChildren) => { + return prefix ? ( + <InputGroup className="mb-3"> + <InputGroup.Text>{prefix}</InputGroup.Text> + {children} + </InputGroup> + ) : ( + <>{children}</> + ) + } + + return ( + <FormElementLayout withinMultipleFieldsGroup={withinMultipleFieldsGroup}> + <FormInputPrefix> + <FormBS.Control type={type} readOnly={readOnly} plaintext={readOnly} {...props} /> + </FormInputPrefix> + </FormElementLayout> + ) +} diff --git a/src/sections/ui/form/form-group/form-element/FormLabel.tsx b/src/sections/ui/form/form-group/form-element/FormLabel.tsx new file mode 100644 index 000000000..b58c94ca2 --- /dev/null +++ b/src/sections/ui/form/form-group/form-element/FormLabel.tsx @@ -0,0 +1,27 @@ +import { PropsWithChildren } from 'react' +import { Form as FormBS } from 'react-bootstrap' +import { RequiredInputSymbol } from '../../required-input-symbol/RequiredInputSymbol' +import { Tooltip } from '../../../tooltip/Tooltip' + +interface FormLabelProps { + required?: boolean + message?: string + withinMultipleFieldsGroup?: boolean +} + +export function FormLabel({ + required, + message, + withinMultipleFieldsGroup, + children +}: PropsWithChildren<FormLabelProps>) { + const layoutProps = withinMultipleFieldsGroup ? {} : { column: true, sm: 3 } + + return ( + <FormBS.Label {...layoutProps}> + {children} + {required && <RequiredInputSymbol />}{' '} + {message && <Tooltip placement="right" message={message}></Tooltip>} + </FormBS.Label> + ) +} diff --git a/src/sections/ui/form/form-group/form-element/FormSelect.tsx b/src/sections/ui/form/form-group/form-element/FormSelect.tsx new file mode 100644 index 000000000..e51fc6b35 --- /dev/null +++ b/src/sections/ui/form/form-group/form-element/FormSelect.tsx @@ -0,0 +1,20 @@ +import { PropsWithChildren } from 'react' +import { Form as FormBS } from 'react-bootstrap' +import { FormElementLayout } from './FormElementLayout' +import * as React from 'react' + +interface FormSelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> { + withinMultipleFieldsGroup?: boolean +} + +export function FormSelect({ + withinMultipleFieldsGroup, + children, + ...props +}: PropsWithChildren<FormSelectProps>) { + return ( + <FormElementLayout withinMultipleFieldsGroup={withinMultipleFieldsGroup}> + <FormBS.Select {...props}>{children}</FormBS.Select> + </FormElementLayout> + ) +} diff --git a/src/sections/ui/form/form-group/form-element/FormText.tsx b/src/sections/ui/form/form-group/form-element/FormText.tsx new file mode 100644 index 000000000..eb7c3b3ac --- /dev/null +++ b/src/sections/ui/form/form-group/form-element/FormText.tsx @@ -0,0 +1,28 @@ +import { PropsWithChildren } from 'react' +import { Form as FormBS } from 'react-bootstrap' +import { Col } from '../../../grid/Col' + +interface FormTextProps { + withinMultipleFieldsGroup?: boolean +} + +export function FormText({ + withinMultipleFieldsGroup, + children +}: PropsWithChildren<FormTextProps>) { + const Layout = ({ children }: PropsWithChildren) => { + return withinMultipleFieldsGroup ? ( + <>{children}</> + ) : ( + <Col sm={{ offset: 3, span: 9 }} className="mt-2"> + {children} + </Col> + ) + } + + return ( + <Layout> + <FormBS.Text muted>{children}</FormBS.Text> + </Layout> + ) +} diff --git a/src/sections/ui/form/form-group/form-element/FormTextArea.tsx b/src/sections/ui/form/form-group/form-element/FormTextArea.tsx new file mode 100644 index 000000000..9773912be --- /dev/null +++ b/src/sections/ui/form/form-group/form-element/FormTextArea.tsx @@ -0,0 +1,16 @@ +import { Form as FormBS } from 'react-bootstrap' +import { FormElementLayout } from './FormElementLayout' +import * as React from 'react' + +export type FormInputElement = HTMLInputElement | HTMLTextAreaElement +interface FormTextAreaProps extends Omit<React.HTMLAttributes<FormInputElement>, 'rows'> { + withinMultipleFieldsGroup?: boolean +} + +export function FormTextArea({ withinMultipleFieldsGroup, ...props }: FormTextAreaProps) { + return ( + <FormElementLayout withinMultipleFieldsGroup={withinMultipleFieldsGroup}> + <FormBS.Control as="textarea" rows={5} {...props} /> + </FormElementLayout> + ) +} diff --git a/src/sections/ui/form/required-input-symbol/RequiredInputSymbol.module.scss b/src/sections/ui/form/required-input-symbol/RequiredInputSymbol.module.scss new file mode 100644 index 000000000..c31abd013 --- /dev/null +++ b/src/sections/ui/form/required-input-symbol/RequiredInputSymbol.module.scss @@ -0,0 +1,5 @@ +@import "src/sections/ui/assets/styles/design-tokens/colors.module"; + +.asterisk { + color: $dv-danger-color; +} \ No newline at end of file diff --git a/src/sections/ui/form/required-input-symbol/RequiredInputSymbol.tsx b/src/sections/ui/form/required-input-symbol/RequiredInputSymbol.tsx new file mode 100644 index 000000000..6f65e6520 --- /dev/null +++ b/src/sections/ui/form/required-input-symbol/RequiredInputSymbol.tsx @@ -0,0 +1,10 @@ +import styles from './RequiredInputSymbol.module.scss' + +export const RequiredInputSymbol = () => { + return ( + <span role="img" aria-label="Required input symbol" className={styles.asterisk}> + {' '} + * + </span> + ) +} diff --git a/src/sections/ui/grid/Col.tsx b/src/sections/ui/grid/Col.tsx index 24409857d..b9c3d8b8a 100644 --- a/src/sections/ui/grid/Col.tsx +++ b/src/sections/ui/grid/Col.tsx @@ -3,12 +3,19 @@ import { ReactNode } from 'react' import * as React from 'react' type ColSize = number | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | '11' | '12' -interface ColProps extends React.HTMLAttributes<HTMLElement> { +type ColSpec = + | ColSize + | { + span?: ColSize + offset?: ColSize + } + +export interface ColProps extends React.HTMLAttributes<HTMLElement> { children: ReactNode - xs?: ColSize - sm?: ColSize - md?: ColSize - lg?: ColSize + xs?: ColSpec + sm?: ColSpec + md?: ColSpec + lg?: ColSpec } export function Col({ children, ...props }: ColProps) { diff --git a/src/sections/ui/tooltip/QuestionIcon.module.scss b/src/sections/ui/tooltip/QuestionIcon.module.scss index 6153b7412..478c04399 100644 --- a/src/sections/ui/tooltip/QuestionIcon.module.scss +++ b/src/sections/ui/tooltip/QuestionIcon.module.scss @@ -1,9 +1,9 @@ @import "src/sections/ui/assets/styles/design-tokens/colors.module"; -svg { +.question-tooltip { color: $dv-tooltip-color; } -svg:hover { +.question-tooltip:hover { color: $dv-tooltip-hover-color; } \ No newline at end of file diff --git a/src/sections/ui/tooltip/QuestionIcon.tsx b/src/sections/ui/tooltip/QuestionIcon.tsx index 194277d39..08a5799e6 100644 --- a/src/sections/ui/tooltip/QuestionIcon.tsx +++ b/src/sections/ui/tooltip/QuestionIcon.tsx @@ -1,5 +1,5 @@ import styles from './QuestionIcon.module.scss' import { QuestionCircleFill } from 'react-bootstrap-icons' export function QuestionIcon() { - return <QuestionCircleFill className={styles.svg} /> + return <QuestionCircleFill className={styles['question-tooltip']} /> } diff --git a/src/sections/ui/tooltip/Tooltip.module.scss b/src/sections/ui/tooltip/Tooltip.module.scss new file mode 100644 index 000000000..5df6117e2 --- /dev/null +++ b/src/sections/ui/tooltip/Tooltip.module.scss @@ -0,0 +1,4 @@ +.tooltip { + display: inline-block; + vertical-align: 0.125em; +} \ No newline at end of file diff --git a/src/sections/ui/tooltip/Tooltip.tsx b/src/sections/ui/tooltip/Tooltip.tsx index 936501790..412b251d3 100644 --- a/src/sections/ui/tooltip/Tooltip.tsx +++ b/src/sections/ui/tooltip/Tooltip.tsx @@ -1,7 +1,7 @@ -import { Tooltip as TooltipBS } from 'react-bootstrap' -import { OverlayTrigger } from 'react-bootstrap' import { Placement } from 'react-bootstrap/types' import { QuestionIcon } from './QuestionIcon' +import styles from './Tooltip.module.scss' +import { OverlayTrigger } from './overlay-trigger/OverlayTrigger' export interface TooltipProps { placement: Placement @@ -10,15 +10,10 @@ export interface TooltipProps { export function Tooltip({ placement, message }: TooltipProps) { return ( - <> - <OverlayTrigger - key={placement} - placement={placement} - overlay={<TooltipBS>{message}</TooltipBS>}> - <span role="img" aria-label="tooltip icon"> - <QuestionIcon></QuestionIcon> - </span> - </OverlayTrigger> - </> + <OverlayTrigger placement={placement} message={message}> + <span role="img" aria-label="tooltip icon" className={styles.tooltip}> + <QuestionIcon></QuestionIcon> + </span> + </OverlayTrigger> ) } diff --git a/src/sections/ui/tooltip/overlay-trigger/OverlayTrigger.tsx b/src/sections/ui/tooltip/overlay-trigger/OverlayTrigger.tsx new file mode 100644 index 000000000..dcf03b688 --- /dev/null +++ b/src/sections/ui/tooltip/overlay-trigger/OverlayTrigger.tsx @@ -0,0 +1,19 @@ +import { OverlayTrigger as OverlayTriggerBS, Tooltip as TooltipBS } from 'react-bootstrap' +import { Placement } from 'react-bootstrap/types' +import { ReactElement } from 'react' + +interface OverlayTriggerProps { + placement: Placement + message: string + children: ReactElement +} +export function OverlayTrigger({ placement, message, children }: OverlayTriggerProps) { + return ( + <OverlayTriggerBS + key={placement} + placement={placement} + overlay={<TooltipBS>{message}</TooltipBS>}> + {children} + </OverlayTriggerBS> + ) +} diff --git a/src/stories/ui/form/Form.stories.tsx b/src/stories/ui/form/Form.stories.tsx new file mode 100644 index 000000000..f9c9fc279 --- /dev/null +++ b/src/stories/ui/form/Form.stories.tsx @@ -0,0 +1,268 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Form } from '../../../sections/ui/form/Form' +import { Col } from '../../../sections/ui/grid/Col' +import { Row } from '../../../sections/ui/grid/Row' + +/** + * ## Description + * A form is a collection of HTML elements used to gather user input. It allows users to enter data, such as text, numbers, + * or file uploads, and submit it to a server for processing. + * + * ## Usage guidelines + * ### Dos + * - Input labels: + * - Each input field should have a label + * - Labels should be short and descriptive + * - They should be placed above the input field + * - Input text: + * - If you need to describe an input text you can use the `Form.Group.Text` component + * - Add the text below the input + * - Select: + * - First option should be the 'Select...' option + * - The options should use the `<option>` tag + * + * ### Don'ts + * - Leave inputs without labels + */ +const meta: Meta<typeof Form> = { + title: 'UI/Form', + component: Form, + tags: ['autodocs'] +} + +export default meta +type Story = StoryObj<typeof Form> + +export const Default: Story = { + render: () => ( + <Form> + <Form.Group controlId="basic-form-username"> + <Form.Group.Label>Username</Form.Group.Label> + <Form.Group.Input type="text" placeholder="Username" /> + </Form.Group> + </Form> + ) +} + +export const AllInputTypes: Story = { + render: () => ( + <Form> + <Form.Group controlId="basic-form-username"> + <Form.Group.Label>Username</Form.Group.Label> + <Form.Group.Input type="text" placeholder="Username" /> + </Form.Group> + <Form.Group controlId="basic-form-email"> + <Form.Group.Label>Email</Form.Group.Label> + <Form.Group.Input type="email" placeholder="Email" /> + </Form.Group> + <Form.Group controlId="basic-form-password"> + <Form.Group.Label>Password</Form.Group.Label> + <Form.Group.Input type="password" placeholder="Password" /> + </Form.Group> + </Form> + ) +} + +export const FieldWithText: Story = { + render: () => ( + <Form> + <Form.Group controlId="basic-form-username"> + <Form.Group.Label>Username</Form.Group.Label> + <Form.Group.Input type="text" placeholder="Username" /> + <Form.Group.Text> + Create a valid username of 2 to 60 characters in length containing letters (a-Z), numbers + (0-9), dashes (-), underscores (_), and periods (.). + </Form.Group.Text> + </Form.Group> + </Form> + ) +} + +export const RequiredField: Story = { + render: () => ( + <Form> + <Form.Group controlId="basic-form-email" required> + <Form.Group.Label>Email</Form.Group.Label> + <Form.Group.Input type="email" placeholder="Email" aria-label="Disabled input example" /> + </Form.Group> + </Form> + ) +} + +export const FieldWithMessage: Story = { + render: () => ( + <Form> + <Form.Group controlId="basic-form-email" required> + <Form.Group.Label message="This is your personal email">Email</Form.Group.Label> + <Form.Group.Input type="email" placeholder="Email" aria-label="Disabled input example" /> + </Form.Group> + </Form> + ) +} + +export const ReadOnlyInput: Story = { + render: () => ( + <Form> + <Form.Group controlId="basic-form-email"> + <Form.Group.Label>Email</Form.Group.Label> + <Form.Group.Input type="email" readOnly defaultValue="text.email@example.com" /> + </Form.Group> + </Form> + ) +} + +export const InputWithPrefix: Story = { + render: () => ( + <Form> + <Form.Group controlId="basic-form-identifier"> + <Form.Group.Label>Identifier</Form.Group.Label> + <Form.Group.Input type="text" placeholder="Identifier" prefix="https://dataverse.org/" /> + </Form.Group> + </Form> + ) +} + +export const Select: Story = { + render: () => ( + <Form> + <Form.Group controlId="basic-form-select"> + <Form.Group.Label>Selector</Form.Group.Label> + <Form.Group.Select> + <option>Select...</option> + <option value="1">Option 1</option> + <option value="2">Option 2</option> + <option value="3">Option 3</option> + </Form.Group.Select> + </Form.Group> + </Form> + ) +} + +export const TextArea: Story = { + render: () => ( + <Form> + <Form.Group controlId="basic-form-description"> + <Form.Group.Label>Description</Form.Group.Label> + <Form.Group.TextArea /> + </Form.Group> + </Form> + ) +} +export const Checkbox: Story = { + render: () => ( + <Form> + <Form.GroupWithMultipleFields title="Metadata Fields"> + <Form.Group.Checkbox + defaultChecked + name="metadata-field" + label="Citation Metadata" + id="basic-form-citation-metadata" + /> + <Form.Group.Checkbox + required + name="metadata-field" + label="Geospatial Metadata" + id="basic-form-geospatial-metadata" + /> + <Form.Group.Checkbox + name="metadata-field" + label="Social Science and Humanities Metadata" + id="basic-form-social-science-metadata" + /> + </Form.GroupWithMultipleFields> + </Form> + ) +} + +export const GroupWithMultipleFields: Story = { + render: () => ( + <Form> + <Form.GroupWithMultipleFields + title="Related Publication" + message="The article or report that uses the data in the Dataset. The full list of related publications will be displayed on the metadata tab" + withDynamicFields> + <Row> + <Form.Group as={Col} controlId="basic-form-citation"> + <Form.Group.Label message="The full bibliographic citation for the related publication"> + Citation + </Form.Group.Label> + <Form.Group.TextArea /> + </Form.Group> + </Row> + <Row> + <Form.Group as={Col} controlId="basic-form-identifier-type"> + <Form.Group.Label message="The type of identifier that uniquely identifies a related publication"> + Identifier Type + </Form.Group.Label> + <Form.Group.Select> + <option>Select...</option> + <option value="doi">doi</option> + <option value="isbn">isbn</option> + <option value="url">url</option> + </Form.Group.Select> + </Form.Group> + <Form.Group as={Col} controlId="basic-form-identifier"> + <Form.Group.Label message="The identifier for a related publication"> + Identifier + </Form.Group.Label> + <Form.Group.Input type="text" /> + </Form.Group> + </Row> + <Row> + <Form.Group as={Col} controlId="basic-form-url" sm={6}> + <Form.Group.Label message="The URL form of the identifier entered in the Identifier field, e.g. the DOI URL if a DOI was entered in the Identifier field. Used to display what was entered in the ID Type and ID Number fields as a link. If what was entered in the Identifier field has no URL form, the URL of the publication webpage is used, e.g. a journal article webpage"> + URL + </Form.Group.Label> + <Form.Group.Input type="text" placeholder="https://" /> + </Form.Group> + </Row> + </Form.GroupWithMultipleFields> + </Form> + ) +} + +export const FormValidation: Story = { + render: () => ( + <Form validated> + <Form.GroupWithMultipleFields + title="Author" + required + message="The entity, e.g. a person or organization, that created the Dataset" + withDynamicFields> + <Row> + <Form.Group as={Col} controlId="basic-form-name" required> + <Form.Group.Label message="The name of the author, such as the person's name or the name of an organization"> + Name + </Form.Group.Label> + <Form.Group.Input type="text" placeholder="Name" /> + </Form.Group> + <Form.Group as={Col} controlId="basic-form-affiliation"> + <Form.Group.Label message="The name of the entity affiliated with the author, e.g. an organization's name"> + Affiliation + </Form.Group.Label> + <Form.Group.Input type="text" placeholder="Affiliation" /> + </Form.Group> + </Row> + <Row> + <Form.Group as={Col} controlId="basic-form-identifier-type"> + <Form.Group.Label message="The type of identifier that uniquely identifies the author (e.g. ORCID, ISNI)"> + Identifier Type + </Form.Group.Label> + <Form.Group.Select> + <option>Select...</option> + <option value="1">Option 1</option> + <option value="2">Option 2</option> + <option value="3">Option 3</option> + </Form.Group.Select> + </Form.Group> + <Form.Group as={Col} controlId="basic-form-identifier" required> + <Form.Group.Label message="Uniquely identifies the author when paired with an identifier type"> + Identifier + </Form.Group.Label> + <Form.Group.Input type="text" placeholder="Identifier" defaultValue="123456" /> + </Form.Group> + </Row> + </Form.GroupWithMultipleFields> + </Form> + ) +} diff --git a/src/stories/ui/grid/Grid.stories.tsx b/src/stories/ui/grid/Grid.stories.tsx index cc52bf21a..7bcef0994 100644 --- a/src/stories/ui/grid/Grid.stories.tsx +++ b/src/stories/ui/grid/Grid.stories.tsx @@ -87,3 +87,21 @@ export const RowsWithDifferentWidths: Story = { </Container> ) } + +export const ColumnsWithOffset: Story = { + render: () => ( + <Container> + <Row> + <Col md={4}>md=4</Col> + <Col md={{ span: 4, offset: 4 }}>{`md={{ span: 4, offset: 4 }}`}</Col> + </Row> + <Row> + <Col md={{ span: 3, offset: 3 }}>{`md={{ span: 3, offset: 3 }}`}</Col> + <Col md={{ span: 3, offset: 3 }}>{`md={{ span: 3, offset: 3 }}`}</Col> + </Row> + <Row> + <Col md={{ span: 6, offset: 3 }}>{`md={{ span: 6, offset: 3 }}`}</Col> + </Row> + </Container> + ) +} diff --git a/tests/sections/ui/dropdown-button/DropdownButton.test.tsx b/tests/sections/ui/dropdown-button/DropdownButton.test.tsx index 8a056fcdf..5b5bf9bdf 100644 --- a/tests/sections/ui/dropdown-button/DropdownButton.test.tsx +++ b/tests/sections/ui/dropdown-button/DropdownButton.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render } from '@testing-library/react' import { DropdownButton } from '../../../../src/sections/ui/dropdown-button/DropdownButton' import { Icon } from '../../../../src/sections/ui/icon.enum' +import styles from '../../../../src/sections/ui/dropdown-button/DropdownButton.module.scss' const titleText = 'My Dropdown Button' @@ -49,17 +50,26 @@ describe('DropdownButton', () => { expect(getByRole('img', { name: Icon.COLLECTION })).toBeInTheDocument() }) + it('renders with spacing class when withSpacing prop is true', () => { + const { getByRole } = render( + <DropdownButton id="my-dropdown" title={titleText} withSpacing> + <span>Item 1</span> + <span>Item 2</span> + </DropdownButton> + ) + + const button = getByRole('button', { name: titleText }) + expect(button.parentNode).toHaveClass(styles.spacing) + }) + it('renders as a button group', () => { const { getByRole } = render( - <DropdownButton - id="dropdown-button" - title="Dropdown Button" - asButtonGroup - data-testid="dropdown-button"> + <DropdownButton id="dropdown-button" title="Dropdown Button" asButtonGroup> <span>Item 1</span> <span>Item 2</span> </DropdownButton> ) + expect(getByRole('group')).toBeInTheDocument() }) }) diff --git a/tests/sections/ui/form/Form.test.tsx b/tests/sections/ui/form/Form.test.tsx new file mode 100644 index 000000000..ccd2bb6cf --- /dev/null +++ b/tests/sections/ui/form/Form.test.tsx @@ -0,0 +1,29 @@ +import { render, fireEvent } from '@testing-library/react' +import { Form } from '../../../../src/sections/ui/form/Form' +import { vi } from 'vitest' +import { FormEvent } from 'react' + +describe('Form', () => { + it('should render children', () => { + const { getByText } = render( + <Form> + <label htmlFor="username">Username</label> + <input type="text" id="username" /> + </Form> + ) + + expect(getByText('Username')).toBeInTheDocument() + }) + + it('should call onSubmit when the form is submitted', () => { + const handleSubmit = vi.fn((e: FormEvent<HTMLFormElement>) => e.preventDefault()) + const { getByText } = render( + <Form onSubmit={handleSubmit}> + <button type="submit">Submit Form</button> + </Form> + ) + + fireEvent.click(getByText('Submit Form')) + expect(handleSubmit).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/sections/ui/form/form-group-with-multiple-fields/FormGroupWithMultipleFields.test.tsx b/tests/sections/ui/form/form-group-with-multiple-fields/FormGroupWithMultipleFields.test.tsx new file mode 100644 index 000000000..4ea95470f --- /dev/null +++ b/tests/sections/ui/form/form-group-with-multiple-fields/FormGroupWithMultipleFields.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render } from '@testing-library/react' +import { FormGroupWithMultipleFields } from '../../../../../src/sections/ui/form/form-group-multiple-fields/FormGroupWithMultipleFields' +import { FormGroup } from '../../../../../src/sections/ui/form/form-group/FormGroup' + +describe('FormGroupWithMultipleFields', () => { + it('renders title with required input symbol if required prop is true', () => { + const { getByText, getByRole } = render( + <FormGroupWithMultipleFields title="Test Title" required /> + ) + const title = getByText(/Test Title/) + const requiredInputSymbol = getByRole('img', { name: 'Required input symbol' }) + + expect(title).toBeInTheDocument() + expect(requiredInputSymbol).toBeInTheDocument() + }) + + it('renders title without required input symbol if required prop is false', () => { + const { getByText, getByRole } = render(<FormGroupWithMultipleFields title="Test Title" />) + const title = getByText(/Test Title/) + + expect(title).toBeInTheDocument() + expect(() => getByRole('img')).toThrow() + }) + + it('renders with children', () => { + const { getByText } = render( + <FormGroupWithMultipleFields title="Test Title"> + <div>Test Children</div> + </FormGroupWithMultipleFields> + ) + const children = getByText('Test Children') + expect(children).toBeInTheDocument() + }) + + it('adds and removes fields dynamically when enabled', () => { + const { getAllByLabelText, getByText } = render( + <FormGroupWithMultipleFields title="Test Group" withDynamicFields> + <FormGroup controlId="username"> + <FormGroup.Label>Username</FormGroup.Label> + <FormGroup.Input type="text" /> + </FormGroup> + </FormGroupWithMultipleFields> + ) + + expect(getAllByLabelText('Username').length).toBe(1) + + fireEvent.click(getByText('Add')) + + expect(getAllByLabelText('Username').length).toBe(2) + + fireEvent.click(getByText('Delete')) + + expect(getAllByLabelText('Username').length).toBe(1) + }) +}) diff --git a/tests/sections/ui/form/form-group-with-multiple-fields/dynamic-fields-buttons/DynamicFieldsButtons.test.tsx b/tests/sections/ui/form/form-group-with-multiple-fields/dynamic-fields-buttons/DynamicFieldsButtons.test.tsx new file mode 100644 index 000000000..50418b76f --- /dev/null +++ b/tests/sections/ui/form/form-group-with-multiple-fields/dynamic-fields-buttons/DynamicFieldsButtons.test.tsx @@ -0,0 +1,62 @@ +import { render, fireEvent } from '@testing-library/react' +import { DynamicFieldsButtons } from '../../../../../../src/sections/ui/form/form-group-multiple-fields/dynamic-fields-buttons/DynamicFieldsButtons' +import { vi } from 'vitest' + +describe('DynamicFieldsButtons', () => { + it('renders add button correctly', () => { + const onAddButtonClick = vi.fn() + const onRemoveButtonClick = vi.fn() + + const { getByRole } = render( + <DynamicFieldsButtons + onAddButtonClick={onAddButtonClick} + onRemoveButtonClick={onRemoveButtonClick} + /> + ) + + const addButton = getByRole('button', { name: 'Add' }) + expect(addButton).toBeInTheDocument() + + fireEvent.click(addButton) + expect(onAddButtonClick).toHaveBeenCalledTimes(1) + }) + + it('renders remove button correctly when originalField is false', () => { + const onAddButtonClick = vi.fn() + const onRemoveButtonClick = vi.fn() + const { getByRole } = render( + <DynamicFieldsButtons + onAddButtonClick={onAddButtonClick} + onRemoveButtonClick={onRemoveButtonClick} + /> + ) + + const removeButton = getByRole('button', { name: 'Delete' }) + expect(removeButton).toBeInTheDocument() + + fireEvent.click(removeButton) + expect(onRemoveButtonClick).toHaveBeenCalledTimes(1) + }) + + it('does not render remove button when originalField is true', () => { + const onAddButtonClick = vi.fn() + const onRemoveButtonClick = vi.fn() + const { getByRole, queryByRole } = render( + <DynamicFieldsButtons + originalField + onAddButtonClick={onAddButtonClick} + onRemoveButtonClick={onRemoveButtonClick} + /> + ) + + const addButton = getByRole('button', { name: 'Add' }) + expect(addButton).toBeInTheDocument() + + const removeButton = queryByRole('button', { name: 'Delete' }) + expect(removeButton).toBeNull() + + fireEvent.click(addButton) + expect(onAddButtonClick).toHaveBeenCalledTimes(1) + expect(onRemoveButtonClick).toHaveBeenCalledTimes(0) + }) +}) diff --git a/tests/sections/ui/form/form-group-with-multiple-fields/useFields.test.tsx b/tests/sections/ui/form/form-group-with-multiple-fields/useFields.test.tsx new file mode 100644 index 000000000..28cab42f7 --- /dev/null +++ b/tests/sections/ui/form/form-group-with-multiple-fields/useFields.test.tsx @@ -0,0 +1,124 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import { useFields } from '../../../../../src/sections/ui/form/form-group-multiple-fields/useFields' + +describe('useFields', () => { + it('renders a form with a single field', () => { + function TestComponent() { + const { fields } = useFields( + <> + <label htmlFor="testField">testField</label> + <input type="text" name="testField" id="testField" /> + </> + ) + + return ( + <form> + {fields.map((field, index) => ( + <React.Fragment key={index}>{field}</React.Fragment> + ))} + </form> + ) + } + + const { getByLabelText } = render(<TestComponent />) + + expect(getByLabelText('testField')).toBeInTheDocument() + }) + + it('adds a new field to the form when the addField function is called', async () => { + function TestComponent() { + const { fields, addField } = useFields( + <> + <label htmlFor="testField">testField</label> + <input type="text" name="testField" id="testField" /> + </>, + true + ) + + return ( + <form> + {fields.map((field, index) => ( + <React.Fragment key={index}>{field}</React.Fragment> + ))} + <button + type="button" + onClick={() => + addField( + <> + <label htmlFor="testField2">testField2</label> + <input type="text" name="testField2" id="testField2" /> + </> + ) + }> + Add Field + </button> + </form> + ) + } + + const { getByLabelText, findByLabelText, getByRole } = render(<TestComponent />) + + expect(getByLabelText('testField')).toBeInTheDocument() + + const addFieldButton = getByRole('button', { name: 'Add Field' }) + + fireEvent.click(addFieldButton) + + expect(await findByLabelText('testField')).toBeInTheDocument() + expect(await findByLabelText('testField2')).toBeInTheDocument() + }) + + it('removes a field from the form when the removeField function is called', () => { + function TestComponent() { + const { fields, removeField, addField } = useFields( + <> + <label htmlFor="testField1">testField1</label> + <input type="text" name="testField1" id="testField1" /> + </>, + true + ) + + return ( + <form> + {fields.map((field, index) => { + return <React.Fragment key={index}> {field}</React.Fragment> + })} + <button type="button" onClick={() => removeField(1)}> + Remove Field + </button> + <button + type="button" + onClick={() => + addField( + <> + <label htmlFor="testField2">testField2</label> + <input type="text" name="testField2" id="testField2" /> + </> + ) + }> + Add Field + </button> + </form> + ) + } + + const { getByLabelText, getByRole } = render(<TestComponent />) + + const addFieldButton = getByRole('button', { name: 'Add Field' }) + + fireEvent.click(addFieldButton) + + expect(getByLabelText('testField1')).toBeInTheDocument() + expect(getByLabelText('testField2')).toBeInTheDocument() + + const removeFieldButton = getByRole('button', { + name: 'Remove Field' + }) + + fireEvent.click(removeFieldButton) + + expect(screen.getByLabelText('testField1')).toBeInTheDocument() + expect(screen.queryByLabelText('testField2')).toBeNull() + }) +}) diff --git a/tests/sections/ui/form/form-group/FormGroupCheckbox.test.tsx b/tests/sections/ui/form/form-group/FormGroupCheckbox.test.tsx new file mode 100644 index 000000000..3f6abdcc4 --- /dev/null +++ b/tests/sections/ui/form/form-group/FormGroupCheckbox.test.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render } from '@testing-library/react' +import { FormGroupWithMultipleFields } from '../../../../../src/sections/ui/form/form-group-multiple-fields/FormGroupWithMultipleFields' +import { FormGroup } from '../../../../../src/sections/ui/form/form-group/FormGroup' + +const option1Label = 'Test Label 1' +const option2Label = 'Test Label 2' +const option3Label = 'Test Label 3' +const checkboxName = 'checkbox-name' + +describe('FormCheckbox', () => { + test('renders label and checkbox input', () => { + const { getByLabelText } = render( + <FormGroupWithMultipleFields title="Checkbox"> + <FormGroup.Checkbox id="checkbox-1" label={option1Label} name={checkboxName} /> + <FormGroup.Checkbox id="checkbox-2" label={option2Label} name={checkboxName} /> + <FormGroup.Checkbox id="checkbox-3" label={option3Label} name={checkboxName} /> + </FormGroupWithMultipleFields> + ) + + const checkbox1 = getByLabelText(option1Label) + expect(checkbox1).toBeInTheDocument() + + const checkbox2 = getByLabelText(option2Label) + expect(checkbox2).toBeInTheDocument() + + const checkbox3 = getByLabelText(option3Label) + expect(checkbox3).toBeInTheDocument() + }) + + test('renders without label', () => { + const { getByLabelText } = render( + <FormGroupWithMultipleFields title="Checkbox"> + <FormGroup.Checkbox id="checkbox-1" label={option1Label} name={checkboxName} /> + <FormGroup.Checkbox id="checkbox-2" label={option2Label} name={checkboxName} /> + <FormGroup.Checkbox id="checkbox-3" label={option3Label} name={checkboxName} /> + </FormGroupWithMultipleFields> + ) + + const checkbox2 = getByLabelText(option2Label) + + fireEvent.click(checkbox2) + + expect(checkbox2).toBeChecked() + }) +}) diff --git a/tests/sections/ui/form/form-group/FormGroupInput.test.tsx b/tests/sections/ui/form/form-group/FormGroupInput.test.tsx new file mode 100644 index 000000000..3fbb13501 --- /dev/null +++ b/tests/sections/ui/form/form-group/FormGroupInput.test.tsx @@ -0,0 +1,76 @@ +import { render, fireEvent } from '@testing-library/react' +import { FormGroup } from '../../../../../src/sections/ui/form/form-group/FormGroup' +import { vi } from 'vitest' + +describe('FormInput', () => { + it('should render with the specified type', () => { + const { getByLabelText } = render( + <FormGroup controlId="username"> + <FormGroup.Label>Username</FormGroup.Label> + <FormGroup.Input type="text" /> + </FormGroup> + ) + expect(getByLabelText('Username')).toHaveAttribute('type', 'text') + }) + + it('should render with the specified readOnly attribute', () => { + const { getByLabelText } = render( + <FormGroup controlId="username"> + <FormGroup.Label>Username</FormGroup.Label> + <FormGroup.Input type="text" readOnly /> + </FormGroup> + ) + + expect(getByLabelText('Username')).toHaveAttribute('readOnly') + }) + + it('should render with the specified prefix', () => { + const { getByText } = render( + <FormGroup controlId="username"> + <FormGroup.Label>Username</FormGroup.Label> + <FormGroup.Input prefix="Prefix:" type="text" readOnly /> + </FormGroup> + ) + + expect(getByText('Prefix:')).toBeInTheDocument() + }) + + it('should render with the required symbol', () => { + const { getByRole } = render( + <FormGroup controlId="username" required> + <FormGroup.Label>Username</FormGroup.Label> + <FormGroup.Input prefix="Prefix:" type="text" readOnly /> + </FormGroup> + ) + + const requiredSymbol = getByRole('img') + expect(requiredSymbol).toBeInTheDocument() + }) + + it('should call onChange when the input value is changed', () => { + const handleChange = vi.fn() + const { getByLabelText } = render( + <FormGroup controlId="username"> + <FormGroup.Label>Username</FormGroup.Label> + <FormGroup.Input type="text" onChange={handleChange} /> + </FormGroup> + ) + + expect(getByLabelText('Username')).not.toHaveValue('new value') + + fireEvent.change(getByLabelText('Username'), { target: { value: 'new value' } }) + expect(handleChange).toHaveBeenCalled() + expect(getByLabelText('Username')).toHaveValue('new value') + }) + + it('renders with fieldIndex in the id when provided', () => { + const { getByLabelText } = render( + <FormGroup controlId="username" fieldIndex="1"> + <FormGroup.Label>Username</FormGroup.Label> + <FormGroup.Input type="text" /> + </FormGroup> + ) + const input = getByLabelText('Username') + expect(input).toHaveAttribute('id', 'username-1') + }) +}) diff --git a/tests/sections/ui/form/form-group/FormGroupSelect.test.tsx b/tests/sections/ui/form/form-group/FormGroupSelect.test.tsx new file mode 100644 index 000000000..7f820e024 --- /dev/null +++ b/tests/sections/ui/form/form-group/FormGroupSelect.test.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render } from '@testing-library/react' +import { FormGroup } from '../../../../../src/sections/ui/form/form-group/FormGroup' +import { vi } from 'vitest' + +describe('FormSelect', () => { + it('renders without error', () => { + const { getByLabelText } = render( + <FormGroup controlId="selector"> + <FormGroup.Label>Selector</FormGroup.Label> + <FormGroup.Select> + <option>Select...</option> + <option value="1">Option 1</option> + <option value="2">Option 2</option> + <option value="3">Option 3</option> + </FormGroup.Select> + </FormGroup> + ) + + const selectElement = getByLabelText('Selector') + expect(selectElement).toBeInTheDocument() + }) + + it('renders the select options', () => { + const options = [ + { value: 'value1', label: 'Option 1' }, + { value: 'value2', label: 'Option 2' }, + { value: 'value3', label: 'Option 3' } + ] + + const { getByLabelText, getAllByRole } = render( + <FormGroup controlId="selector"> + <FormGroup.Label>Selector</FormGroup.Label> + <FormGroup.Select> + {options.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </FormGroup.Select> + </FormGroup> + ) + + const selectElement = getByLabelText('Selector') + const optionElements = getAllByRole('option') + + expect(selectElement).toBeInTheDocument() + expect(optionElements.length).toEqual(3) + }) + + it('passes through additional props', () => { + const onChange = vi.fn() + + const { getByLabelText } = render( + <FormGroup controlId="selector"> + <FormGroup.Label>Selector</FormGroup.Label> + <FormGroup.Select onChange={onChange}> + <option>Select...</option> + <option value="1">Option 1</option> + <option value="2">Option 2</option> + <option value="3">Option 3</option> + </FormGroup.Select> + </FormGroup> + ) + + const selectElement = getByLabelText('Selector') + + fireEvent.change(selectElement, { target: { value: '2' } }) + expect(onChange).toHaveBeenCalledTimes(1) + expect(selectElement).toHaveValue('2') + }) + + it('renders with fieldIndex in the id when provided', () => { + const { getByLabelText } = render( + <FormGroup controlId="selector" fieldIndex="3"> + <FormGroup.Label>Selector</FormGroup.Label> + <FormGroup.Select> + <option>Select...</option> + <option value="1">Option 1</option> + <option value="2">Option 2</option> + <option value="3">Option 3</option> + </FormGroup.Select> + </FormGroup> + ) + const input = getByLabelText('Selector') + expect(input).toHaveAttribute('id', 'selector-3') + }) +}) diff --git a/tests/sections/ui/form/form-group/FormGroupText.test.tsx b/tests/sections/ui/form/form-group/FormGroupText.test.tsx new file mode 100644 index 000000000..68669d1c3 --- /dev/null +++ b/tests/sections/ui/form/form-group/FormGroupText.test.tsx @@ -0,0 +1,20 @@ +import { render } from '@testing-library/react' +import { FormGroup } from '../../../../../src/sections/ui/form/form-group/FormGroup' + +describe('FormText component', () => { + it('renders with children', () => { + const { getByText } = render(<FormGroup.Text>Test text</FormGroup.Text>) + + const text = getByText('Test text') + expect(text).toBeInTheDocument() + }) + + it('renders with withinMultipleFieldsGroup prop', () => { + const { getByText } = render( + <FormGroup.Text withinMultipleFieldsGroup>Test text</FormGroup.Text> + ) + + const text = getByText('Test text') + expect(text).toBeInTheDocument() + }) +}) diff --git a/tests/sections/ui/form/form-group/FormGroupTextArea..test.tsx b/tests/sections/ui/form/form-group/FormGroupTextArea..test.tsx new file mode 100644 index 000000000..39385e5d2 --- /dev/null +++ b/tests/sections/ui/form/form-group/FormGroupTextArea..test.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react' +import { FormGroup } from '../../../../../src/sections/ui/form/form-group/FormGroup' + +describe('FormInput', () => { + test('renders FormTextArea component without crashing', () => { + const { getByRole } = render( + <FormGroup controlId="textarea"> + <FormGroup.Label>Username</FormGroup.Label> + <FormGroup.TextArea /> + </FormGroup> + ) + + const textarea = getByRole('textbox') + expect(textarea).toBeInTheDocument() + }) + + it('handles withinMultipleFieldsGroup prop', () => { + render( + <FormGroup controlId="textarea"> + <FormGroup.Label>Username</FormGroup.Label> + <FormGroup.TextArea withinMultipleFieldsGroup /> + </FormGroup> + ) + }) + + it('renders with fieldIndex in the id when provided', () => { + const { getByLabelText } = render( + <FormGroup controlId="textarea" fieldIndex="4"> + <FormGroup.Label>Username</FormGroup.Label> + <FormGroup.TextArea withinMultipleFieldsGroup /> + </FormGroup> + ) + const input = getByLabelText('Username') + expect(input).toHaveAttribute('id', 'textarea-4') + }) +})