diff --git a/cypress/e2e/common/i18n.js b/cypress/e2e/common/i18n.js index 140980b26325..39b4239e1dd7 100644 --- a/cypress/e2e/common/i18n.js +++ b/cypress/e2e/common/i18n.js @@ -34,7 +34,7 @@ export const updateTranslation = () => { enterTranslation('de de'); }); - cy.contains('button', 'Save').click(); + flushClockAndSave(); }; export const assertTranslation = () => { diff --git a/cypress/e2e/editorial_workflow_spec_test_backend.js b/cypress/e2e/editorial_workflow_spec_test_backend.js index 7d5fae36a968..423e8c6fbc00 100644 --- a/cypress/e2e/editorial_workflow_spec_test_backend.js +++ b/cypress/e2e/editorial_workflow_spec_test_backend.js @@ -24,6 +24,8 @@ import { publishAndDuplicateEntryInEditor, assertNotification, assertFieldValidationError, + advanceClock, + flushClockAndSave, } from '../utils/steps'; import { workflowStatus, editorStatus, publishTypes, notifications } from '../utils/constants'; @@ -253,10 +255,8 @@ describe('Test Backend Editorial Workflow', () => { cy.get('[id^="path-field"]').should('have.value', 'directory/sub-directory'); cy.get('[id^="path-field"]').type('/new-path'); cy.get('[id^="title-field"]').type('New Path Title'); - cy.clock().then(clock => { - clock.tick(150); - }); - cy.contains('button', 'Save').click(); + cy.clock().then(clock => { advanceClock(clock); }); + flushClockAndSave(); assertNotification(notifications.saved); updateWorkflowStatusInEditor(editorStatus.ready); publishEntryInEditor(publishTypes.publishNow); diff --git a/cypress/utils/steps.js b/cypress/utils/steps.js index 464365f802b6..5af0816eaf08 100644 --- a/cypress/utils/steps.js +++ b/cypress/utils/steps.js @@ -449,12 +449,12 @@ function validateObjectFields({ limit, author }) { cy.get('a[href^="#/collections/settings"]').click(); cy.get('a[href^="#/collections/settings/entries/general"]').click(); cy.get('input[type=number]').type(limit); - cy.contains('button', 'Save').click(); + flushClockAndSave(); assertNotification(notifications.error.missingField); assertFieldErrorStatus('Default Author', colorError); cy.contains('label', 'Default Author').click(); cy.focused().type(author); - cy.contains('button', 'Save').click(); + flushClockAndSave(); assertNotification(notifications.saved); assertFieldErrorStatus('Default Author', colorNormal); } @@ -464,20 +464,20 @@ function validateNestedObjectFields({ limit, author }) { cy.get('a[href^="#/collections/settings/entries/general"]').click(); cy.contains('label', 'Default Author').click(); cy.focused().type(author); - cy.contains('button', 'Save').click(); + flushClockAndSave(); assertNotification(notifications.error.missingField); cy.get('input[type=number]').type(limit + 1); - cy.contains('button', 'Save').click(); + flushClockAndSave(); assertFieldValidationError(notifications.validation.range); cy.get('input[type=number]') .clear() .type(-1); - cy.contains('button', 'Save').click(); + flushClockAndSave(); assertFieldValidationError(notifications.validation.range); cy.get('input[type=number]') .clear() .type(limit); - cy.contains('button', 'Save').click(); + flushClockAndSave(); assertNotification(notifications.saved); } @@ -485,7 +485,7 @@ function validateListFields({ name, description }) { cy.get('a[href^="#/collections/settings"]').click(); cy.get('a[href^="#/collections/settings/entries/authors"]').click(); cy.contains('button', 'Add').click(); - cy.contains('button', 'Save').click(); + flushClockAndSave(); assertNotification(notifications.error.missingField); assertFieldErrorStatus('Authors', colorError); cy.get('div[class*=SortableListItem]') @@ -500,10 +500,10 @@ function validateListFields({ name, description }) { }); assertListControlErrorStatus([colorError, colorError], '@listControl'); cy.get('input') - .eq(2) + .last() .type(name); cy.getMarkdownEditor() - .eq(2) + .last() .type(description); flushClockAndSave(); assertNotification(notifications.saved); @@ -718,4 +718,5 @@ module.exports = { assertPublishedEntryInEditor, assertUnpublishedEntryInEditor, assertUnpublishedChangesInEditor, + advanceClock, }; diff --git a/packages/decap-cms-widget-code/src/CodeControl.js b/packages/decap-cms-widget-code/src/CodeControl.js index efea35111aa8..e6c66fd72eb2 100644 --- a/packages/decap-cms-widget-code/src/CodeControl.js +++ b/packages/decap-cms-widget-code/src/CodeControl.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { ClassNames } from '@emotion/react'; import { Map } from 'immutable'; -import { uniq, isEqual, isEmpty } from 'lodash'; +import { uniq, isEqual, isEmpty, debounce } from 'lodash'; import { v4 as uuid } from 'uuid'; import { UnControlled as ReactCodeMirror } from 'react-codemirror2'; import CodeMirror from 'codemirror'; @@ -115,6 +115,8 @@ export default class CodeControl extends React.Component { } } + debounceOnChange = debounce(value => this.props.onChange(value), 300); + updateCodeMirrorProps(prevState) { const keys = ['lang', 'theme', 'keyMap']; const changedProps = getChangedProps(prevState, this.state, keys); @@ -188,8 +190,6 @@ export default class CodeControl extends React.Component { } async handleChangeCodeMirrorProps(changedProps) { - const { onChange } = this.props; - if (changedProps.lang) { const { mode } = this.getLanguageByName(changedProps.lang) || {}; if (mode) { @@ -218,7 +218,7 @@ export default class CodeControl extends React.Component { // Only persist the language change if supported - requires the value to be // a map rather than just a code string. if (changedProps.lang && this.valueIsMap()) { - onChange(this.toValue('lang', changedProps.lang)); + this.debounceOnChange(this.toValue('lang', changedProps.lang)); } } @@ -226,7 +226,7 @@ export default class CodeControl extends React.Component { const cursor = this.cm.doc.getCursor(); const selections = this.cm.doc.listSelections(); this.setState({ lastKnownValue: newValue }); - this.props.onChange(this.toValue('code', newValue), { cursor, selections }); + this.debounceOnChange(this.toValue('code', newValue), { cursor, selections }); } showSettings = () => { diff --git a/packages/decap-cms-widget-colorstring/src/ColorControl.js b/packages/decap-cms-widget-colorstring/src/ColorControl.js index 5b1e83caa8a3..16f0a46eb65c 100644 --- a/packages/decap-cms-widget-colorstring/src/ColorControl.js +++ b/packages/decap-cms-widget-colorstring/src/ColorControl.js @@ -1,9 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; import styled from '@emotion/styled'; import ChromePicker from 'react-color'; import tinycolor from 'tinycolor2'; import { zIndex } from 'decap-cms-ui-default'; +import { debounce } from 'lodash'; function ClearIcon() { return ( @@ -78,6 +80,7 @@ const ClickOutsideDiv = styled.div` export default class ColorControl extends React.Component { static propTypes = { + field: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, forID: PropTypes.string, value: PropTypes.node, @@ -91,28 +94,44 @@ export default class ColorControl extends React.Component { }; state = { + value: this.props.value, showColorPicker: false, }; + + debounceOnChange = debounce(value => this.props.onChange(value), 300); + // show/hide color picker handleClick = () => { this.setState({ showColorPicker: !this.state.showColorPicker }); }; + handleClear = () => { - this.props.onChange(''); + this.setState({ value: '' }); + this.debounceOnChange(''); }; + handleClose = () => { this.setState({ showColorPicker: false }); }; + + handleInputChange = e => { + const { value } = e.target; + this.setState({ value }); + this.debounceOnChange(value); + }; + handleChange = color => { const formattedColor = color.rgb.a < 1 ? `rgba(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b}, ${color.rgb.a})` : color.hex; - this.props.onChange(formattedColor); + this.setState({ value: formattedColor }); + this.debounceOnChange(formattedColor); }; + render() { - const { forID, value, field, onChange, classNameWrapper, setActiveStyle, setInactiveStyle } = - this.props; + const { forID, field, classNameWrapper, setActiveStyle, setInactiveStyle } = this.props; + const { value } = this.state; const allowInput = field.get('allowInput', false); @@ -123,7 +142,7 @@ export default class ColorControl extends React.Component { <> {' '} {showClearButton && ( - + @@ -140,8 +159,8 @@ export default class ColorControl extends React.Component { ? {this.state.showColorPicker && ( - - + + onChange(e.target.value)} + onChange={this.handleInputChange} onFocus={setActiveStyle} onBlur={setInactiveStyle} style={{ diff --git a/packages/decap-cms-widget-datetime/src/DateTimeControl.js b/packages/decap-cms-widget-datetime/src/DateTimeControl.js index 8dcfc496c1f7..a2d20e4346e4 100644 --- a/packages/decap-cms-widget-datetime/src/DateTimeControl.js +++ b/packages/decap-cms-widget-datetime/src/DateTimeControl.js @@ -7,6 +7,7 @@ import customParseFormat from 'dayjs/plugin/customParseFormat'; import localizedFormat from 'dayjs/plugin/localizedFormat'; import utc from 'dayjs/plugin/utc'; import { buttons } from 'decap-cms-ui-default'; +import { debounce } from 'lodash'; dayjs.extend(customParseFormat); dayjs.extend(localizedFormat); @@ -98,6 +99,7 @@ class DateTimeControl extends React.Component { isUtc = this.props.field.get('picker_utc') || false; isValidDate = datetime => dayjs(datetime).isValid() || datetime === ''; defaultValue = this.getDefaultValue(); + state = { value: this.defaultValue }; componentDidMount() { const { value } = this.props; @@ -124,17 +126,20 @@ class DateTimeControl extends React.Component { return this.isUtc ? dayjs.utc(value).format(inputFormat) : dayjs(value).format(inputFormat); } + onChange = debounce(value => this.props.onChange(value), 300); + handleChange = datetime => { if (!this.isValidDate(datetime)) return; - const { onChange } = this.props; if (datetime === '') { - onChange(''); + this.onChange(''); } else { const { format, inputFormat } = this.getFormat(); const formattedValue = dayjs(datetime, inputFormat).format(format); - onChange(formattedValue); + this.onChange(formattedValue); } + + this.setState({ value: datetime }); }; onInputChange = e => { @@ -144,8 +149,9 @@ class DateTimeControl extends React.Component { }; render() { - const { forID, value, classNameWrapper, setActiveStyle, setInactiveStyle, t, isDisabled } = + const { forID, classNameWrapper, setActiveStyle, setInactiveStyle, t, isDisabled } = this.props; + const { value } = this.state const { inputType, inputFormat } = this.getFormat(); return ( diff --git a/packages/decap-cms-widget-number/src/NumberControl.js b/packages/decap-cms-widget-number/src/NumberControl.js index 35606d9e4211..d4f219216c5c 100644 --- a/packages/decap-cms-widget-number/src/NumberControl.js +++ b/packages/decap-cms-widget-number/src/NumberControl.js @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; const ValidationErrorTypes = { PRESENCE: 'PRESENCE', PATTERN: 'PATTERN', @@ -68,15 +69,20 @@ export default class NumberControl extends React.Component { value: '', }; + state = { value: this.props.value }; + + debounceOnChange = debounce(value => this.props.onChange(value), 300); + handleChange = e => { const valueType = this.props.field.get('value_type'); - const { onChange } = this.props; const value = valueType === 'float' ? parseFloat(e.target.value) : parseInt(e.target.value, 10); - if (!isNaN(value)) { - onChange(value); + if (isNaN(value)) { + this.setState({ value: '' }); + this.debounceOnChange(''); } else { - onChange(''); + this.setState({ value }); + this.debounceOnChange(value); } }; @@ -96,7 +102,8 @@ export default class NumberControl extends React.Component { }; render() { - const { field, value, classNameWrapper, forID, setActiveStyle, setInactiveStyle } = this.props; + const { field, classNameWrapper, forID, setActiveStyle, setInactiveStyle } = this.props; + const { value } = this.state; const min = field.get('min', ''); const max = field.get('max', ''); const step = field.get('step', field.get('value_type') === 'int' ? 1 : ''); 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..4dc973f7eef7 100644 --- a/packages/decap-cms-widget-number/src/__tests__/number.spec.js +++ b/packages/decap-cms-widget-number/src/__tests__/number.spec.js @@ -63,6 +63,8 @@ function setup({ field, defaultValue }) { const input = helpers.container.querySelector('input'); + jest.useFakeTimers(); + return { ...helpers, ...renderArgs, @@ -82,6 +84,8 @@ describe('Number widget', () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: String(testValue) } }); + jest.runAllTimers(); + expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenCalledWith(testValue); }); @@ -93,6 +97,8 @@ describe('Number widget', () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: '' } }); + jest.runAllTimers(); + expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenCalledWith(''); }); @@ -104,6 +110,8 @@ describe('Number widget', () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: 'invalid' } }); + jest.runAllTimers(); + expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenCalledWith(''); }); @@ -116,6 +124,8 @@ describe('Number widget', () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: String(testValue) } }); + jest.runAllTimers(); + expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenCalledWith(parseInt(testValue, 10)); }); @@ -128,6 +138,8 @@ describe('Number widget', () => { fireEvent.focus(input); fireEvent.change(input, { target: { value: String(testValue) } }); + jest.runAllTimers(); + expect(onChangeSpy).toHaveBeenCalledTimes(1); expect(onChangeSpy).toHaveBeenCalledWith(parseFloat(testValue)); }); diff --git a/packages/decap-cms-widget-string/src/StringControl.js b/packages/decap-cms-widget-string/src/StringControl.js index bcb8dd14c2b0..b44b70fd809f 100644 --- a/packages/decap-cms-widget-string/src/StringControl.js +++ b/packages/decap-cms-widget-string/src/StringControl.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { debounce } from 'lodash'; export default class StringControl extends React.Component { static propTypes = { @@ -15,6 +16,10 @@ export default class StringControl extends React.Component { value: '', }; + state = { value: this.props.value }; + + debounceOnChange = debounce(value => this.props.onChange(value), 300); + // The selection to maintain for the input element _sel = 0; @@ -37,11 +42,13 @@ export default class StringControl extends React.Component { handleChange = e => { this._sel = e.target.selectionStart; - this.props.onChange(e.target.value); + const { value } = e.target; + this.setState({ value }); + this.debounceOnChange(value); }; render() { - const { forID, value, classNameWrapper, setActiveStyle, setInactiveStyle } = this.props; + const { forID, classNameWrapper, setActiveStyle, setInactiveStyle } = this.props; return ( , + ); + + const input = helpers.container.querySelector('input'); + + return { + ...helpers, + setActiveSpy, + setInactiveSpy, + onChangeSpy, + input, + }; +} + +describe('String widget', () => { + it('calls setActiveStyle when input focused', () => { + const { input, setActiveSpy } = setup(); + + fireEvent.focus(input); + + expect(setActiveSpy).toBeCalledTimes(1); + }); + + it('calls setInactiveSpy when input blurred', () => { + const { input, setInactiveSpy } = setup(); + + fireEvent.focus(input); + fireEvent.blur(input); + + expect(setInactiveSpy).toBeCalledTimes(1); + }); + + it('renders with default value', () => { + const testValue = 'bar'; + const { input } = setup({ defaultValue: testValue }); + expect(input.value).toEqual(testValue); + }); + + it('calls onChange when input changes', () => { + jest.useFakeTimers(); + const testValue = 'foo'; + const { input, onChangeSpy } = setup(); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: testValue } }); + + jest.runAllTimers(); + + expect(onChangeSpy).toBeCalledTimes(1); + expect(onChangeSpy).toBeCalledWith(testValue); + }); + + it('sets input value', () => { + const testValue = 'foo'; + const { input } = setup({ defaultValue: 'bar' }); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: testValue } }); + + jest.runAllTimers(); + + expect(input.value).toEqual(testValue); + }); +}); diff --git a/packages/decap-cms-widget-text/src/TextControl.js b/packages/decap-cms-widget-text/src/TextControl.js index 26cbe443f87e..7a606af228b3 100644 --- a/packages/decap-cms-widget-text/src/TextControl.js +++ b/packages/decap-cms-widget-text/src/TextControl.js @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import Textarea from 'react-textarea-autosize'; +import { debounce } from 'lodash'; export default class TextControl extends React.Component { static propTypes = { @@ -12,9 +13,11 @@ export default class TextControl extends React.Component { setInactiveStyle: PropTypes.func.isRequired, }; - static defaultProps = { - value: '', - }; + static defaultProps = { value: '' }; + + state = { value: this.props.value }; + + debounceOnChange = debounce(value => this.props.onChange(value), 300); /** * Always update to ensure `react-textarea-autosize` properly calculates @@ -27,20 +30,25 @@ export default class TextControl extends React.Component { return true; } + handleChange = e => { + const { value } = e.target; + this.setState({ value }); + this.debounceOnChange(value); + }; + render() { - const { forID, value, onChange, classNameWrapper, setActiveStyle, setInactiveStyle } = - this.props; + const { forID, classNameWrapper, setActiveStyle, setInactiveStyle } = this.props; return (