From be00028c4ac3928f904324a551644db875ee34bd Mon Sep 17 00:00:00 2001 From: Jesper Hodge <19345795+jesperhodge@users.noreply.github.com> Date: Mon, 11 Mar 2024 12:59:05 -0400 Subject: [PATCH] Fix Problem Type Select (#460) * fix: override selectablebox with copy of version from edx paragon 21.5.6 * chore: remove console logs * fix: lint * chore: update snapshots --- .../content/ProblemTypeSelect.jsx | 4 +- .../ProblemTypeSelect.test.jsx.snap | 25 -- .../SelectableBox/FormCheckbox.jsx | 145 ++++++++++++ .../SelectableBox/FormCheckboxSet.jsx | 68 ++++++ .../SelectableBox/FormCheckboxSetContext.jsx | 10 +- .../SelectableBox/FormControlFeedback.jsx | 56 +++++ .../SelectableBox/FormControlSet.jsx | 40 ++++ .../SelectableBox/FormGroupContext.jsx | 121 ++++++++++ .../SelectableBox/FormLabel.jsx | 42 ++++ .../SelectableBox/FormRadio.jsx | 110 +++++++++ .../SelectableBox/FormRadioSet.jsx | 68 ++++++ .../SelectableBox/FormRadioSetContext.jsx | 15 +- .../SelectableBox/FormText.jsx | 115 ++++++++++ .../sharedComponents/SelectableBox/README.md | 215 ++++++++++++++++++ .../SelectableBox/SelectableBoxSet.jsx | 22 +- .../SelectableBox/_variables.scss | 5 + .../SelectableBox/constants.js | 17 ++ .../SelectableBox/fieldUtils.js | 70 ++++++ .../sharedComponents/SelectableBox/index.jsx | 18 +- .../sharedComponents/SelectableBox/index.scss | 54 +++++ .../sharedComponents/SelectableBox/newId.js | 8 + .../tests/SelectableBox.test.jsx | 124 ++++++++++ .../tests/SelectableBoxSet.test.jsx | 107 +++++++++ .../__snapshots__/SelectableBox.test.jsx.snap | 22 ++ .../SelectableBoxSet.test.jsx.snap | 57 +++++ .../{getInputType.jsx => utils.js} | 13 +- 26 files changed, 1468 insertions(+), 83 deletions(-) create mode 100644 src/editors/sharedComponents/SelectableBox/FormCheckbox.jsx create mode 100644 src/editors/sharedComponents/SelectableBox/FormCheckboxSet.jsx create mode 100644 src/editors/sharedComponents/SelectableBox/FormControlFeedback.jsx create mode 100644 src/editors/sharedComponents/SelectableBox/FormControlSet.jsx create mode 100644 src/editors/sharedComponents/SelectableBox/FormGroupContext.jsx create mode 100644 src/editors/sharedComponents/SelectableBox/FormLabel.jsx create mode 100644 src/editors/sharedComponents/SelectableBox/FormRadio.jsx create mode 100644 src/editors/sharedComponents/SelectableBox/FormRadioSet.jsx create mode 100644 src/editors/sharedComponents/SelectableBox/FormText.jsx create mode 100644 src/editors/sharedComponents/SelectableBox/README.md create mode 100644 src/editors/sharedComponents/SelectableBox/_variables.scss create mode 100644 src/editors/sharedComponents/SelectableBox/constants.js create mode 100644 src/editors/sharedComponents/SelectableBox/fieldUtils.js create mode 100644 src/editors/sharedComponents/SelectableBox/index.scss create mode 100644 src/editors/sharedComponents/SelectableBox/newId.js create mode 100644 src/editors/sharedComponents/SelectableBox/tests/SelectableBox.test.jsx create mode 100644 src/editors/sharedComponents/SelectableBox/tests/SelectableBoxSet.test.jsx create mode 100644 src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBox.test.jsx.snap create mode 100644 src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBoxSet.test.jsx.snap rename src/editors/sharedComponents/SelectableBox/{getInputType.jsx => utils.js} (63%) diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.jsx b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.jsx index 9cbc218a7..b1c71ddcc 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.jsx +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/ProblemTypeSelect.jsx @@ -3,9 +3,7 @@ import PropTypes from 'prop-types'; import { Button, Container } from '@openedx/paragon'; import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; -/* SelectableBox from Paragon currently has a bug as it misses a ContextProvider. -This is a temporary fix by overriding it with our own copy that includes the ContextProvider. */ -// TODO: Replace this import with a paragon import once the bug is fixed and delete our copy of SelectableBox. +// SelectableBox in paragon has a bug only visible on stage where you can't change selection. So we override it import SelectableBox from '../../../../../sharedComponents/SelectableBox'; import { ProblemTypes, ProblemTypeKeys, AdvanceProblemKeys } from '../../../../../data/constants/problem'; import messages from './messages'; diff --git a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/ProblemTypeSelect.test.jsx.snap b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/ProblemTypeSelect.test.jsx.snap index 596d3a87a..1b34450fa 100644 --- a/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/ProblemTypeSelect.test.jsx.snap +++ b/src/editors/containers/ProblemEditor/components/SelectTypeModal/content/__snapshots__/ProblemTypeSelect.test.jsx.snap @@ -25,7 +25,6 @@ exports[`ProblemTypeSelect snapshot DROPDOWN 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="multiplechoiceresponse" > @@ -41,7 +40,6 @@ exports[`ProblemTypeSelect snapshot DROPDOWN 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="choiceresponse" > @@ -57,7 +55,6 @@ exports[`ProblemTypeSelect snapshot DROPDOWN 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="optionresponse" > @@ -73,7 +70,6 @@ exports[`ProblemTypeSelect snapshot DROPDOWN 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="numericalresponse" > @@ -89,7 +85,6 @@ exports[`ProblemTypeSelect snapshot DROPDOWN 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="stringresponse" > @@ -135,7 +130,6 @@ exports[`ProblemTypeSelect snapshot MULTISELECT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="multiplechoiceresponse" > @@ -151,7 +145,6 @@ exports[`ProblemTypeSelect snapshot MULTISELECT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="choiceresponse" > @@ -167,7 +160,6 @@ exports[`ProblemTypeSelect snapshot MULTISELECT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="optionresponse" > @@ -183,7 +175,6 @@ exports[`ProblemTypeSelect snapshot MULTISELECT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="numericalresponse" > @@ -199,7 +190,6 @@ exports[`ProblemTypeSelect snapshot MULTISELECT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="stringresponse" > @@ -245,7 +235,6 @@ exports[`ProblemTypeSelect snapshot NUMERIC 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="multiplechoiceresponse" > @@ -261,7 +250,6 @@ exports[`ProblemTypeSelect snapshot NUMERIC 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="choiceresponse" > @@ -277,7 +265,6 @@ exports[`ProblemTypeSelect snapshot NUMERIC 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="optionresponse" > @@ -293,7 +280,6 @@ exports[`ProblemTypeSelect snapshot NUMERIC 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="numericalresponse" > @@ -309,7 +295,6 @@ exports[`ProblemTypeSelect snapshot NUMERIC 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="stringresponse" > @@ -355,7 +340,6 @@ exports[`ProblemTypeSelect snapshot SINGLESELECT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="multiplechoiceresponse" > @@ -371,7 +355,6 @@ exports[`ProblemTypeSelect snapshot SINGLESELECT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="choiceresponse" > @@ -387,7 +370,6 @@ exports[`ProblemTypeSelect snapshot SINGLESELECT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="optionresponse" > @@ -403,7 +385,6 @@ exports[`ProblemTypeSelect snapshot SINGLESELECT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="numericalresponse" > @@ -419,7 +400,6 @@ exports[`ProblemTypeSelect snapshot SINGLESELECT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="stringresponse" > @@ -465,7 +445,6 @@ exports[`ProblemTypeSelect snapshot TEXTINPUT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="multiplechoiceresponse" > @@ -481,7 +460,6 @@ exports[`ProblemTypeSelect snapshot TEXTINPUT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="choiceresponse" > @@ -497,7 +475,6 @@ exports[`ProblemTypeSelect snapshot TEXTINPUT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="optionresponse" > @@ -513,7 +490,6 @@ exports[`ProblemTypeSelect snapshot TEXTINPUT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="numericalresponse" > @@ -529,7 +505,6 @@ exports[`ProblemTypeSelect snapshot TEXTINPUT 1`] = ` isInvalid={false} onClick={[Function]} onFocus={[Function]} - showActiveBoxState={true} type="radio" value="stringresponse" > diff --git a/src/editors/sharedComponents/SelectableBox/FormCheckbox.jsx b/src/editors/sharedComponents/SelectableBox/FormCheckbox.jsx new file mode 100644 index 000000000..d2e1951ba --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/FormCheckbox.jsx @@ -0,0 +1,145 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { useCheckboxSetContext } from './FormCheckboxSetContext'; +import { FormGroupContextProvider, useFormGroupContext } from './FormGroupContext'; +import FormLabel from './FormLabel'; +import FormControlFeedback from './FormControlFeedback'; + +const CheckboxControl = React.forwardRef( + ({ isIndeterminate, ...props }, ref) => { + const { getCheckboxControlProps, hasCheckboxSetProvider } = useCheckboxSetContext(); + const defaultRef = React.useRef(); + const resolvedRef = ref || defaultRef; + const { getControlProps } = useFormGroupContext(); + let checkboxProps = getControlProps({ + ...props, + className: classNames('pgn__form-checkbox-input', props.className), + }); + + if (hasCheckboxSetProvider) { + checkboxProps = getCheckboxControlProps(checkboxProps); + } + + React.useEffect(() => { + // this if(resolvedRef.current) prevents console errors in testing + if (resolvedRef.current) { + resolvedRef.current.indeterminate = isIndeterminate; + } + }, [resolvedRef, isIndeterminate]); + + return ( + + ); + }, +); + +CheckboxControl.propTypes = { + /** Specifies whether the checkbox should be rendered in indeterminate state. */ + isIndeterminate: PropTypes.bool, + /** Specifies class name to append to the base element. */ + className: PropTypes.string, +}; + +CheckboxControl.defaultProps = { + isIndeterminate: false, + className: undefined, +}; + +const FormCheckbox = React.forwardRef(({ + children, + className, + controlClassName, + labelClassName, + description, + isInvalid, + isValid, + controlAs, + floatLabelLeft, + ...props +}, ref) => { + const { hasCheckboxSetProvider } = useCheckboxSetContext(); + const { hasFormGroupProvider, useSetIsControlGroupEffect, getControlProps } = useFormGroupContext(); + useSetIsControlGroupEffect(true); + const shouldActAsGroup = hasFormGroupProvider && !hasCheckboxSetProvider; + const groupProps = shouldActAsGroup ? { + ...getControlProps({}), + role: 'group', + } : {}; + + const control = React.createElement(controlAs, { ...props, className: controlClassName, ref }); + return ( + +
+ {control} +
+ + {children} + + {description && ( + + {description} + + )} +
+
+
+ ); +}); + +FormCheckbox.propTypes = { + /** Specifies id of the FormCheckbox component. */ + id: PropTypes.string, + /** Specifies contents of the component. */ + children: PropTypes.node.isRequired, + /** Specifies class name to append to the base element. */ + className: PropTypes.string, + /** Specifies class name for control component. */ + controlClassName: PropTypes.string, + /** Specifies class name for label component. */ + labelClassName: PropTypes.string, + /** Specifies description to show under the checkbox. */ + description: PropTypes.node, + /** Specifies whether to display checkbox in invalid state, this affects styling. */ + isInvalid: PropTypes.bool, + /** Specifies whether to display checkbox in valid state, this affects styling. */ + isValid: PropTypes.bool, + /** Specifies control element. */ + controlAs: PropTypes.elementType, + /** Specifies whether the floating label should be aligned to the left. */ + floatLabelLeft: PropTypes.bool, + /** Specifies whether the `FormCheckbox` is disabled. */ + disabled: PropTypes.bool, +}; + +FormCheckbox.defaultProps = { + id: undefined, + className: undefined, + controlClassName: undefined, + labelClassName: undefined, + description: undefined, + isInvalid: false, + isValid: false, + controlAs: CheckboxControl, + floatLabelLeft: false, + disabled: false, +}; + +export { CheckboxControl }; +export default FormCheckbox; diff --git a/src/editors/sharedComponents/SelectableBox/FormCheckboxSet.jsx b/src/editors/sharedComponents/SelectableBox/FormCheckboxSet.jsx new file mode 100644 index 000000000..2d90cada0 --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/FormCheckboxSet.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useFormGroupContext } from './FormGroupContext'; +import { FormCheckboxSetContextProvider } from './FormCheckboxSetContext'; +import FormControlSet from './FormControlSet'; + +const FormCheckboxSet = ({ + children, + name, + value, + defaultValue, + isInline, + onChange, + onFocus, + onBlur, + ...props +}) => { + const { getControlProps, useSetIsControlGroupEffect } = useFormGroupContext(); + useSetIsControlGroupEffect(true); + const controlProps = getControlProps(props); + return ( + + + {children} + + + ); +}; + +FormCheckboxSet.propTypes = { + /** Specifies contents of the component. */ + children: PropTypes.node.isRequired, + /** Specifies class name to append to the base element. */ + className: PropTypes.string, + /** Specifies name for the component. */ + name: PropTypes.string.isRequired, + /** Specifies values for the checkboxes. */ + value: PropTypes.arrayOf(PropTypes.string), + /** Specifies default values for the checkboxes. */ + defaultValue: PropTypes.arrayOf(PropTypes.string), + /** Specifies whether to display components with inline styling. */ + isInline: PropTypes.bool, + /** Specifies onChange event handler. */ + onChange: PropTypes.func, + /** Specifies onFocus event handler. */ + onFocus: PropTypes.func, + /** Specifies onBlur event handler. */ + onBlur: PropTypes.func, +}; + +FormCheckboxSet.defaultProps = { + className: undefined, + value: undefined, + defaultValue: undefined, + isInline: false, + onChange: undefined, + onFocus: undefined, + onBlur: undefined, +}; + +export default FormCheckboxSet; diff --git a/src/editors/sharedComponents/SelectableBox/FormCheckboxSetContext.jsx b/src/editors/sharedComponents/SelectableBox/FormCheckboxSetContext.jsx index 2cd951d5c..1dd45b524 100644 --- a/src/editors/sharedComponents/SelectableBox/FormCheckboxSetContext.jsx +++ b/src/editors/sharedComponents/SelectableBox/FormCheckboxSetContext.jsx @@ -1,14 +1,6 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; - -const callAllHandlers = (...handlers) => { - const unifiedEventHandler = (event) => { - handlers - .filter(handler => typeof handler === 'function') - .forEach(handler => handler(event)); - }; - return unifiedEventHandler; -}; +import { callAllHandlers } from './fieldUtils'; const identityFn = props => props; diff --git a/src/editors/sharedComponents/SelectableBox/FormControlFeedback.jsx b/src/editors/sharedComponents/SelectableBox/FormControlFeedback.jsx new file mode 100644 index 000000000..b4e211218 --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/FormControlFeedback.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { useFormGroupContext } from './FormGroupContext'; +import FormText, { resolveTextType } from './FormText'; + +const FormControlFeedback = ({ children, ...props }) => { + const { getDescriptorProps, isInvalid, isValid } = useFormGroupContext(); + const descriptorProps = getDescriptorProps(props); + const className = classNames('pgn__form-control-description', props.className); + const textType = props.type || resolveTextType({ isInvalid, isValid }); + return ( + + {children} + + ); +}; + +const FEEDBACK_TYPES = [ + 'default', + 'valid', + 'invalid', + 'warning', + 'criteria-empty', + 'criteria-valid', + 'criteria-invalid', +]; + +FormControlFeedback.propTypes = { + /** Specifies contents of the component. */ + children: PropTypes.node.isRequired, + /** Specifies class name to append to the base element. */ + className: PropTypes.string, + /** Specifies whether to show an icon next to the text. */ + hasIcon: PropTypes.bool, + /** Specifies feedback type, this affects styling. */ + type: PropTypes.oneOf(FEEDBACK_TYPES), + /** Specifies icon to show, will only be shown if `hasIcon` prop is set to `true`. */ + icon: PropTypes.node, + /** Specifies whether to show feedback with muted styling. */ + muted: PropTypes.bool, +}; + +FormControlFeedback.defaultProps = { + hasIcon: true, + type: undefined, + icon: undefined, + className: undefined, + muted: false, +}; + +export default FormControlFeedback; diff --git a/src/editors/sharedComponents/SelectableBox/FormControlSet.jsx b/src/editors/sharedComponents/SelectableBox/FormControlSet.jsx new file mode 100644 index 000000000..69052e767 --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/FormControlSet.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +const FormControlSet = ({ + as, + className, + isInline, + children, + ...props +}) => React.createElement(as, { + className: classNames( + className, + { + 'pgn__form-control-set': !isInline, + 'pgn__form-control-set-inline': isInline, + }, + ), + ...props, +}, children); + +FormControlSet.propTypes = { + /** Specifies the base element */ + as: PropTypes.elementType, + /** A class name to append to the base element. */ + className: PropTypes.string, + /** Specifies whether the component should be displayed with inline styling. */ + isInline: PropTypes.bool, + /** Specifies contents of the component. */ + children: PropTypes.node, +}; + +FormControlSet.defaultProps = { + as: 'div', + className: undefined, + isInline: false, + children: null, +}; + +export default FormControlSet; diff --git a/src/editors/sharedComponents/SelectableBox/FormGroupContext.jsx b/src/editors/sharedComponents/SelectableBox/FormGroupContext.jsx new file mode 100644 index 000000000..af38a4f8a --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/FormGroupContext.jsx @@ -0,0 +1,121 @@ +import React, { + useState, useEffect, useMemo, useCallback, +} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import newId from './newId'; +import { useIdList, omitUndefinedProperties } from './fieldUtils'; +import { FORM_CONTROL_SIZES } from './constants'; + +const identityFn = props => props; +const noop = () => {}; + +const FormGroupContext = React.createContext({ + getControlProps: identityFn, + useSetIsControlGroupEffect: noop, + getLabelProps: identityFn, + getDescriptorProps: identityFn, + hasFormGroupProvider: false, +}); + +const useFormGroupContext = () => React.useContext(FormGroupContext); + +const useStateEffect = (initialState) => { + const [state, setState] = useState(initialState); + const useSetStateEffect = (newState) => { + useEffect(() => setState(newState), [newState]); + }; + return [state, useSetStateEffect]; +}; + +const FormGroupContextProvider = ({ + children, + controlId: explicitControlId, + isInvalid, + isValid, + size, +}) => { + const controlId = useMemo(() => explicitControlId || newId('form-field'), [explicitControlId]); + const [describedByIds, registerDescriptorId] = useIdList(controlId); + const [labelledByIds, registerLabelerId] = useIdList(controlId); + const [isControlGroup, useSetIsControlGroupEffect] = useStateEffect(false); + + const getControlProps = useCallback((controlProps) => { + // labelledByIds from the list above should only be added to a control + // if it the control is a group. We prefer adding a condition here because: + // - Hooks cannot be called inside conditionals + // - The getLabelProps function below is forced to generate an id + // whether it is needed or not. + // - This is what allows consumers of Paragon to use + // interchangeably between ControlGroup type controls and regular Controls + const labelledByIdsForControl = isControlGroup ? labelledByIds : undefined; + return omitUndefinedProperties({ + ...controlProps, + 'aria-describedby': classNames(controlProps['aria-describedby'], describedByIds) || undefined, + 'aria-labelledby': classNames(controlProps['aria-labelledby'], labelledByIdsForControl) || undefined, + id: controlId, + }); + }, [ + isControlGroup, + describedByIds, + labelledByIds, + controlId, + ]); + + const getLabelProps = (labelProps) => { + const id = registerLabelerId(labelProps?.id); + if (isControlGroup) { + return { ...labelProps, id }; + } + return { ...labelProps, htmlFor: controlId }; + }; + + const getDescriptorProps = (descriptorProps) => { + const id = registerDescriptorId(descriptorProps?.id); + return { ...descriptorProps, id }; + }; + + // eslint-disable-next-line react/jsx-no-constructed-context-values + const contextValue = { + getControlProps, + getLabelProps, + getDescriptorProps, + useSetIsControlGroupEffect, + isControlGroup, + controlId, + isInvalid, + isValid, + size, + hasFormGroupProvider: true, + }; + + return ( + + {children} + + ); +}; + +FormGroupContextProvider.propTypes = { + children: PropTypes.node.isRequired, + controlId: PropTypes.string, + isInvalid: PropTypes.bool, + isValid: PropTypes.bool, + size: PropTypes.oneOf([ + FORM_CONTROL_SIZES.SMALL, + FORM_CONTROL_SIZES.LARGE, + ]), +}; + +FormGroupContextProvider.defaultProps = { + controlId: undefined, + isInvalid: undefined, + isValid: undefined, + size: undefined, +}; + +export { + FormGroupContext, + FormGroupContextProvider, + useFormGroupContext, +}; diff --git a/src/editors/sharedComponents/SelectableBox/FormLabel.jsx b/src/editors/sharedComponents/SelectableBox/FormLabel.jsx new file mode 100644 index 000000000..7f45bb9bc --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/FormLabel.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { useFormGroupContext } from './FormGroupContext'; +import { FORM_CONTROL_SIZES } from './constants'; + +const FormLabel = ({ children, isInline, ...props }) => { + const { size, isControlGroup, getLabelProps } = useFormGroupContext(); + const className = classNames( + 'pgn__form-label', + { + 'pgn__form-label-inline': isInline, + 'pgn__form-label-lg': size === FORM_CONTROL_SIZES.LARGE, + 'pgn__form-label-sm': size === FORM_CONTROL_SIZES.SMALL, + }, + props.className, + ); + const labelProps = getLabelProps({ ...props, className }); + const componentType = isControlGroup ? 'p' : 'label'; + return React.createElement(componentType, labelProps, children); +}; + +const SIZE_CHOICES = ['sm', 'lg']; + +FormLabel.propTypes = { + /** Specifies class name to append to the base element. */ + className: PropTypes.string, + /** Specifies contents of the component. */ + children: PropTypes.node.isRequired, + /** Specifies whether the component should be displayed with inline styling. */ + isInline: PropTypes.bool, + /** Specifies size of the component. */ + size: PropTypes.oneOf(SIZE_CHOICES), +}; + +FormLabel.defaultProps = { + isInline: false, + size: undefined, + className: undefined, +}; + +export default FormLabel; diff --git a/src/editors/sharedComponents/SelectableBox/FormRadio.jsx b/src/editors/sharedComponents/SelectableBox/FormRadio.jsx new file mode 100644 index 000000000..43e4133d6 --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/FormRadio.jsx @@ -0,0 +1,110 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { useRadioSetContext } from './FormRadioSetContext'; +import { FormGroupContextProvider, useFormGroupContext } from './FormGroupContext'; +import FormLabel from './FormLabel'; +import FormControlFeedback from './FormControlFeedback'; + +const RadioControl = React.forwardRef((props, ref) => { + const { getControlProps } = useFormGroupContext(); + const { getRadioControlProps, hasRadioSetProvider } = useRadioSetContext(); + let radioProps = getControlProps({ + ...props, + className: classNames('pgn__form-radio-input', props.className), + }); + + if (hasRadioSetProvider) { + radioProps = getRadioControlProps(radioProps); + } + + const onChange = (...args) => { + if (radioProps.onChange) { + radioProps.onChange(...args); + } + }; + + return ( + + ); +}); + +RadioControl.propTypes = { + className: PropTypes.string, +}; + +RadioControl.defaultProps = { + className: undefined, +}; + +const FormRadio = React.forwardRef(({ + children, + className, + controlClassName, + labelClassName, + description, + isInvalid, + isValid, + ...props +}, ref) => ( + +
+ +
+ + {children} + + {description && ( + + {description} + + )} +
+
+
+)); + +FormRadio.propTypes = { + /** Specifies id of the FormRadio component. */ + id: PropTypes.string, + /** Specifies contents of the component. */ + children: PropTypes.node.isRequired, + /** Specifies class name to append to the base element. */ + className: PropTypes.string, + /** Specifies class name for control component. */ + controlClassName: PropTypes.string, + /** Specifies class name for label component. */ + labelClassName: PropTypes.string, + /** Specifies description to show under the radio's value. */ + description: PropTypes.node, + /** Specifies whether to display component in invalid state, this affects styling. */ + isInvalid: PropTypes.bool, + /** Specifies whether to display component in valid state, this affects styling. */ + isValid: PropTypes.bool, + /** Specifies whether the `FormRadio` is disabled. */ + disabled: PropTypes.bool, +}; + +FormRadio.defaultProps = { + id: undefined, + className: undefined, + controlClassName: undefined, + labelClassName: undefined, + description: undefined, + isInvalid: false, + isValid: false, + disabled: false, +}; + +export { RadioControl }; +export default FormRadio; diff --git a/src/editors/sharedComponents/SelectableBox/FormRadioSet.jsx b/src/editors/sharedComponents/SelectableBox/FormRadioSet.jsx new file mode 100644 index 000000000..05524ba20 --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/FormRadioSet.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useFormGroupContext } from './FormGroupContext'; +import { FormRadioSetContextProvider } from './FormRadioSetContext'; +import FormControlSet from './FormControlSet'; + +const FormRadioSet = ({ + children, + name, + value, + defaultValue, + isInline, + onChange, + onFocus, + onBlur, + ...props +}) => { + const { getControlProps, useSetIsControlGroupEffect } = useFormGroupContext(); + useSetIsControlGroupEffect(true); + const controlProps = getControlProps(props); + return ( + + + {children} + + + ); +}; + +FormRadioSet.propTypes = { + /** Specifies contents of the component. */ + children: PropTypes.node.isRequired, + /** A class name to append to the base element. */ + className: PropTypes.string, + /** Specifies name for the component. */ + name: PropTypes.string.isRequired, + /** Specifies values for the FormRadioSet. */ + value: PropTypes.string, + /** Specifies default values. */ + defaultValue: PropTypes.string, + /** Specifies whether the component should be displayed with inline styling. */ + isInline: PropTypes.bool, + /** Specifies onChange event handler. */ + onChange: PropTypes.func, + /** Specifies onFocus event handler. */ + onFocus: PropTypes.func, + /** Specifies onBlur event handler. */ + onBlur: PropTypes.func, +}; + +FormRadioSet.defaultProps = { + className: undefined, + value: undefined, + defaultValue: undefined, + isInline: false, + onChange: undefined, + onFocus: undefined, + onBlur: undefined, +}; + +export default FormRadioSet; diff --git a/src/editors/sharedComponents/SelectableBox/FormRadioSetContext.jsx b/src/editors/sharedComponents/SelectableBox/FormRadioSetContext.jsx index 99642c974..41716cadd 100644 --- a/src/editors/sharedComponents/SelectableBox/FormRadioSetContext.jsx +++ b/src/editors/sharedComponents/SelectableBox/FormRadioSetContext.jsx @@ -1,14 +1,6 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; - -const callAllHandlers = (...handlers) => { - const unifiedEventHandler = (event) => { - handlers - .filter(handler => typeof handler === 'function') - .forEach(handler => handler(event)); - }; - return unifiedEventHandler; -}; +import { callAllHandlers } from './fieldUtils'; const identityFn = props => props; @@ -28,6 +20,9 @@ const FormRadioSetContextProvider = ({ value, defaultValue, }) => { + const handleChange = (...args) => { + onChange(...args); + }; const isControlled = !defaultValue && value !== undefined; const getRadioControlProps = (radioProps) => ({ ...radioProps, @@ -37,7 +32,7 @@ const FormRadioSetContextProvider = ({ /* istanbul ignore next */ onFocus: radioProps.onFocus ? callAllHandlers(onFocus, radioProps.onFocus) : onFocus, /* istanbul ignore next */ - onChange: radioProps.onChange ? callAllHandlers(onChange, radioProps.onChange) : onChange, + onChange: radioProps.onChange ? callAllHandlers(handleChange, radioProps.onChange) : onChange, checked: isControlled ? value === radioProps.value : undefined, defaultChecked: isControlled ? undefined : defaultValue === radioProps.value, }); diff --git a/src/editors/sharedComponents/SelectableBox/FormText.jsx b/src/editors/sharedComponents/SelectableBox/FormText.jsx new file mode 100644 index 000000000..f6469a151 --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/FormText.jsx @@ -0,0 +1,115 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +// import Icon from '../Icon'; +// import { +// Check, Close, Cancel, CheckCircle, RadioButtonUnchecked, WarningFilled, +// } from '../../icons'; + +import { FORM_TEXT_TYPES } from './constants'; + +// const FORM_TEXT_ICONS = { +// [FORM_TEXT_TYPES.DEFAULT]: null, +// [FORM_TEXT_TYPES.VALID]: Check, +// [FORM_TEXT_TYPES.INVALID]: Close, +// [FORM_TEXT_TYPES.WARNING]: WarningFilled, +// [FORM_TEXT_TYPES.CRITERIA_EMPTY]: RadioButtonUnchecked, +// [FORM_TEXT_TYPES.CRITERIA_VALID]: CheckCircle, +// [FORM_TEXT_TYPES.CRITERIA_INVALID]: Cancel, +// }; + +const resolveTextType = ({ isInvalid, isValid }) => { + if (isValid) { + return FORM_TEXT_TYPES.VALID; + } + if (isInvalid) { + return FORM_TEXT_TYPES.INVALID; + } + return FORM_TEXT_TYPES.DEFAULT; +}; + +// const FormTextIcon = ({ type, customIcon }) => { +// if (customIcon) { +// return customIcon; +// } + +// const typeIcon = FORM_TEXT_ICONS[type]; +// if (typeIcon) { +// return ; +// } + +// return null; +// }; + +// FormTextIcon.propTypes = { +// type: PropTypes.oneOf(Object.values(FORM_TEXT_TYPES)), +// customIcon: PropTypes.node, +// }; + +// FormTextIcon.defaultProps = { +// type: undefined, +// customIcon: undefined, +// }; + +const FormText = ({ + children, type, icon, muted, hasIcon, ...props +}) => { + const className = classNames( + props.className, + 'pgn__form-text', + `pgn__form-text-${type}`, + { + 'text-muted': muted, + }, + ); + + return ( +
+ {/* {hasIcon && } */} +
+ {children} +
+
+ ); +}; + +const FORM_TEXT_TYPE_CHOICES = [ + 'default', + 'valid', + 'invalid', + 'warning', + 'criteria-empty', + 'criteria-valid', + 'criteria-invalid', +]; + +FormText.propTypes = { + /** Specifies contents of the component. */ + children: PropTypes.node.isRequired, + /** Specifies class name to append to the base element. */ + className: PropTypes.string, + /** Specifies whether to show an icon next to the text. */ + hasIcon: PropTypes.bool, + /** Specifies text type, this affects styling. */ + type: PropTypes.oneOf(FORM_TEXT_TYPE_CHOICES), + /** Specifies icon to show, will only be shown if `hasIcon` prop is set to `true`. */ + icon: PropTypes.node, + /** Specifies whether to show text with muted styling. */ + muted: PropTypes.bool, +}; + +FormText.defaultProps = { + hasIcon: true, + type: 'default', + icon: undefined, + className: undefined, + muted: false, +}; + +export default FormText; +export { + FORM_TEXT_TYPES, + // FORM_TEXT_ICONS, + // FormTextIcon, + resolveTextType, +}; diff --git a/src/editors/sharedComponents/SelectableBox/README.md b/src/editors/sharedComponents/SelectableBox/README.md new file mode 100644 index 000000000..4ab1f417d --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/README.md @@ -0,0 +1,215 @@ +--- +title: 'SelectableBox' +type: 'component' +components: +- SelectableBox +- SelectableBoxSet +categories: +- Forms +- Content +status: 'New' +designStatus: 'Done' +devStatus: 'In progress' +notes: | +--- + +A box that has selection states. It can be used as an alternative to a radio button or checkbox set. + +The ``SelectableBox`` can contain any kind of content as long as it is not clickable. In other words, there should be no clickable targets distinct from selection. + +## Basic Usage + +As ``Checkbox`` + +```jsx live +() => { + const type = 'checkbox'; + const allCheeseOptions = ['swiss', 'cheddar', 'pepperjack']; + const [checkedCheeses, { add, remove, set, clear }] = useCheckboxSetValues(['swiss']); + + const handleChange = e => { + e.target.checked ? add(e.target.value) : remove(e.target.value); + }; + + const isInvalid = () => checkedCheeses.includes('swiss'); + const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth }); + + return ( +
+ + +
+

It is my first SelectableBox

+

Swiss

+
+
+ + Cheddar + + +

Pepperjack

+
+
+
+ ); +} +``` + +## As Radio + +```jsx live +() => { + const type = 'radio'; + const [value, setValue] = useState('green'); + const handleChange = e => setValue(e.target.value); + const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth }); + + return ( + + +
+

It is Red color

+

Select me

+
+
+ +

Green

+

Leaves and grass

+
+ +

Blue

+

The sky

+
+
+ ); +} +``` +## As Checkbox +As ``Checkbox`` with ``isIndeterminate`` + +```jsx live +() => { + const type = 'checkbox'; + const allCheeseOptions = ['swiss', 'cheddar', 'pepperjack']; + const [checkedCheeses, { add, remove, set, clear }] = useCheckboxSetValues(['swiss']); + + const allChecked = allCheeseOptions.every(value => checkedCheeses.includes(value)); + const someChecked = allCheeseOptions.some(value => checkedCheeses.includes(value)); + const isIndeterminate = someChecked && !allChecked; + const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.small.maxWidth }); + + const handleChange = e => { + e.target.checked ? add(e.target.value) : remove(e.target.value); + }; + + const handleCheckAllChange = ({ checked }) => { + checked ? set(allCheeseOptions) : clear(); + }; + + return ( + <> +
+ + All the cheese + +
+ + +
+

It is my first SelectableBox

+

Swiss

+
+
+ + Cheddar + + +

Pepperjack

+
+
+ + ); +} +``` + +As ``Checkbox`` with ``ariaLabelledby`` + +```jsx live +() => { + const type = 'checkbox'; + const allCheeseOptions = ['swiss', 'cheddar', 'pepperjack']; + const [checkedCheeses, { add, remove, set, clear }] = useCheckboxSetValues(['swiss']); + + const handleChange = e => { + e.target.checked ? add(e.target.value) : remove(e.target.value); + }; + + const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth }); + + return ( +
+

+ Select your favorite cheese +

+ + +

+ Swiss +

+
+ +

+ Cheddar +

+
+ +

+ Pepperjack +

+
+
+
+ ); +} +``` diff --git a/src/editors/sharedComponents/SelectableBox/SelectableBoxSet.jsx b/src/editors/sharedComponents/SelectableBox/SelectableBoxSet.jsx index d8523c161..a96cccab5 100644 --- a/src/editors/sharedComponents/SelectableBox/SelectableBoxSet.jsx +++ b/src/editors/sharedComponents/SelectableBox/SelectableBoxSet.jsx @@ -1,8 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { getInputType } from './getInputType'; -import { FormRadioSetContextProvider } from './FormRadioSetContext'; +import { getInputType } from './utils'; +// import { requiredWhenNot } from '../utils/propTypes'; const INPUT_TYPES = [ 'radio', @@ -26,7 +26,7 @@ const SelectableBoxSet = React.forwardRef(({ }, ref) => { const inputType = getInputType('SelectableBoxSet', type); - const selectableBoxElement = React.createElement( + return React.createElement( inputType, { name, @@ -45,22 +45,6 @@ const SelectableBoxSet = React.forwardRef(({ }, children, ); - - return type === 'radio' ? ( - - { - selectableBoxElement - } - - ) : selectableBoxElement; }); SelectableBoxSet.propTypes = { diff --git a/src/editors/sharedComponents/SelectableBox/_variables.scss b/src/editors/sharedComponents/SelectableBox/_variables.scss new file mode 100644 index 000000000..b7d4e114d --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/_variables.scss @@ -0,0 +1,5 @@ +$selectable-box-padding: 1rem !default; +$selectable-box-border-radius: .25rem !default; +$selectable-box-space: .75rem !default; +$min-cols-number: 1 !default; +$max-cols-number: 12 !default; diff --git a/src/editors/sharedComponents/SelectableBox/constants.js b/src/editors/sharedComponents/SelectableBox/constants.js new file mode 100644 index 000000000..68abdda93 --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/constants.js @@ -0,0 +1,17 @@ +/* eslint-disable import/prefer-default-export */ +const FORM_CONTROL_SIZES = { + SMALL: 'sm', + LARGE: 'lg', +}; + +const FORM_TEXT_TYPES = { + DEFAULT: 'default', + VALID: 'valid', + INVALID: 'invalid', + WARNING: 'warning', + CRITERIA_EMPTY: 'criteria-empty', + CRITERIA_VALID: 'criteria-valid', + CRITERIA_INVALID: 'criteria-invalid', +}; + +export { FORM_CONTROL_SIZES, FORM_TEXT_TYPES }; diff --git a/src/editors/sharedComponents/SelectableBox/fieldUtils.js b/src/editors/sharedComponents/SelectableBox/fieldUtils.js new file mode 100644 index 000000000..5c0d980a1 --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/fieldUtils.js @@ -0,0 +1,70 @@ +import classNames from 'classnames'; +import { useState, useEffect } from 'react'; +import newId from './newId'; + +const omitUndefinedProperties = (obj = {}) => Object.entries(obj) + .reduce((acc, [key, value]) => { + if (value !== undefined) { + acc[key] = value; + } + return acc; + }, {}); + +const callAllHandlers = (...handlers) => { + const unifiedEventHandler = (event) => { + handlers + .filter(handler => typeof handler === 'function') + .forEach(handler => handler(event)); + }; + return unifiedEventHandler; +}; + +const useHasValue = ({ defaultValue, value }) => { + const [hasUncontrolledValue, setHasUncontrolledValue] = useState(!!defaultValue || defaultValue === 0); + const hasValue = !!value || value === 0 || hasUncontrolledValue; + const handleInputEvent = (e) => setHasUncontrolledValue(e.target.value); + return [hasValue, handleInputEvent]; +}; + +const useIdList = (uniqueIdPrefix, initialList) => { + const [idList, setIdList] = useState(initialList || []); + const addId = (idToAdd) => { + setIdList(oldIdList => [...oldIdList, idToAdd]); + return idToAdd; + }; + const getNewId = () => { + const idToAdd = newId(`${uniqueIdPrefix}-`); + return addId(idToAdd); + }; + const removeId = (idToRemove) => { + setIdList(oldIdList => oldIdList.filter(id => id !== idToRemove)); + }; + + const useRegisteredId = (explicitlyRegisteredId) => { + const [registeredId, setRegisteredId] = useState(explicitlyRegisteredId); + useEffect(() => { + if (explicitlyRegisteredId) { + addId(explicitlyRegisteredId); + } else if (!registeredId) { + setRegisteredId(getNewId(uniqueIdPrefix)); + } + return () => removeId(registeredId); + }, [registeredId, explicitlyRegisteredId]); + return registeredId; + }; + + return [idList, useRegisteredId]; +}; + +const mergeAttributeValues = (...values) => { + const mergedValues = classNames(values); + return mergedValues || undefined; +}; + +export { + callAllHandlers, + useHasValue, + mergeAttributeValues, + useIdList, + omitUndefinedProperties, +}; diff --git a/src/editors/sharedComponents/SelectableBox/index.jsx b/src/editors/sharedComponents/SelectableBox/index.jsx index 3bebc6d77..40da72439 100644 --- a/src/editors/sharedComponents/SelectableBox/index.jsx +++ b/src/editors/sharedComponents/SelectableBox/index.jsx @@ -1,10 +1,10 @@ -import React, { useRef, useEffect, useContext } from 'react'; +import React, { useRef, useEffect } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import SelectableBoxSet from './SelectableBoxSet'; -import { getInputType } from './getInputType'; -import FormCheckboxSetContext from './FormCheckboxSetContext'; -import FormRadioSetContext from './FormRadioSetContext'; +import { useCheckboxSetContext } from './FormCheckboxSetContext'; +import { useRadioSetContext } from './FormRadioSetContext'; +import { getInputType } from './utils'; const INPUT_TYPES = [ 'radio', @@ -22,12 +22,11 @@ const SelectableBox = React.forwardRef(({ onFocus, inputHidden, className, - showActiveBoxState, ...props }, ref) => { const inputType = getInputType('SelectableBox', type); - const { value: radioValue } = useContext(FormRadioSetContext); - const { value: checkboxValues = [] } = useContext(FormCheckboxSetContext); + const { value: radioValue } = useRadioSetContext(); + const { value: checkboxValues = [] } = useCheckboxSetContext(); const isChecked = () => { switch (type) { @@ -64,7 +63,7 @@ const SelectableBox = React.forwardRef(({ onClick={() => inputRef.current.click()} onFocus={onFocus} className={classNames('pgn__selectable_box', className, { - 'pgn__selectable_box-active': (!inputHidden && !showActiveBoxState) ? false : isChecked() || checked, + 'pgn__selectable_box-active': isChecked() || checked, 'pgn__selectable_box-invalid': isInvalid, })} tabIndex={0} @@ -98,8 +97,6 @@ SelectableBox.propTypes = { isInvalid: PropTypes.bool, /** A class that is appended to the base element. */ className: PropTypes.string, - /** Controls the visibility of the active state for the `SelectableBox`. */ - showActiveBoxState: PropTypes.bool, }; SelectableBox.defaultProps = { @@ -112,7 +109,6 @@ SelectableBox.defaultProps = { isIndeterminate: false, isInvalid: false, className: undefined, - showActiveBoxState: true, }; SelectableBox.Set = SelectableBoxSet; diff --git a/src/editors/sharedComponents/SelectableBox/index.scss b/src/editors/sharedComponents/SelectableBox/index.scss new file mode 100644 index 000000000..1038286af --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/index.scss @@ -0,0 +1,54 @@ +@import "variables"; + +.pgn__selectable_box-set { + display: grid; + grid-auto-rows: 1fr; + grid-gap: $selectable-box-space; + + @for $i from $min-cols-number through $max-cols-number { + &.pgn__selectable_box-set--#{$i} { + grid-template-columns: repeat(#{$i}, 1fr); + } + } + + & > * + * { + margin: 0; + } +} + +.pgn__selectable_box { + position: relative; + height: 100%; + padding: $selectable-box-padding; + box-shadow: $level-1-box-shadow; + border-radius: $selectable-box-border-radius; + text-align: start; + background: $white; + + &:focus-visible { + outline: 1px solid $primary-700; + } + + .pgn__form-radio, + .pgn__form-checkbox { + position: absolute; + top: $selectable-box-padding; + inset-inline-end: $selectable-box-padding; + + input { + margin-inline-end: 0; + } + } + + * { + pointer-events: none; + } +} + +.pgn__selectable_box-active { + outline: 2px solid $primary-500; +} + +.pgn__selectable_box-invalid { + outline: 2px solid $danger-300; +} diff --git a/src/editors/sharedComponents/SelectableBox/newId.js b/src/editors/sharedComponents/SelectableBox/newId.js new file mode 100644 index 000000000..9055c9e31 --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/newId.js @@ -0,0 +1,8 @@ +let lastId = 0; + +const newId = (prefix = 'id') => { + lastId += 1; + return `${prefix}${lastId}`; +}; + +export default newId; diff --git a/src/editors/sharedComponents/SelectableBox/tests/SelectableBox.test.jsx b/src/editors/sharedComponents/SelectableBox/tests/SelectableBox.test.jsx new file mode 100644 index 000000000..226c5a9f8 --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/tests/SelectableBox.test.jsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import renderer from 'react-test-renderer'; + +import SelectableBox from '..'; + +const checkboxType = 'checkbox'; +const checkboxText = 'SelectableCheckbox'; + +const radioType = 'radio'; +const radioText = 'SelectableRadio'; + +const SelectableCheckbox = (props) => {checkboxText}; + +const SelectableRadio = (props) => {radioText}; + +describe('', () => { + describe('correct rendering', () => { + it('renders without props', () => { + const tree = renderer.create(( + SelectableBox + )).toJSON(); + expect(tree).toMatchSnapshot(); + }); + it('correct render when type prop is changed', () => { + const { rerender } = render(); + const checkboxControl = screen.getByText(radioText); + expect(checkboxControl).toBeTruthy(); + rerender(); + const radioControl = screen.getByText(radioText); + expect(radioControl).toBeTruthy(); + }); + it('renders with radio input type if neither checkbox nor radio is passed', () => { + // Mock the `console.error` is intentional because an invalid `type` prop + // with `wrongType` specified for `ForwardRef` expects one of the ['radio','flag'] parameters. + // eslint-disable-next-line no-console + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + const selectableBox = screen.getByRole('button'); + expect(selectableBox).toBeTruthy(); + consoleErrorSpy.mockRestore(); + }); + it('renders with checkbox input type', () => { + render(); + const selectableBox = screen.getByRole('button'); + expect(selectableBox).toBeTruthy(); + }); + it('renders with radio input type', () => { + render(); + const selectableBox = screen.getByRole('button'); + expect(selectableBox).toBeTruthy(); + }); + it('renders with correct children', () => { + render(); + const selectableBox = screen.getByText(radioText); + expect(selectableBox).toBeTruthy(); + }); + it('renders with correct class', () => { + const className = 'myClass'; + render(); + const selectableBox = screen.getByRole('button'); + expect(selectableBox.classList.contains(className)).toEqual(true); + }); + it('renders as active when checked is passed', () => { + render(); + const selectableBox = screen.getByRole('button'); + const inputElement = screen.getByRole('radio', { hidden: true }); + expect(selectableBox.classList.contains('pgn__selectable_box-active')).toEqual(true); + expect(inputElement.checked).toEqual(true); + }); + it('renders as invalid when isInvalid is passed', () => { + render(); + const selectableBox = screen.getByRole('button'); + expect(selectableBox.classList.contains('pgn__selectable_box-invalid')).toEqual(true); + }); + it('renders with on click event when onClick is passed', async () => { + const onClickSpy = jest.fn(); + render(); + const selectableBox = screen.getByRole('button'); + await userEvent.click(selectableBox); + expect(onClickSpy).toHaveBeenCalledTimes(1); + }); + it('renders with on key press event when onClick is passed', async () => { + const onClickSpy = jest.fn(); + render(); + const selectableBox = screen.getByRole('button'); + selectableBox.focus(); + await userEvent.keyboard('{enter}'); + expect(onClickSpy).toHaveBeenCalledTimes(1); + }); + it('renders with hidden input when inputHidden is passed', () => { + const { rerender } = render(); + const inputElement = screen.getByRole('checkbox', { hidden: true }); + expect(inputElement.getAttribute('hidden')).toEqual(''); + rerender(); + expect(inputElement.getAttribute('hidden')).toBeNull(); + }); + }); + describe('correct interactions', () => { + it('correct checkbox state change when checked is changed', () => { + const { rerender } = render(); + const checkbox = screen.getByRole('button'); + expect(checkbox.className).not.toContain('pgn__selectable_box-active'); + rerender(); + expect(checkbox.className).toContain('pgn__selectable_box-active'); + }); + it('correct radio state change when checked is changed', () => { + const { rerender } = render(); + const radio = screen.getByRole('button'); + expect(radio.className).toContain('pgn__selectable_box-active'); + rerender(); + expect(radio.className).toContain('pgn__selectable_box-active'); + }); + it('ref is passed to onClick function', () => { + let inputRef; + const onClick = (ref) => { inputRef = ref; }; + render(); + const radio = screen.getByRole('button'); + userEvent.click(radio); + expect(inputRef).not.toBeFalsy(); + }); + }); +}); diff --git a/src/editors/sharedComponents/SelectableBox/tests/SelectableBoxSet.test.jsx b/src/editors/sharedComponents/SelectableBox/tests/SelectableBoxSet.test.jsx new file mode 100644 index 000000000..cf3db6f3c --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/tests/SelectableBoxSet.test.jsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import userEvent from '@testing-library/user-event'; +import SelectableBox from '..'; + +const boxText = (text) => `SelectableBox${text}`; + +const checkboxType = 'checkbox'; +const checkboxText = (text) => `SelectableCheckbox${text}`; + +const radioType = 'radio'; +const radioText = (text) => `SelectableRadio${text}`; + +const ariaLabel = 'test-default-label'; + +const SelectableBoxSet = (props) => ( + + {boxText(1)} + {boxText(2)} + {boxText(3)} + +); + +const SelectableCheckboxSet = (props) => ( + + {checkboxText(1)} + {checkboxText(2)} + {checkboxText(3)} + +); + +const SelectableRadioSet = (props) => ( + + {radioText(1)} + {radioText(2)} + {radioText(3)} + +); + +describe('', () => { + describe('correct rendering', () => { + it('renders without props', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + it('forwards props', () => { + render(()); + expect(screen.getByTestId('test-radio-set-name')).toBeInTheDocument(); + }); + it('correct render when type prop is changed', () => { + const { rerender } = render(); + expect(screen.getByTestId('radio-set')).toBeInTheDocument(); + rerender(); + expect(screen.getByTestId('radio-set')).toBeInTheDocument(); + rerender(); + expect(screen.getByTestId('checkbox-set')).toBeInTheDocument(); + }); + it('renders with children', () => { + render( + {checkboxText(1)}, + ); + expect(screen.getByText(checkboxText(1))).toBeInTheDocument(); + }); + it('renders with on change event', async () => { + const onChangeSpy = jest.fn(); + render(); + const checkbox = screen.getByRole('button', { name: checkboxText(1) }); + await userEvent.click(checkbox); + expect(onChangeSpy).toHaveBeenCalledTimes(1); + }); + it('renders with checkbox type', () => { + render(); + expect(screen.getByTestId('checkbox-set')).toBeInTheDocument(); + }); + it('renders with radio type if neither checkbox nor radio is passed', () => { + render(); + expect(screen.getByTestId('radio-set')).toBeInTheDocument(); + }); + it('renders with radio type', () => { + render(); + expect(screen.getByTestId('radio-set')).toBeInTheDocument(); + }); + it('renders with correct number of columns', () => { + const columns = 10; + render(); + const selectableBoxSet = screen.getByTestId('selectable-box-set'); + expect(selectableBoxSet).toHaveClass(`pgn__selectable_box-set--${columns}`); + }); + it('renders with an aria-label attribute', () => { + render(()); + expect(screen.getByLabelText('test-radio-set-label')).toBeInTheDocument(); + }); + it('renders with an aria-labelledby attribute', () => { + render(( + <> +

Radio Set Label text

+ + + )); + expect(screen.getByLabelText('Radio Set Label text')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBox.test.jsx.snap b/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBox.test.jsx.snap new file mode 100644 index 000000000..96676cabf --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBox.test.jsx.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` correct rendering renders without props 1`] = ` +
+ + SelectableBox +
+`; diff --git a/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBoxSet.test.jsx.snap b/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBoxSet.test.jsx.snap new file mode 100644 index 000000000..4c9a0ab8f --- /dev/null +++ b/src/editors/sharedComponents/SelectableBox/tests/__snapshots__/SelectableBoxSet.test.jsx.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` correct rendering renders without props 1`] = ` +
+
+
+ + SelectableRadio1 +
+
+ + SelectableRadio2 +
+
+ + SelectableRadio3 +
+
+
+`; diff --git a/src/editors/sharedComponents/SelectableBox/getInputType.jsx b/src/editors/sharedComponents/SelectableBox/utils.js similarity index 63% rename from src/editors/sharedComponents/SelectableBox/getInputType.jsx rename to src/editors/sharedComponents/SelectableBox/utils.js index c0abf2daf..87efbab0c 100644 --- a/src/editors/sharedComponents/SelectableBox/getInputType.jsx +++ b/src/editors/sharedComponents/SelectableBox/utils.js @@ -1,6 +1,7 @@ -import { - Form, CheckboxControl, RadioControl, -} from '@openedx/paragon'; +import { CheckboxControl } from './FormCheckbox'; +import { RadioControl } from './FormRadio'; +import FormRadioSet from './FormRadioSet'; +import FormCheckboxSet from './FormCheckboxSet'; // eslint-disable-next-line import/prefer-default-export,consistent-return export const getInputType = (component, type) => { @@ -16,11 +17,11 @@ export const getInputType = (component, type) => { } else if (component === 'SelectableBoxSet') { switch (type) { case 'radio': - return Form.RadioSet; + return FormRadioSet; case 'checkbox': - return Form.CheckboxSet; + return FormCheckboxSet; default: - return Form.RadioSet; + return FormRadioSet; } } };