diff --git a/.github/ISSUE_TEMPLATE/USER_STORY.yaml b/.github/ISSUE_TEMPLATE/USER_STORY.yaml new file mode 100644 index 000000000000..858c33d6c3f2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/USER_STORY.yaml @@ -0,0 +1,56 @@ +name: (Carbon core team ONLY) User story +description: + Write a user story to begin solving their needs. +title: '[YOUR TITLE]: Brief description' +body: + - type: markdown + attributes: + value: "Avoid any type of solutions in this user story." + - type: markdown + attributes: + value: "Consider the following when writing Acceptance criteria for this story: + + - Each product backlog item or user story should have at least one Acceptance criteria. + + - Acceptance criteria defines a deliverable that can be completed in a single sprint. + + - Each Acceptance criterion is independently testable. + + - Include functional as well as non-functional criteria – when relevant. + + - Team members write Acceptance criteria and the Product Owner verifies it." + - type: textarea + id: user-story + attributes: + label: User story + value: "> As a `[user role below]`: + + + > I need to: + + + > so that I can:" + validations: + required: true + - type: textarea + id: additional-information + attributes: + label: Additional information + value: "- _{{user research}}_ + +- _{{user insights}}_ + +- _{{user metrics}}_" + validations: + required: true + - type: textarea + id: acceptance-criteria + attributes: + label: Acceptance criteria + value: "- [ ] _{{State acceptance criteria}}_ + +- [ ] _{{State another}}_ + +- [ ] _{{And another}}_" + validations: + required: true diff --git a/packages/react/src/components/ExpandableSearch/ExpandableSearch-test.js b/packages/react/src/components/ExpandableSearch/ExpandableSearch-test.js new file mode 100644 index 000000000000..93f8338639b8 --- /dev/null +++ b/packages/react/src/components/ExpandableSearch/ExpandableSearch-test.js @@ -0,0 +1,206 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { mount } from 'enzyme'; +import React from 'react'; +import Search from './ExpandableSearch'; + +const prefix = 'cds'; + +describe('ExpandableSearch', () => { + let wrapper; + + const container = () => wrapper.find(`.${prefix}--search`); + const button = () => wrapper.find('button'); + const input = () => wrapper.find('input'); + const label = () => wrapper.find('label'); + + const render = (props) => { + if (wrapper) { + return wrapper.setProps(props); + } + + wrapper = mount(); + + return wrapper; + }; + + describe('container', () => { + beforeEach(() => { + render(); + }); + + it('has the class `${prefix}--search--expandable`', () => { + const value = container().hasClass(`${prefix}--search--expandable`); + expect(value).toEqual(true); + }); + + describe('expanded', () => { + const value = () => container().hasClass(`${prefix}--search--expanded`); + + describe('when input has no content', () => { + beforeEach(() => { + input().simulate('change', { target: { value: '' } }); + }); + + it('is false', () => { + expect(value()).toEqual(false); + }); + }); + + describe.skip('when input has content', () => { + beforeEach(() => { + input().simulate('change', { target: { value: 'text' } }); + }); + + it('is true', () => { + expect(value()).toEqual(true); + }); + + describe('when content is cleared', () => { + beforeEach(() => { + button().simulate('click'); + }); + + it('is false', () => { + expect(value()).toEqual(false); + }); + }); + }); + }); + }); + + describe('label', () => { + beforeEach(() => { + render(); + }); + + it('is rendered', () => { + expect(label().text()).toEqual('testlabel'); + }); + }); + + describe('onBlur', () => { + const onBlur = jest.fn(); + + beforeEach(() => { + render({ onBlur }); + }); + + afterEach(() => { + onBlur.mockReset(); + }); + + it('is called on blur', () => { + input().simulate('blur'); + expect(onBlur).toHaveBeenCalled(); + }); + }); + + describe('onChange', () => { + const onChange = jest.fn(); + + beforeEach(() => { + render({ onChange }); + }); + + afterEach(() => { + onChange.mockReset(); + }); + + it('is called on change', () => { + input().simulate('change', { target: { value: 'text' } }); + expect(onChange).toHaveBeenCalled(); + }); + }); + + describe('onClick', () => { + const onClick = jest.fn(); + + beforeEach(() => { + render({ onClick }); + }); + + afterEach(() => { + onClick.mockReset(); + }); + + it('is called on click', () => { + input().simulate('click'); + expect(onClick).toHaveBeenCalled(); + }); + }); + + describe('onClear', () => { + const onClear = jest.fn(); + + beforeEach(() => { + render({ onClear }); + }); + + afterEach(() => { + onClear.mockReset(); + }); + + describe('when input has no content', () => { + beforeEach(() => { + input().simulate('change', { target: { value: '' } }); + }); + + it('is called on clear', () => { + button().simulate('click'); + expect(onClear).toHaveBeenCalled(); + }); + }); + + describe('when input has content', () => { + beforeEach(() => { + input().simulate('change', { target: { value: 'text' } }); + }); + + it('is called on clear', () => { + button().simulate('click'); + expect(onClear).toHaveBeenCalled(); + }); + }); + }); + + describe('onExpand', () => { + const onExpand = jest.fn(); + + beforeEach(() => { + render({ onExpand }); + }); + + afterEach(() => { + onExpand.mockReset(); + }); + + // This won't work until v11 + it.skip('is called on focus', () => { + input().simulate('focus'); + expect(onExpand).toHaveBeenCalled(); + }); + }); + + describe('onFocus', () => { + const onFocus = jest.fn(); + + beforeEach(() => { + render({ onFocus }); + }); + + afterEach(() => { + onFocus.mockReset(); + }); + + it('is called on focus', () => { + input().simulate('focus'); + expect(onFocus).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/react/src/components/ExpandableSearch/ExpandableSearch.js b/packages/react/src/components/ExpandableSearch/ExpandableSearch.js index 912b6d675e34..143544adb094 100644 --- a/packages/react/src/components/ExpandableSearch/ExpandableSearch.js +++ b/packages/react/src/components/ExpandableSearch/ExpandableSearch.js @@ -9,8 +9,9 @@ import React, { useState, useRef } from 'react'; import classnames from 'classnames'; import Search from '../Search'; import { usePrefix } from '../../internal/usePrefix'; +import { composeEventHandlers } from '../../tools/events'; -function ExpandableSearch(props) { +function ExpandableSearch({ onBlur, onChange, onExpand, onFocus, ...props }) { const [expanded, setExpanded] = useState(false); const [hasContent, setHasContent] = useState(false); const searchRef = useRef(null); @@ -32,6 +33,14 @@ function ExpandableSearch(props) { } } + function handleChange(evt) { + setHasContent(evt.target.value !== ''); + } + + function handleExpand() { + searchRef.current.focus?.(); + } + const classes = classnames( `${prefix}--search--expandable`, { @@ -45,14 +54,10 @@ function ExpandableSearch(props) { {...props} ref={searchRef} className={classes} - onFocus={handleFocus} - onBlur={handleBlur} - onChange={(event) => { - setHasContent(event.target.value !== ''); - }} - onExpand={() => { - searchRef.current.focus?.(); - }} + onFocus={composeEventHandlers([onFocus, handleFocus])} + onBlur={composeEventHandlers([onBlur, handleBlur])} + onChange={composeEventHandlers([onChange, handleChange])} + onExpand={composeEventHandlers([onExpand, handleExpand])} /> ); } diff --git a/packages/react/src/components/Search/Search.js b/packages/react/src/components/Search/Search.js index fa0b20f0cfe2..a23773ff2e07 100644 --- a/packages/react/src/components/Search/Search.js +++ b/packages/react/src/components/Search/Search.js @@ -190,6 +190,7 @@ export default class Search extends Component { onKeyDown, renderIcon, onClear, // eslint-disable-line no-unused-vars + onExpand, // eslint-disable-line no-unused-vars, react/prop-types ...other } = this.props; diff --git a/packages/react/src/components/Search/next/Search-test.js b/packages/react/src/components/Search/next/Search-test.js index c090db0a068a..bcf46d67c3d3 100644 --- a/packages/react/src/components/Search/next/Search-test.js +++ b/packages/react/src/components/Search/next/Search-test.js @@ -13,6 +13,23 @@ import { mount, shallow } from 'enzyme'; const prefix = 'cds'; describe('Search', () => { + let wrapper; + + // const container = () => wrapper.find(`.${prefix}--search`); + const button = () => wrapper.find('button'); + const input = () => wrapper.find('input'); + // const label = () => wrapper.find('label'); + + const render = (props) => { + if (wrapper) { + return wrapper.setProps(props); + } + + wrapper = mount(); + + return wrapper; + }; + describe('renders as expected', () => { const wrapper = mount( { }); describe('events', () => { + describe('onChange', () => { + const onChange = jest.fn(); + + beforeEach(() => { + onChange.mockReset(); + render({ onChange: (e) => onChange(e.target.value) }); + }); + + describe('when input value is changed', () => { + const target = { value: 'test' }; + const mock = { target }; + + beforeEach(() => { + input().simulate('change', mock); + }); + + it('is called', () => { + expect(onChange).toHaveBeenCalledWith(target.value); + }); + }); + + describe('when clear button is clicked', () => { + const target = { value: '' }; + + beforeEach(() => { + button().simulate('click'); + }); + + it('is called', () => { + expect(onChange).toHaveBeenCalledWith(target.value); + }); + }); + }); + describe('enabled textinput', () => { const onClick = jest.fn(); const onChange = jest.fn(); diff --git a/packages/react/src/components/Search/next/Search.js b/packages/react/src/components/Search/next/Search.js index f604aa1e3b5d..69e78223a080 100644 --- a/packages/react/src/components/Search/next/Search.js +++ b/packages/react/src/components/Search/next/Search.js @@ -68,19 +68,15 @@ const Search = React.forwardRef(function Search( setPrevValue(value); } - function clearInput(event) { + function clearInput() { if (!value) { inputRef.current.value = ''; - onChange(event); - } else { - const clearedEvt = Object.assign({}, event.target, { - target: { - value: '', - }, - }); - onChange(clearedEvt); } + const inputTarget = Object.assign({}, inputRef.current, { value: '' }); + const clearedEvt = { target: inputTarget, type: 'change' }; + + onChange(clearedEvt); onClear(); setHasContent(false); focus(inputRef); @@ -99,17 +95,13 @@ const Search = React.forwardRef(function Search( return (
- {/* the magnifier is used in ExpandableSearch as a click target to expand, + {/* the magnifier is used in ExpandableSearch as a click target to expand, however, it does not need a keyboard event bc the input element gets focus on keyboard nav and expands that way*/} {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
{ - if (onExpand) { - onExpand(); - } - }}> + onClick={onExpand}>