diff --git a/dev-test/config.yml b/dev-test/config.yml index 3b8d15d82fbc..c43645f4763c 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -149,11 +149,31 @@ collections: # A list of collections the CMS should be able to edit display_fields: ['title', 'datetime'] search_fields: ['title', 'body'] value_field: 'title' - - { label: 'Title', name: 'title', widget: 'string' } - - { label: 'Boolean', name: 'boolean', widget: 'boolean', default: true } + - { + label: 'Title', + name: 'title', + widget: 'string', + prefix: 'This string:', + suffix: 'is a title' + } + - { + label: 'Boolean', + name: 'boolean', + widget: 'boolean', + prefix: 'OFF', + suffix: 'ON', + hint: 'Toggle this to switch on/off', + default: true + } - { label: 'Map', name: 'map', widget: 'map' } - { label: 'Text', name: 'text', widget: 'text', hint: 'Plain text, not markdown' } - - { label: 'Number', name: 'number', widget: 'number', hint: 'To infinity and beyond!' } + - { + label: 'Number', + name: 'number', + widget: 'number', + suffix: 'px', + hint: 'To infinity and beyond!' + } - { label: 'Markdown', name: 'markdown', widget: 'markdown' } - { label: 'Datetime', name: 'datetime', widget: 'datetime' } - { label: 'Image', name: 'image', widget: 'image' } diff --git a/packages/decap-cms-widget-boolean/src/BooleanControl.js b/packages/decap-cms-widget-boolean/src/BooleanControl.js index 3667c1bd372a..33834f8c3b75 100644 --- a/packages/decap-cms-widget-boolean/src/BooleanControl.js +++ b/packages/decap-cms-widget-boolean/src/BooleanControl.js @@ -4,6 +4,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { css } from '@emotion/react'; import { Toggle, ToggleBackground, colors } from 'decap-cms-ui-default'; +const innerWrapper = css` + display: flex; + align-items: center; +`; + function BooleanBackground({ isActive, ...props }) { return ( - +
+ {prefix && {prefix} } + + {suffix &&  {suffix}} +
); } } - -BooleanControl.propTypes = { - field: ImmutablePropTypes.map.isRequired, - onChange: PropTypes.func.isRequired, - classNameWrapper: PropTypes.string.isRequired, - setActiveStyle: PropTypes.func.isRequired, - setInactiveStyle: PropTypes.func.isRequired, - forID: PropTypes.string, - value: PropTypes.bool, -}; - -BooleanControl.defaultProps = { - value: false, -}; diff --git a/packages/decap-cms-widget-boolean/src/__tests__/boolean.spec.js b/packages/decap-cms-widget-boolean/src/__tests__/boolean.spec.js new file mode 100644 index 000000000000..873869a6a639 --- /dev/null +++ b/packages/decap-cms-widget-boolean/src/__tests__/boolean.spec.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { fromJS } from 'immutable'; +import { render } from '@testing-library/react'; + +import { DecapCmsWidgetBoolean } from '../'; + +const BooleanControl = DecapCmsWidgetBoolean.controlComponent; + +function setup({ field, value = false }) { + const onChangeSpy = jest.fn(); + const setActiveSpy = jest.fn(); + const setInactiveSpy = jest.fn(); + + const helpers = render( + , + ); + + return { + ...helpers, + onChangeSpy, + setActiveSpy, + setInactiveSpy, + }; +} + +describe('Boolean widget', () => { + it('should render the toggle without prefix or suffix by default', () => { + const field = fromJS({ name: 'test' }); + const { queryByText } = setup({ field }); + + expect(queryByText('OFF')).toBeNull(); + expect(queryByText('ON')).toBeNull(); + }); + + it('should render prefix text when prefix is configured', () => { + const field = fromJS({ name: 'test', prefix: 'OFF' }); + const { getByText, queryByText } = setup({ field }); + + expect(getByText(/OFF/)).toBeInTheDocument(); + expect(queryByText('ON')).toBeNull(); + }); + + it('should render suffix text when suffix is configured', () => { + const field = fromJS({ name: 'test', suffix: 'ON' }); + const { getByText, queryByText } = setup({ field }); + + expect(queryByText('OFF')).toBeNull(); + expect(getByText(/ON/)).toBeInTheDocument(); + }); + + it('should render both prefix and suffix when both are configured', () => { + const field = fromJS({ name: 'test', prefix: 'OFF', suffix: 'ON' }); + const { getByText } = setup({ field }); + + expect(getByText(/OFF/)).toBeInTheDocument(); + expect(getByText(/ON/)).toBeInTheDocument(); + }); + + it('should not render prefix or suffix for empty strings', () => { + const field = fromJS({ name: 'test', prefix: '', suffix: '' }); + const { queryByText } = setup({ field }); + + expect(queryByText('OFF')).toBeNull(); + expect(queryByText('ON')).toBeNull(); + }); +}); diff --git a/packages/decap-cms-widget-number/src/NumberControl.js b/packages/decap-cms-widget-number/src/NumberControl.js index 2d74dcabd182..e749798b208b 100644 --- a/packages/decap-cms-widget-number/src/NumberControl.js +++ b/packages/decap-cms-widget-number/src/NumberControl.js @@ -1,6 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { css } from '@emotion/react'; + const ValidationErrorTypes = { PRESENCE: 'PRESENCE', PATTERN: 'PATTERN', @@ -8,6 +10,11 @@ const ValidationErrorTypes = { CUSTOM: 'CUSTOM', }; +const innerWrapper = css` + display: flex; + align-items: baseline; +`; + export function validateMinMax(value, min, max, field, t) { let error; @@ -105,19 +112,31 @@ export default class NumberControl extends React.Component { const min = field.get('min', ''); const max = field.get('max', ''); const step = field.get('step', field.get('value_type') === 'int' ? 1 : ''); + + const prefix = field.get('prefix', ''); + const suffix = field.get('suffix', ''); + return ( - +
+
+ {prefix && {prefix} } + + {suffix &&  {suffix}} +
+
); } } diff --git a/packages/decap-cms-widget-number/src/__tests__/number.spec.js b/packages/decap-cms-widget-number/src/__tests__/number.spec.js index bab212c8348d..0bd0b44b9cce 100644 --- a/packages/decap-cms-widget-number/src/__tests__/number.spec.js +++ b/packages/decap-cms-widget-number/src/__tests__/number.spec.js @@ -211,4 +211,44 @@ describe('Number widget', () => { expect(error).toBeNull(); }); }); + + describe('prefix and suffix', () => { + it('should not render prefix or suffix by default', () => { + const field = fromJS(fieldSettings); + const { queryByText } = setup({ field }); + + expect(queryByText('$')).toBeNull(); + expect(queryByText('px')).toBeNull(); + }); + + it('should render prefix text when prefix is configured', () => { + const field = fromJS({ ...fieldSettings, prefix: '$' }); + const { getByText } = setup({ field }); + + expect(getByText(/\$/)).toBeInTheDocument(); + }); + + it('should render suffix text when suffix is configured', () => { + const field = fromJS({ ...fieldSettings, suffix: 'px' }); + const { getByText } = setup({ field }); + + expect(getByText(/px/)).toBeInTheDocument(); + }); + + it('should render both prefix and suffix when both are configured', () => { + const field = fromJS({ ...fieldSettings, prefix: '$', suffix: 'USD' }); + const { getByText } = setup({ field }); + + expect(getByText(/\$/)).toBeInTheDocument(); + expect(getByText(/USD/)).toBeInTheDocument(); + }); + + it('should not render prefix or suffix for empty strings', () => { + const field = fromJS({ ...fieldSettings, prefix: '', suffix: '' }); + const { queryByText } = setup({ field }); + + expect(queryByText('$')).toBeNull(); + expect(queryByText('USD')).toBeNull(); + }); + }); }); diff --git a/packages/decap-cms-widget-number/src/schema.js b/packages/decap-cms-widget-number/src/schema.js index 4f2f16004831..fc905d76ddf5 100644 --- a/packages/decap-cms-widget-number/src/schema.js +++ b/packages/decap-cms-widget-number/src/schema.js @@ -4,5 +4,7 @@ export default { value_type: { type: 'string' }, min: { type: 'number' }, max: { type: 'number' }, + prefix: { type: 'string' }, + suffix: { type: 'string' }, }, }; diff --git a/packages/decap-cms-widget-string/src/StringControl.js b/packages/decap-cms-widget-string/src/StringControl.js index 864d7274fd6b..8884fb3f7fc7 100644 --- a/packages/decap-cms-widget-string/src/StringControl.js +++ b/packages/decap-cms-widget-string/src/StringControl.js @@ -1,8 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { css } from '@emotion/react'; + +const innerWrapper = css` + display: flex; + align-items: baseline; +`; export default class StringControl extends React.Component { static propTypes = { + field: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, forID: PropTypes.string, value: PropTypes.node, @@ -46,21 +54,32 @@ export default class StringControl extends React.Component { }; render() { - const { forID, value, classNameWrapper, setActiveStyle, setInactiveStyle } = this.props; + const { field, forID, value, classNameWrapper, setActiveStyle, setInactiveStyle } = this.props; + + const prefix = field.get('prefix', ''); + const suffix = field.get('suffix', ''); return ( - { - this._el = el; - }} - type="text" - id={forID} - className={classNameWrapper} - value={value || ''} - onChange={this.handleChange} - onFocus={setActiveStyle} - onBlur={setInactiveStyle} - /> +
+
+ {prefix && {prefix} } + { + this._el = el; + }} + type="text" + id={forID} + value={value || ''} + onChange={this.handleChange} + onFocus={setActiveStyle} + onBlur={setInactiveStyle} + css={css` + flex-grow: 1; + `} + /> + {suffix &&  {suffix}} +
+
); } } diff --git a/packages/decap-cms-widget-string/src/__tests__/string.spec.js b/packages/decap-cms-widget-string/src/__tests__/string.spec.js new file mode 100644 index 000000000000..19a321870e9a --- /dev/null +++ b/packages/decap-cms-widget-string/src/__tests__/string.spec.js @@ -0,0 +1,99 @@ +import React from 'react'; +import { fromJS } from 'immutable'; +import { render, fireEvent } from '@testing-library/react'; + +import { DecapCmsWidgetString } from '../'; + +const StringControl = DecapCmsWidgetString.controlComponent; + +function setup({ field, value = '' }) { + const onChangeSpy = jest.fn(); + const setActiveSpy = jest.fn(); + const setInactiveSpy = jest.fn(); + + const helpers = render( + , + ); + + const input = helpers.container.querySelector('input'); + + return { + ...helpers, + onChangeSpy, + setActiveSpy, + setInactiveSpy, + input, + }; +} + +describe('String widget', () => { + it('should call onChange when input changes', () => { + const field = fromJS({ name: 'test' }); + const { input, onChangeSpy } = setup({ field }); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'hello' } }); + + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenCalledWith('hello'); + }); + + describe('prefix and suffix', () => { + it('should not render prefix or suffix by default', () => { + const field = fromJS({ name: 'test' }); + const { queryByText } = setup({ field }); + + expect(queryByText('Title:')).toBeNull(); + expect(queryByText('(required)')).toBeNull(); + }); + + it('should render prefix text when prefix is configured', () => { + const field = fromJS({ name: 'test', prefix: 'Title:' }); + const { getByText } = setup({ field }); + + expect(getByText(/Title:/)).toBeInTheDocument(); + }); + + it('should render suffix text when suffix is configured', () => { + const field = fromJS({ name: 'test', suffix: '(required)' }); + const { getByText } = setup({ field }); + + expect(getByText(/\(required\)/)).toBeInTheDocument(); + }); + + it('should render both prefix and suffix when both are configured', () => { + const field = fromJS({ name: 'test', prefix: 'Start:', suffix: ':End' }); + const { getByText } = setup({ field }); + + expect(getByText(/Start:/)).toBeInTheDocument(); + expect(getByText(/:End/)).toBeInTheDocument(); + }); + + it('should not render prefix or suffix for empty strings', () => { + const field = fromJS({ name: 'test', prefix: '', suffix: '' }); + const { queryByText } = setup({ field }); + + expect(queryByText('Start:')).toBeNull(); + expect(queryByText(':End')).toBeNull(); + }); + + it('should still call onChange when prefix and suffix are configured', () => { + const field = fromJS({ name: 'test', prefix: 'Pre', suffix: 'Suf' }); + const { input, onChangeSpy } = setup({ field }); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'test value' } }); + + expect(onChangeSpy).toHaveBeenCalledTimes(1); + expect(onChangeSpy).toHaveBeenCalledWith('test value'); + }); + }); +}); diff --git a/packages/decap-server/package.json b/packages/decap-server/package.json index c2e752aafc99..f81857d8ab5d 100644 --- a/packages/decap-server/package.json +++ b/packages/decap-server/package.json @@ -51,7 +51,5 @@ "engines": { "node": ">=v10.22.1" }, - "bin": { - "decap-server": "./dist/index.js" - } + "bin": "./dist/index.js" }