diff --git a/packages/cloud-cognitive/CHANGELOG.md b/packages/cloud-cognitive/CHANGELOG.md index 50c193efc3..d195a7513a 100644 --- a/packages/cloud-cognitive/CHANGELOG.md +++ b/packages/cloud-cognitive/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.39.11](https://github.com/carbon-design-system/ibm-cloud-cognitive/compare/@carbon/ibm-cloud-cognitive@0.39.10...@carbon/ibm-cloud-cognitive@0.39.11) (2021-05-25) + +**Note:** Version bump only for package @carbon/ibm-cloud-cognitive + + + + + ## [0.39.10](https://github.com/carbon-design-system/ibm-cloud-cognitive/compare/@carbon/ibm-cloud-cognitive@0.39.9...@carbon/ibm-cloud-cognitive@0.39.10) (2021-05-24) diff --git a/packages/cloud-cognitive/package.json b/packages/cloud-cognitive/package.json index 7994e455d0..f3ae032be3 100644 --- a/packages/cloud-cognitive/package.json +++ b/packages/cloud-cognitive/package.json @@ -1,7 +1,7 @@ { "name": "@carbon/ibm-cloud-cognitive", "description": "Carbon for Cloud & Cognitive UI components", - "version": "0.39.10", + "version": "0.39.11", "license": "Apache-2.0", "main": "lib/index.js", "module": "es/index.js", diff --git a/packages/cloud-cognitive/src/components/ActionBar/ActionBar.js b/packages/cloud-cognitive/src/components/ActionBar/ActionBar.js index 64addac92c..7bdb6a200d 100644 --- a/packages/cloud-cognitive/src/components/ActionBar/ActionBar.js +++ b/packages/cloud-cognitive/src/components/ActionBar/ActionBar.js @@ -223,7 +223,7 @@ ActionBar.propTypes = { PropTypes.arrayOf(PropTypes.element), PropTypes.element, ]), - "See documentation on the 'actions' property." + 'See documentation on the `actions` prop.' ), // expects action bar item as array or in fragment, /** * className diff --git a/packages/cloud-cognitive/src/components/ActionBar/ActionBar.test.js b/packages/cloud-cognitive/src/components/ActionBar/ActionBar.test.js index a07545da4f..840da8ad4e 100644 --- a/packages/cloud-cognitive/src/components/ActionBar/ActionBar.test.js +++ b/packages/cloud-cognitive/src/components/ActionBar/ActionBar.test.js @@ -32,7 +32,7 @@ const ActionBarChildren = ( ); // eslint-disable-next-line react/prop-types -const TestActionBar = ({ width, children, ...rest }) => { +const TestActionBar = ({ width, children = null, ...rest }) => { return (
{children} @@ -83,7 +83,7 @@ describe(ActionBar.displayName, () => { screen.getByText(/Action 10/); expect(warn).toBeCalledWith( - "The prop 'children' of 'ActionBar' has been deprecated and will soon be removed. See documentation on the 'actions' property." + 'The prop `children` of `ActionBar` has been deprecated and will soon be removed. See documentation on the `actions` prop.' ); warn.mockRestore(); // Remove mock diff --git a/packages/cloud-cognitive/src/components/ActionSet/ActionSet.js b/packages/cloud-cognitive/src/components/ActionSet/ActionSet.js index 03c3f00271..fd195c77b6 100644 --- a/packages/cloud-cognitive/src/components/ActionSet/ActionSet.js +++ b/packages/cloud-cognitive/src/components/ActionSet/ActionSet.js @@ -12,6 +12,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; import { pkg } from '../../settings'; +import { allPropTypes } from '../../global/js/utils/props-helper'; // Carbon and package components we use. import { Button, ButtonSet, InlineLoading } from 'carbon-components-react'; @@ -54,6 +55,8 @@ const ActionSetButton = React.forwardRef( ) ); +ActionSetButton.displayName = 'ActionSetButton'; + ActionSetButton.propTypes = { ...Button.PropTypes, kind: PropTypes.oneOf(['ghost', 'secondary', 'primary']), @@ -94,7 +97,7 @@ export const ActionSet = React.forwardRef( const buttons = (actions && actions.slice?.(0)) || []; // We stack the buttons in a xs/sm set, or if there are three or more in a md set. - const stack = willStack(size, buttons.length); + const stacking = willStack(size, buttons.length); // order the actions with ghost buttons first and primary buttons last // (and the opposite way if we're stacking) @@ -103,11 +106,11 @@ export const ActionSet = React.forwardRef( const kind2 = action2.kind || defaultKind; return kind1 === 'ghost' || kind2 === 'primary' - ? stack + ? stacking ? 1 : -1 : kind1 === 'primary' || kind2 === 'ghost' - ? stack + ? stacking ? -1 : 1 : 0; @@ -123,17 +126,17 @@ export const ActionSet = React.forwardRef( blockClass, className, { - [`${blockClass}--row-single`]: !stack && buttons.length === 1, - [`${blockClass}--row-double`]: !stack && buttons.length === 2, - [`${blockClass}--row-triple`]: !stack && buttons.length === 3, - [`${blockClass}--row-quadruple`]: !stack && buttons.length >= 4, - [`${blockClass}--stack`]: stack, + [`${blockClass}--row-single`]: !stacking && buttons.length === 1, + [`${blockClass}--row-double`]: !stacking && buttons.length === 2, + [`${blockClass}--row-triple`]: !stacking && buttons.length === 3, + [`${blockClass}--row-quadruple`]: !stacking && buttons.length >= 4, + [`${blockClass}--stacking`]: stacking, }, `${blockClass}--${size}` )} ref={ref} role="presentation" - stacked={stack}> + stacked={stacking}> {buttons.map((action, index) => ( (props, propName, componentName) => { - const prop = props[propName]; +/** + * A validator function to help validate the actions supplied for a particular + * size of component. When the size is xs or sm, or md with three actions, the + * buttons will be stacked and a maximum of three buttons is applied with no + * ghosts unless the ghost is the only button. Otherwise a maximum of four + * buttons with a maximum of one ghost is applied. In either case, a maximum + * of one primary button is allowed. + * @param sizeFn An optional function which will be passed all the props and + * returns the size that the component should be treated as being: if not + * provided, a 'size' prop is used to determine the size of the component. + * @returns null if the actions meet the requirements, or an Error object with + * an explanatory message. + */ +ActionSet.validateActions = (sizeFn) => ( + props, + propName, + componentName, + location, + propFullName +) => { + const name = propFullName || propName; + const prop = props[name]; const actions = prop && prop?.length; + const problems = []; if (actions > 0) { const size = sizeFn ? sizeFn(props) : props.size; - const stack = willStack(size, prop.length); + const stacking = willStack(size, actions); const countActions = (kind) => prop.filter((action) => (action.kind || defaultKind) === kind).length; - const check = (iftrue, problem) => { - if (iftrue) - throw new Error(`Property '${propName}' is invalid: ${problem}`); - }; + const primaryActions = countActions('primary'); + const secondaryActions = countActions('secondary'); + const ghostActions = countActions('ghost'); - check( - stack && actions > 3, - `you cannot have more than three actions in this size of ${componentName}.` - ); + stacking && + actions > 3 && + problems.push( + `you cannot have more than three actions in this size of ${componentName}` + ); - check( - actions > 4, - `you cannot have more than four actions in a ${componentName}.` - ); + actions > 4 && + problems.push( + `you cannot have more than four actions in a ${componentName}` + ); - const primaryActions = countActions('primary'); - check( - primaryActions > 1, - `you cannot have more than one 'primary' action in a ${componentName}.` - ); + primaryActions > 1 && + problems.push( + `you cannot have more than one 'primary' action in a ${componentName}` + ); - const ghostActions = countActions('ghost'); - check( - ghostActions > 1, - `you cannot have more than one 'ghost' action in a ${componentName}.` - ); + ghostActions > 1 && + problems.push( + `you cannot have more than one 'ghost' action in a ${componentName}` + ); - check( - stack && actions > 1 && ghostActions > 0, - `you cannot have a 'ghost' button in conjunction with other action types in this size of ${componentName}. Try using a 'secondary' button instead.` - ); + stacking && + actions > 1 && + ghostActions > 0 && + problems.push( + `you cannot have a 'ghost' button in conjunction with other action types in this size of ${componentName}` + ); - const secondaryActions = countActions('secondary'); - check( - prop.length > primaryActions + secondaryActions + ghostActions, - `you can only have 'primary', 'secondary' and 'ghost' buttons in a ${componentName}.` - ); + actions > primaryActions + secondaryActions + ghostActions && + problems.push( + `you can only have 'primary', 'secondary' and 'ghost' buttons in a ${componentName}` + ); } - return null; + return problems.length > 0 + ? new Error( + `Invalid ${location} \`${name}\` supplied to \`${componentName}\`: ${problems.join( + ', and ' + )}.` + ) + : null; }; ActionSet.propTypes = { @@ -223,7 +243,7 @@ ActionSet.propTypes = { * * See https://react.carbondesignsystem.com/?path=/docs/components-button--default#component-api */ - actions: PropTypes.oneOfType([ + actions: allPropTypes([ ActionSet.validateActions(), PropTypes.arrayOf( PropTypes.shape({ diff --git a/packages/cloud-cognitive/src/components/ActionSet/ActionSet.test.js b/packages/cloud-cognitive/src/components/ActionSet/ActionSet.test.js index c1fa019cb0..e21045c455 100644 --- a/packages/cloud-cognitive/src/components/ActionSet/ActionSet.test.js +++ b/packages/cloud-cognitive/src/components/ActionSet/ActionSet.test.js @@ -81,6 +81,19 @@ describe(componentName, () => { expect(buttons[1].textContent).toEqual(label1); }); + it('rejects too many buttons using the custom validator', () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + render( + + ); + expect(error).toBeCalledWith( + expect.stringContaining('`actions` supplied to `ActionSet`: you cannot') + ); + error.mockRestore(); + }); + it('applies className to an action button', () => { render(); expect(getByRoleAndLabel('button', label1)).toHaveClass(className); @@ -131,7 +144,7 @@ describe(componentName, () => { }); }); -const v = (size, props, propName, componentName) => () => +const v = (size, props, propName, componentName) => ActionSet.validateActions(() => size)(props, propName, componentName); const prop = `prop-${uuidv4()}`; @@ -166,70 +179,80 @@ const props = { describe(`${componentName}.validateActions`, () => { it('rejects more than three actions for an extra small size', () => { - expect(v('xs', props[1], prop, componentName)).not.toThrow(); - expect(v('xs', props[2], prop, componentName)).not.toThrow(); - expect(v('xs', props[3], prop, componentName)).not.toThrow(); - expect(v('xs', props[4], prop, componentName)).toThrow(); + expect(v('xs', props[1], prop, componentName)).toBeNull(); + expect(v('xs', props[2], prop, componentName)).toBeNull(); + expect(v('xs', props[3], prop, componentName)).toBeNull(); + expect(v('xs', props[4], prop, componentName)).toBeInstanceOf(Error); }); it('rejects more than three actions for a small size', () => { - expect(v('sm', props[1], prop, componentName)).not.toThrow(); - expect(v('sm', props[2], prop, componentName)).not.toThrow(); - expect(v('sm', props[3], prop, componentName)).not.toThrow(); - expect(v('sm', props[4], prop, componentName)).toThrow(); + expect(v('sm', props[1], prop, componentName)).toBeNull(); + expect(v('sm', props[2], prop, componentName)).toBeNull(); + expect(v('sm', props[3], prop, componentName)).toBeNull(); + expect(v('sm', props[4], prop, componentName)).toBeInstanceOf(Error); }); it('rejects more than three actions for a medium size', () => { - expect(v('md', props[1], prop, componentName)).not.toThrow(); - expect(v('md', props[2], prop, componentName)).not.toThrow(); - expect(v('md', props[3], prop, componentName)).not.toThrow(); - expect(v('md', props[4], prop, componentName)).toThrow(); + expect(v('md', props[1], prop, componentName)).toBeNull(); + expect(v('md', props[2], prop, componentName)).toBeNull(); + expect(v('md', props[3], prop, componentName)).toBeNull(); + expect(v('md', props[4], prop, componentName)).toBeInstanceOf(Error); }); it('rejects more than four actions for a large size', () => { - expect(v('lg', props[1], prop, componentName)).not.toThrow(); - expect(v('lg', props[2], prop, componentName)).not.toThrow(); - expect(v('lg', props[3], prop, componentName)).not.toThrow(); - expect(v('lg', props[4], prop, componentName)).not.toThrow(); - expect(v('lg', props[5], prop, componentName)).toThrow(); + expect(v('lg', props[1], prop, componentName)).toBeNull(); + expect(v('lg', props[2], prop, componentName)).toBeNull(); + expect(v('lg', props[3], prop, componentName)).toBeNull(); + expect(v('lg', props[4], prop, componentName)).toBeNull(); + expect(v('lg', props[5], prop, componentName)).toBeInstanceOf(Error); }); it('rejects more than four actions for a max size', () => { - expect(v('max', props[1], prop, componentName)).not.toThrow(); - expect(v('max', props[2], prop, componentName)).not.toThrow(); - expect(v('max', props[3], prop, componentName)).not.toThrow(); - expect(v('max', props[4], prop, componentName)).not.toThrow(); - expect(v('max', props[5], prop, componentName)).toThrow(); + expect(v('max', props[1], prop, componentName)).toBeNull(); + expect(v('max', props[2], prop, componentName)).toBeNull(); + expect(v('max', props[3], prop, componentName)).toBeNull(); + expect(v('max', props[4], prop, componentName)).toBeNull(); + expect(v('max', props[5], prop, componentName)).toBeInstanceOf(Error); }); it('rejects more than one primary kind', () => { - expect(v('md', props.primary, prop, componentName)).not.toThrow(); - expect(v('md', props.twoPrimaries, prop, componentName)).toThrow(); + expect(v('md', props.primary, prop, componentName)).toBeNull(); + expect(v('md', props.twoPrimaries, prop, componentName)).toBeInstanceOf( + Error + ); }); it('rejects more than one ghost kind', () => { - expect(v('md', props.ghost, prop, componentName)).not.toThrow(); - expect(v('md', props.twoGhosts, prop, componentName)).toThrow(); + expect(v('md', props.ghost, prop, componentName)).toBeNull(); + expect(v('md', props.twoGhosts, prop, componentName)).toBeInstanceOf(Error); }); it('rejects ghost kind with other kinds for extra small, small, medium size', () => { - expect(v('xs', props.psg, prop, componentName)).toThrow(); - expect(v('sm', props.psg, prop, componentName)).toThrow(); - expect(v('md', props.psg, prop, componentName)).toThrow(); - expect(v('lg', props.psg, prop, componentName)).not.toThrow(); - expect(v('max', props.psg, prop, componentName)).not.toThrow(); + expect(v('xs', props.psg, prop, componentName)).toBeInstanceOf(Error); + expect(v('sm', props.psg, prop, componentName)).toBeInstanceOf(Error); + expect(v('md', props.psg, prop, componentName)).toBeInstanceOf(Error); + expect(v('lg', props.psg, prop, componentName)).toBeNull(); + expect(v('max', props.psg, prop, componentName)).toBeNull(); }); it('rejects any kind other than primary, secondary, ghost', () => { - expect(v('md', props.primary, prop, componentName)).not.toThrow(); - expect(v('md', props.secondary, prop, componentName)).not.toThrow(); - expect(v('md', props.danger, prop, componentName)).toThrow(); - expect(v('md', props.ghost, prop, componentName)).not.toThrow(); - expect(v('md', props.dangerPrimary, prop, componentName)).toThrow(); - expect(v('md', props.dangerGhost, prop, componentName)).toThrow(); - expect(v('md', props.dangerTertiary, prop, componentName)).toThrow(); - expect(v('md', props.tertiary, prop, componentName)).toThrow(); - expect(v('md', props.twoPrimaries, prop, componentName)).toThrow(); - expect(v('md', props.twoGhosts, prop, componentName)).toThrow(); + expect(v('md', props.primary, prop, componentName)).toBeNull(); + expect(v('md', props.secondary, prop, componentName)).toBeNull(); + expect(v('md', props.danger, prop, componentName)).toBeInstanceOf(Error); + expect(v('md', props.ghost, prop, componentName)).toBeNull(); + expect(v('md', props.dangerPrimary, prop, componentName)).toBeInstanceOf( + Error + ); + expect(v('md', props.dangerGhost, prop, componentName)).toBeInstanceOf( + Error + ); + expect(v('md', props.dangerTertiary, prop, componentName)).toBeInstanceOf( + Error + ); + expect(v('md', props.tertiary, prop, componentName)).toBeInstanceOf(Error); + expect(v('md', props.twoPrimaries, prop, componentName)).toBeInstanceOf( + Error + ); + expect(v('md', props.twoGhosts, prop, componentName)).toBeInstanceOf(Error); }); }); diff --git a/packages/cloud-cognitive/src/components/ButtonSetWithOverflow/ButtonSetWithOverflow.js b/packages/cloud-cognitive/src/components/ButtonSetWithOverflow/ButtonSetWithOverflow.js index a352815802..da69d5a313 100644 --- a/packages/cloud-cognitive/src/components/ButtonSetWithOverflow/ButtonSetWithOverflow.js +++ b/packages/cloud-cognitive/src/components/ButtonSetWithOverflow/ButtonSetWithOverflow.js @@ -192,7 +192,7 @@ ButtonSetWithOverflow.propTypes = { PropTypes.arrayOf(PropTypes.element), PropTypes.element, ]), - "See documentation on the 'buttons' property." + 'See documentation on the `buttons` property.' ), // expects action bar item as array or in fragment, /** * className diff --git a/packages/cloud-cognitive/src/components/ButtonSetWithOverflow/ButtonSetWithOverflow.test.js b/packages/cloud-cognitive/src/components/ButtonSetWithOverflow/ButtonSetWithOverflow.test.js index 11a049cb68..6087a5fd37 100644 --- a/packages/cloud-cognitive/src/components/ButtonSetWithOverflow/ButtonSetWithOverflow.test.js +++ b/packages/cloud-cognitive/src/components/ButtonSetWithOverflow/ButtonSetWithOverflow.test.js @@ -89,7 +89,7 @@ describe(ButtonSetWithOverflow.displayName, () => { }); expect(warn).toBeCalledWith( - "The prop 'children' of 'ButtonSetWithOverflow' has been deprecated and will soon be removed. See documentation on the 'buttons' property." + 'The prop `children` of `ButtonSetWithOverflow` has been deprecated and will soon be removed. See documentation on the `buttons` property.' ); userEvent.click(action1); diff --git a/packages/cloud-cognitive/src/components/PageHeader/PageHeader.js b/packages/cloud-cognitive/src/components/PageHeader/PageHeader.js index ad3af63163..8073a44226 100644 --- a/packages/cloud-cognitive/src/components/PageHeader/PageHeader.js +++ b/packages/cloud-cognitive/src/components/PageHeader/PageHeader.js @@ -924,7 +924,7 @@ PageHeader.propTypes = { */ titleIcon: deprecateProp( PropTypes.oneOfType([PropTypes.func, PropTypes.object]), - 'Deprecated. Use title prop shape instead.' + 'Use `title` prop shape instead.' ), }; diff --git a/packages/cloud-cognitive/src/components/PageHeader/PageHeader.test.js b/packages/cloud-cognitive/src/components/PageHeader/PageHeader.test.js index e2dbe8732c..cff0cb54dc 100644 --- a/packages/cloud-cognitive/src/components/PageHeader/PageHeader.test.js +++ b/packages/cloud-cognitive/src/components/PageHeader/PageHeader.test.js @@ -339,7 +339,7 @@ describe('PageHeader', () => { render(); expect(warn).toBeCalledWith( - "The usage of prop 'actionBarItems' of 'PageHeader' has been changed and you should update. Expects an array of objects with the following properties: iconDescription, renderIcon and onClick." + 'The usage of the prop `actionBarItems` of `PageHeader` has been changed and support for the old usage will soon be removed. Expects an array of objects with the following properties: iconDescription, renderIcon and onClick.' ); warn.mockRestore(); // Remove mock @@ -355,7 +355,7 @@ describe('PageHeader', () => { }); expect(warn).toBeCalledWith( - "The usage of prop 'pageActions' of 'PageHeader' has been changed and you should update. Expects an array of objects with the following properties: label and onClick." + 'The usage of the prop `pageActions` of `PageHeader` has been changed and support for the old usage will soon be removed. Expects an array of objects with the following properties: label and onClick.' ); warn.mockRestore(); // Remove mock @@ -371,7 +371,7 @@ describe('PageHeader', () => { }); expect(warn).toBeCalledWith( - "The usage of prop 'pageActions' of 'PageHeader' has been changed and you should update. Expects an array of objects with the following properties: label and onClick." + 'The usage of the prop `pageActions` of `PageHeader` has been changed and support for the old usage will soon be removed. Expects an array of objects with the following properties: label and onClick.' ); warn.mockRestore(); // Remove mock @@ -479,7 +479,7 @@ describe('PageHeader', () => { render(); expect(warn).toBeCalledWith( - "The prop 'titleIcon' of 'PageHeader' has been deprecated and will soon be removed. Deprecated. Use title prop shape instead." + 'The prop `titleIcon` of `PageHeader` has been deprecated and will soon be removed. Use `title` prop shape instead.' ); screen.getByText(titleString, { selector: `.${blockClass}__title span` }); diff --git a/packages/cloud-cognitive/src/components/SidePanel/SidePanel.js b/packages/cloud-cognitive/src/components/SidePanel/SidePanel.js index 97506ba531..b1f9bf0c1d 100644 --- a/packages/cloud-cognitive/src/components/SidePanel/SidePanel.js +++ b/packages/cloud-cognitive/src/components/SidePanel/SidePanel.js @@ -14,6 +14,7 @@ import cx from 'classnames'; import ReactResizeDetector from 'react-resize-detector'; import wrapFocus from '../../global/js/utils/wrapFocus'; import { pkg } from '../../settings'; +import { allPropTypes } from '../../global/js/utils/props-helper'; import { SIDE_PANEL_SIZES } from './constants'; // Carbon and package components we use. @@ -576,7 +577,7 @@ SidePanel.propTypes = { * * See https://react.carbondesignsystem.com/?path=/docs/components-button--default#component-api */ - actions: PropTypes.oneOfType([ + actions: allPropTypes([ ActionSet.validateActions(), PropTypes.arrayOf( PropTypes.shape({ diff --git a/packages/cloud-cognitive/src/components/SidePanel/SidePanel.test.js b/packages/cloud-cognitive/src/components/SidePanel/SidePanel.test.js index 86e2700b0d..11f8fa4fd0 100644 --- a/packages/cloud-cognitive/src/components/SidePanel/SidePanel.test.js +++ b/packages/cloud-cognitive/src/components/SidePanel/SidePanel.test.js @@ -271,6 +271,23 @@ describe('SidePanel', () => { ).toBeTruthy(); }); + it('rejects too many buttons using the custom validator', () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + renderSidePanel({ + actions: [ + { kind: 'primary' }, + { kind: 'primary' }, + { kind: 'ghost' }, + { kind: 'ghost' }, + { kind: 'danger' }, + ], + }); + expect(error).toBeCalledWith( + expect.stringContaining('`actions` supplied to `SidePanel`: you cannot') + ); + error.mockRestore(); + }); + it('should render navigation button', () => { const { container } = renderSidePanel({ currentStep: 1, diff --git a/packages/cloud-cognitive/src/components/Tearsheet/Tearsheet.js b/packages/cloud-cognitive/src/components/Tearsheet/Tearsheet.js index 7189741668..556bd499c6 100644 --- a/packages/cloud-cognitive/src/components/Tearsheet/Tearsheet.js +++ b/packages/cloud-cognitive/src/components/Tearsheet/Tearsheet.js @@ -11,7 +11,7 @@ import React from 'react'; // Other standard imports. import PropTypes from 'prop-types'; import { pkg } from '../../settings'; -import { prepareProps } from '../../global/js/utils/props-helper'; +import { allPropTypes, prepareProps } from '../../global/js/utils/props-helper'; // Carbon and package components we use. import { Button } from 'carbon-components-react'; @@ -66,7 +66,7 @@ Tearsheet.propTypes = { * * See https://react.carbondesignsystem.com/?path=/docs/components-button--default#component-api */ - actions: PropTypes.oneOfType([ + actions: allPropTypes([ ActionSet.validateActions(() => 'max'), PropTypes.arrayOf( PropTypes.shape({ diff --git a/packages/cloud-cognitive/src/components/Tearsheet/Tearsheet.test.js b/packages/cloud-cognitive/src/components/Tearsheet/Tearsheet.test.js index f99791009a..63203f39e1 100644 --- a/packages/cloud-cognitive/src/components/Tearsheet/Tearsheet.test.js +++ b/packages/cloud-cognitive/src/components/Tearsheet/Tearsheet.test.js @@ -29,6 +29,13 @@ const actions = [ { kind: 'secondary', onClick, label: 'Cancel' }, { onClick, label: createButton }, ]; +const badactions = [ + { kind: 'primary' }, + { kind: 'primary' }, + { kind: 'ghost' }, + { kind: 'ghost' }, + { kind: 'danger' }, +]; const childFragment = `Main ${uuidv4()} content`; const children =
{childFragment}
; const className = `class-${uuidv4()}`; @@ -98,6 +105,15 @@ const commonTests = (Ts, name) => { expect(onClick).toHaveBeenCalledTimes(1); }); + it('rejects too many buttons using the custom validator', () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + render(); + expect(error).toBeCalledWith( + expect.stringContaining(`\`actions\` supplied to \`${name}\`: you cannot`) + ); + error.mockRestore(); + }); + it('renders children', () => { render({children}); expect(document.querySelector(`.${blockClass}__main`)).not.toBeNull(); diff --git a/packages/cloud-cognitive/src/components/Tearsheet/TearsheetNarrow.js b/packages/cloud-cognitive/src/components/Tearsheet/TearsheetNarrow.js index 83be579110..06211f1307 100644 --- a/packages/cloud-cognitive/src/components/Tearsheet/TearsheetNarrow.js +++ b/packages/cloud-cognitive/src/components/Tearsheet/TearsheetNarrow.js @@ -11,7 +11,7 @@ import React from 'react'; // Other standard imports. import PropTypes from 'prop-types'; import { pkg } from '../../settings'; -import { prepareProps } from '../../global/js/utils/props-helper'; +import { allPropTypes, prepareProps } from '../../global/js/utils/props-helper'; // Carbon and package components we use. import { Button } from 'carbon-components-react'; @@ -65,7 +65,7 @@ TearsheetNarrow.propTypes = { * * See https://react.carbondesignsystem.com/?path=/docs/components-button--default#component-api */ - actions: PropTypes.oneOfType([ + actions: allPropTypes([ ActionSet.validateActions(() => 'lg'), PropTypes.arrayOf( PropTypes.shape({ diff --git a/packages/cloud-cognitive/src/global/js/utils/pconsole.js b/packages/cloud-cognitive/src/global/js/utils/pconsole.js new file mode 100644 index 0000000000..59ed8e8268 --- /dev/null +++ b/packages/cloud-cognitive/src/global/js/utils/pconsole.js @@ -0,0 +1,22 @@ +// +// Copyright IBM Corp. 2020, 2021 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +export const isProduction = process.env.NODE_ENV === 'production'; +export const noop = () => undefined; +export const shimIfProduction = (fn) => (isProduction ? noop : fn); +export const log = shimIfProduction((...args) => console.log(...args)); +export const warn = shimIfProduction((...args) => console.warn(...args)); +export const error = shimIfProduction((...args) => console.error(...args)); + +export default { + isProduction, + noop, + shimIfProduction, + log, + warn, + error, +}; diff --git a/packages/cloud-cognitive/src/global/js/utils/props-helper.js b/packages/cloud-cognitive/src/global/js/utils/props-helper.js index 6dd9409278..50ff911499 100644 --- a/packages/cloud-cognitive/src/global/js/utils/props-helper.js +++ b/packages/cloud-cognitive/src/global/js/utils/props-helper.js @@ -6,9 +6,9 @@ // import React from 'react'; -import PropTypes from 'prop-types'; import unwrapIfFragment from './unwrap-if-fragment'; +import pconsole from './pconsole'; // helper functions for component props @@ -55,50 +55,49 @@ export const prepareProps = (...values) => { }, {}); }; -const deprecatePropInner = (validator, messageFunction, additionalInfo) => { - const deprecatePropValidator = ( - props, - name, - componentName, - location, - propFullName - ) => { - if (props[name] !== null) { - const info = additionalInfo ? ` ${additionalInfo}` : ''; - console.warn( - messageFunction(location, propFullName || name, componentName, info) - ); - } - - return null; - }; +// A simple wrapper for a prop-types checker that issues a warning message if +// the value being validated is not null/undefined. +const deprecatePropInner = (message, validator, info) => (...args) => ( + // args = [props, propName, componentName, location, propFullName, ...] + args[0][args[1]] && + pconsole.warn(message(args[3], args[4] || args[1], args[2], info)), + validator(...args) +); - // first does the deprecation check and then calls original validator - return ( - PropTypes.oneOfType([deprecatePropValidator]) || - PropTypes.oneOfType([validator]) - ); -}; - -export const deprecatePropUsage = (validator, additionalInfo) => { - return deprecatePropInner( - validator, - (location, name, componentName, info) => { - return `The usage of ${location} '${name}' of '${componentName}' has been changed and you should update.${info}`; - }, - additionalInfo - ); -}; +/** + * A prop-types type checker that marks a particular usage of a prop as + * deprecated. This can be used to deprecate an option in a oneOfType checker, + * and the deprecated option(s) should be listed last so that the deprecation + * message is only reported if none of the other type options is matched. + * @param {} validator The prop-types validator for the prop usage as it should + * be if it weren't deprecated. If this validator produces type checking + * errors they will be reported as usual. + * @param {*} additionalInfo One or more sentences to be appended to the + * deprecation message to explain why the prop usage is deprecated and/or what + * should be used instead. + * @returns Any type checking error reported by the validator, or null. + */ +export const deprecatePropUsage = deprecatePropInner.bind( + undefined, + (location, propName, componentName, info) => + `The usage of the ${location} \`${propName}\` of \`${componentName}\` has been changed and support for the old usage will soon be removed. ${info}` +); -export const deprecateProp = (validator, additionalInfo) => { - return deprecatePropInner( - validator, - (location, name, componentName, info) => { - return `The ${location} '${name}' of '${componentName}' has been deprecated and will soon be removed.${info}`; - }, - additionalInfo - ); -}; +/** + * A prop-types type checker that marks a prop as deprecated. + * @param {} validator The prop-types validator for the prop as it should be + * used if it weren't deprecated. If this validator produces type checking + * errors they will be reported as usual. + * @param {*} additionalInfo One or more sentences to be appended to the + * deprecation message to explain why the prop is deprecated and/or what should + * be used instead. + * @returns Any type checking error reported by the validator, or null. + */ +export const deprecateProp = deprecatePropInner.bind( + undefined, + (location, propName, componentName, info) => + `The ${location} \`${propName}\` of \`${componentName}\` has been deprecated and will soon be removed. ${info}` +); /** * Takes items as fragment, node or array @@ -118,3 +117,69 @@ export const extractShapesArray = (items) => { return Array.isArray(items) ? items : []; }; + +/** + * A prop-types validation function that takes an array of type checkers and + * requires prop values to satisfy all of the type checkers. This can be useful + * to combine custom validation functions with regular prop types, or for + * combining inherited prop-types from another component with tighter + * requirements. + * + * Examples: + * + * MyComponent.propTypes = { + * + * foo: allPropTypes([ + * customValidationFunction, + * PropTypes.arrayOf( + * PropTypes.shape({ + * text: PropType.string + * }) + * ) + * ]), + * + * kind: allPropTypes([ + * Button.propTypes.kind, + * PropTypes.oneOf('primary', 'secondary') + * ]), + * + * } + */ +export const allPropTypes = pconsole.shimIfProduction((arrayOfTypeCheckers) => { + if (!Array.isArray(arrayOfTypeCheckers)) { + pconsole.error( + 'Warning: Invalid argument supplied to allPropTypes, expected an instance of array.' + ); + return pconsole.noop; + } + + for (let i = 0; i < arrayOfTypeCheckers.length; i++) { + if (typeof arrayOfTypeCheckers[i] !== 'function') { + pconsole.error( + `Invalid argument supplied to allPropTypes. Expected an array of check functions, but received ${arrayOfTypeCheckers[i]} at index ${i}.` + ); + return pconsole.noop; + } + } + + const checkType = (...args) => { + let error = null; + arrayOfTypeCheckers.some((checker) => (error = checker(...args))); + return error; + }; + + checkType.isRequired = (props, propName, comp, loc, propFullName, secret) => { + const prop = propFullName || propName; + return props[prop] == null + ? new Error( + `The ${loc} \`${prop}\` is marked as required in \`${ + comp || '<>' + }\`, but its value is \`${ + props[prop] === null ? 'null' : 'undefined' + }\`.` + ) + : checkType(props, prop, comp, loc, propFullName, secret); + }; + + return checkType; +}); diff --git a/packages/cloud-cognitive/src/global/js/utils/props-helper.test.js b/packages/cloud-cognitive/src/global/js/utils/props-helper.test.js index be52fc6481..8b7a4038ea 100644 --- a/packages/cloud-cognitive/src/global/js/utils/props-helper.test.js +++ b/packages/cloud-cognitive/src/global/js/utils/props-helper.test.js @@ -1,11 +1,27 @@ -import { prepareProps } from './props-helper'; +// +// Copyright IBM Corp. 2021, 2021 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// -const defaults = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8 }; -const props = { e: 9, f: 10, g: 11, h: 12, i: 13, j: 14, k: 15, l: 16 }; -const overrides = { c: 17, d: 18, g: 19, h: 20, k: 21, l: 22, o: 23, p: 24 }; -const block = ['b', 'd', 'f', 'h', 'n', 'p']; +import React from 'react'; +import PropTypes from 'prop-types'; +import { render } from '@testing-library/react'; + +import { + prepareProps, + allPropTypes, + deprecateProp, + deprecatePropUsage, +} from './props-helper'; describe('prepareProps', () => { + const defaults = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8 }; + const props = { e: 9, f: 10, g: 11, h: 12, i: 13, j: 14, k: 15, l: 16 }; + const overrides = { c: 17, d: 18, g: 19, h: 20, k: 21, l: 22, o: 23, p: 24 }; + const block = ['b', 'd', 'f', 'h', 'n', 'p']; + it('applies correct defaults and overrides and blocks values', () => { const result = prepareProps(defaults, props, block, 'j', 'l', overrides); @@ -43,3 +59,87 @@ describe('prepareProps', () => { expect(result.p).toEqual(24); }); }); + +describe('deprecateProp and deprecatePropUsage', () => { + const Component = () => null; + Component.displayName = 'x'; + Component.propTypes = { + a: deprecateProp(PropTypes.string, 'Explanation 1.'), + b: PropTypes.string, + c: PropTypes.oneOfType([ + PropTypes.string, + deprecatePropUsage(PropTypes.number, 'Explanation 2.'), + ]), + }; + + it('reports prop deprecated when deprecated prop is used', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + expect(warn).toBeCalledWith( + 'The prop `a` of `x` has been deprecated and will soon be removed. Explanation 1.' + ); + warn.mockRestore(); + }); + + it('does not report prop deprecated when non-deprecated prop is used', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + expect(warn).not.toBeCalled(); + warn.mockRestore(); + }); + + it('reports prop usage deprecated when deprecated usage is used', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + expect(warn).toBeCalledWith( + 'The usage of the prop `c` of `x` has been changed and support for the old usage will soon be removed. Explanation 2.' + ); + warn.mockRestore(); + }); + + it('does not report prop usage deprecated when non-deprecated usage is used', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + expect(warn).not.toBeCalled(); + warn.mockRestore(); + }); +}); + +describe('allPropTypes', () => { + const e1 = new Error(); + const e2 = new Error( + 'The prop `b` is marked as required in `Component X`, but its value is `null`.' + ); + const e3 = new Error( + 'The prop `c` is marked as required in `Component X`, but its value is `undefined`.' + ); + const p = () => null; + const f = () => e1; + const props = { a: '42', b: null }; + const args1 = [props, 'a', 'Component X', 'prop']; + const args2 = [props, 'b', 'Component X', 'prop']; + const args3 = [props, 'c', 'Component X', 'prop']; + + it('returns null only if all supplied type checkers return null', () => { + expect(allPropTypes([f, f, f, f])(...args1)).toEqual(e1); + expect(allPropTypes([p, f, f, f])(...args1)).toEqual(e1); + expect(allPropTypes([p, p, f, f])(...args1)).toEqual(e1); + expect(allPropTypes([p, p, p, f])(...args1)).toEqual(e1); + expect(allPropTypes([p, p, p, p])(...args1)).toBeNull(); + }); + + it('rejects null or undefined when marked required', () => { + expect(allPropTypes([f, f, f, f]).isRequired(...args1)).toEqual(e1); + expect(allPropTypes([f, f, f, f]).isRequired(...args2)).toEqual(e2); + expect(allPropTypes([f, f, f, f]).isRequired(...args3)).toEqual(e3); + expect(allPropTypes([p, f, f, f]).isRequired(...args1)).toEqual(e1); + expect(allPropTypes([p, f, f, f]).isRequired(...args2)).toEqual(e2); + expect(allPropTypes([f, f, f, f]).isRequired(...args3)).toEqual(e3); + expect(allPropTypes([p, p, p, f]).isRequired(...args1)).toEqual(e1); + expect(allPropTypes([p, p, p, f]).isRequired(...args2)).toEqual(e2); + expect(allPropTypes([f, f, f, f]).isRequired(...args3)).toEqual(e3); + expect(allPropTypes([p, p, p, p]).isRequired(...args1)).toBeNull(); + expect(allPropTypes([p, p, p, p]).isRequired(...args2)).toEqual(e2); + expect(allPropTypes([f, f, f, f]).isRequired(...args3)).toEqual(e3); + }); +}); diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 7cd33d9e6a..3a54d1d7f9 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.8.174](https://github.com/carbon-design-system/ibm-cloud-cognitive/compare/@carbon/ibm-cloud-cognitive-core@0.8.173...@carbon/ibm-cloud-cognitive-core@0.8.174) (2021-05-25) + +**Note:** Version bump only for package @carbon/ibm-cloud-cognitive-core + + + + + ## [0.8.173](https://github.com/carbon-design-system/ibm-cloud-cognitive/compare/@carbon/ibm-cloud-cognitive-core@0.8.172...@carbon/ibm-cloud-cognitive-core@0.8.173) (2021-05-24) **Note:** Version bump only for package @carbon/ibm-cloud-cognitive-core diff --git a/packages/core/package.json b/packages/core/package.json index 47a38ba49f..a29f57ed1a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@carbon/ibm-cloud-cognitive-core", "private": true, - "version": "0.8.173", + "version": "0.8.174", "license": "Apache-2.0", "main": "scripts/build.js", "repository": { @@ -25,7 +25,7 @@ }, "devDependencies": { "@carbon/grid": "10.24.0", - "@carbon/ibm-cloud-cognitive": "^0.39.10", + "@carbon/ibm-cloud-cognitive": "^0.39.11", "@carbon/icons-react": "10.29.0", "@carbon/import-once": "10.5.0", "@carbon/layout": "10.22.0", diff --git a/packages/experimental/CHANGELOG.md b/packages/experimental/CHANGELOG.md index 6186d833a0..7e3698abd9 100644 --- a/packages/experimental/CHANGELOG.md +++ b/packages/experimental/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.34.56](https://github.com/carbon-design-system/ibm-cloud-cognitive/compare/@carbon/ibm-cloud-cognitive-experimental@0.34.55...@carbon/ibm-cloud-cognitive-experimental@0.34.56) (2021-05-25) + +**Note:** Version bump only for package @carbon/ibm-cloud-cognitive-experimental + + + + + ## [0.34.55](https://github.com/carbon-design-system/ibm-cloud-cognitive/compare/@carbon/ibm-cloud-cognitive-experimental@0.34.54...@carbon/ibm-cloud-cognitive-experimental@0.34.55) (2021-05-24) **Note:** Version bump only for package @carbon/ibm-cloud-cognitive-experimental diff --git a/packages/experimental/package.json b/packages/experimental/package.json index e80a24d182..4bd8aeac33 100644 --- a/packages/experimental/package.json +++ b/packages/experimental/package.json @@ -3,7 +3,7 @@ "private-note": "no longer published, package deprecated in favor of @carbon/ibm-cloud-cognitive with feature flags", "private": true, "description": "Carbon for Cloud & Cognitive UI components", - "version": "0.34.55", + "version": "0.34.56", "license": "Apache-2.0", "main": "lib/index.js", "module": "es/index.js", @@ -50,7 +50,7 @@ }, "dependencies": { "@babel/runtime": "^7.13.10", - "@carbon/ibm-cloud-cognitive": "^0.39.10", + "@carbon/ibm-cloud-cognitive": "^0.39.11", "@carbon/telemetry": "^0.0.0-alpha.6", "react-resize-detector": "^6.0.0" },