diff --git a/src/components/PushAudienceDialog/PushAudienceDialog.react.js b/src/components/PushAudienceDialog/PushAudienceDialog.react.js index bd13bc2ce5..83e5e8fb2f 100644 --- a/src/components/PushAudienceDialog/PushAudienceDialog.react.js +++ b/src/components/PushAudienceDialog/PushAudienceDialog.react.js @@ -47,8 +47,8 @@ const filterFormatter = (filters, schema) => { export default class PushAudienceDialog extends React.Component { static contextType = CurrentApp; - constructor() { - super(); + constructor(props) { + super(props); this.xhrHandle = null; this.state = { platforms: [], @@ -58,7 +58,7 @@ export default class PushAudienceDialog extends React.Component { audienceName: '', audienceSize: undefined, approximate: false, - errorMessage: undefined, + errorMessage: props.errorMessage, }; } @@ -72,7 +72,9 @@ export default class PushAudienceDialog extends React.Component { stateSettings.platforms = deviceType.$in || []; } if (audienceInfo.filters) { - stateSettings.filters = audienceInfo.filters; + stateSettings.filters = audienceInfo.filters.map(filter => + filter.get('class') ? filter : filter.set('class', '_Installation') + ); } if (audienceInfo.name) { stateSettings.audienceName = audienceInfo.name; @@ -87,6 +89,15 @@ export default class PushAudienceDialog extends React.Component { } } + componentDidUpdate(prevProps) { + if ( + prevProps.errorMessage !== this.props.errorMessage && + this.state.errorMessage !== this.props.errorMessage + ) { + this.setState({ errorMessage: this.props.errorMessage }); + } + } + handleChange(newValue) { this.setState({ platforms: newValue }, this.fetchAudienceSize.bind(this)); } @@ -100,10 +111,22 @@ export default class PushAudienceDialog extends React.Component { return; } const available = Filters.availableFilters(this.props.schema, this.state.filters); - const field = Object.keys(available)[0]; + + const keys = Object.keys(available); + if (keys.length === 0) { + this.setState({ + errorMessage: 'No condition available.', + }); + return; + } + + const field = keys[0]; this.setState( ({ filters }) => ({ - filters: filters.push(new Map({ field: field, constraint: available[field][0] })), + filters: filters.push( + new Map({ class: '_Installation', field: field, constraint: available[field][0] }) + ), + errorMessage: undefined, }), this.fetchAudienceSize.bind(this) ); @@ -284,11 +307,19 @@ export default class PushAudienceDialog extends React.Component { />
{ - this.setState({ filters }, this.fetchAudienceSize.bind(this)); + this.setState( + { filters, errorMessage: undefined }, + this.fetchAudienceSize.bind(this) + ); }} + onSearch={() => + this.setState({ errorMessage: undefined }, this.fetchAudienceSize.bind(this)) + } renderRow={props => } />
@@ -305,13 +336,10 @@ export default class PushAudienceDialog extends React.Component { {futureUseSegment} 0) || - (this.state.errorMessage && this.state.errorMessage.length > 0) - )} + show={Boolean(this.state.errorMessage && this.state.errorMessage.length > 0)} color="red" > - {this.props.errorMessage || this.state.errorMessage} + {this.state.errorMessage} ); @@ -338,4 +366,5 @@ PushAudienceDialog.propTypes = { availableDevices: PropTypes.arrayOf(PropTypes.string).describe( 'List of all availableDevices devices for push notifications.' ), + errorMessage: PropTypes.string.describe('Error message to display in the dialog.'), }; diff --git a/src/lib/Filters.js b/src/lib/Filters.js index 56533e0038..9c67651414 100644 --- a/src/lib/Filters.js +++ b/src/lib/Filters.js @@ -260,6 +260,9 @@ export function availableFilters(schema, currentFilters, blacklist) { export function findRelatedClasses(referClass, allClasses, blacklist, currentFilters) { const relatedClasses = {}; + if (!allClasses) { + return relatedClasses; + } if (allClasses[referClass]) { const availableForRefer = availableFilters(allClasses[referClass], currentFilters, blacklist); if (Object.keys(availableForRefer).length > 0) { diff --git a/src/lib/tests/Filters.findRelatedClasses.test.js b/src/lib/tests/Filters.findRelatedClasses.test.js new file mode 100644 index 0000000000..9aa7453c97 --- /dev/null +++ b/src/lib/tests/Filters.findRelatedClasses.test.js @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +const { findRelatedClasses } = require('../Filters'); + +describe('findRelatedClasses', () => { + it('returns an empty object when all classes are undefined', () => { + expect(findRelatedClasses('_Installation', undefined, [], undefined)).toEqual({}); + }); + + it('returns the available filters for the referenced class', () => { + const allClasses = { _Installation: { deviceType: { type: 'String' } } }; + const result = findRelatedClasses('_Installation', allClasses, [], undefined); + + expect(result._Installation).toBeDefined(); + expect(result._Installation.deviceType).toContain('exists'); + }); +}); diff --git a/src/lib/tests/PushAudienceDialog.test.js b/src/lib/tests/PushAudienceDialog.test.js new file mode 100644 index 0000000000..e9988c8f6d --- /dev/null +++ b/src/lib/tests/PushAudienceDialog.test.js @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +jest.dontMock('../../components/PushAudienceDialog/PushAudienceDialog.react'); +jest.mock('../../components/Filter/Filter.react'); +jest.mock('../../components/MultiSelect/MultiSelect.react'); +jest.mock('../../components/Popover/Popover.react', () => 'div'); +jest.mock('context/currentApp', () => require('../../context/currentApp'), { virtual: true }); + +const Filter = require('../../components/Filter/Filter.react').default; +const FormNote = require('../../components/FormNote/FormNote.react').default; +const PushAudienceDialog = + require('../../components/PushAudienceDialog/PushAudienceDialog.react').default; +const React = require('react'); +const { act } = React; +const { List, Map } = require('immutable'); +const { renderComponent } = require('./renderWithAct'); + +const defaultProps = { + availableDevices: [], + primaryAction: jest.fn(), + secondaryAction: jest.fn(), +}; + +function renderDialog(schema, audienceInfo, props = {}) { + return renderComponent( + + ); +} + +describe('PushAudienceDialog', () => { + beforeEach(() => { + jest.spyOn(PushAudienceDialog.prototype, 'fetchAudienceSize').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('configures the shared filter for the Installation class', () => { + const schema = { + deviceType: { type: 'String' }, + }; + const component = renderDialog(schema); + const filter = component.root.findByType(Filter); + + expect(filter.props.className).toBe('_Installation'); + expect(filter.props.schema).toEqual({ _Installation: schema }); + expect(filter.props.allClasses).toEqual({ _Installation: schema }); + }); + + it('adds an available audience condition for the Installation class', () => { + const component = renderDialog({ + deviceType: { type: 'String' }, + }); + const dialog = component.getInstance(); + + act(() => { + dialog.setState({ errorMessage: 'No condition available.' }); + }); + act(() => { + dialog.handleAddCondition(); + }); + + expect(dialog.state.filters.size).toBe(1); + expect(dialog.state.filters.getIn([0, 'class'])).toBe('_Installation'); + expect(dialog.state.filters.getIn([0, 'field'])).toBe('deviceType'); + expect(dialog.state.filters.getIn([0, 'constraint'])).toBe('exists'); + expect(dialog.state.errorMessage).toBeUndefined(); + expect(dialog.fetchAudienceSize).toHaveBeenCalledTimes(1); + }); + + it('shows an error when no audience condition is available', () => { + const component = renderDialog({ + unsupported: { type: 'File' }, + }); + const dialog = component.getInstance(); + + act(() => { + dialog.handleAddCondition(); + }); + + expect(dialog.state.filters.size).toBe(0); + expect(dialog.state.errorMessage).toBe('No condition available.'); + }); + + it('normalizes persisted audience filters for the Installation class', () => { + const filters = new List([ + new Map({ + field: 'deviceType', + constraint: 'exists', + }), + ]); + const component = renderDialog( + { deviceType: { type: 'String' } }, + { + filters, + } + ); + const dialog = component.getInstance(); + + expect(dialog.state.filters.getIn([0, 'class'])).toBe('_Installation'); + expect(dialog.fetchAudienceSize).toHaveBeenCalledTimes(1); + }); + + it('clears stale errors when filters change', () => { + const schema = { + deviceType: { type: 'String' }, + }; + const component = renderDialog(schema); + const dialog = component.getInstance(); + const filters = new List([ + new Map({ + class: '_Installation', + field: 'deviceType', + constraint: 'exists', + }), + ]); + act(() => { + dialog.setState({ errorMessage: 'No condition available.' }); + }); + const filter = component.root.findByType(Filter); + + act(() => { + filter.props.onChange(filters); + }); + + expect(dialog.state.filters).toBe(filters); + expect(dialog.state.errorMessage).toBeUndefined(); + expect(dialog.fetchAudienceSize).toHaveBeenCalledTimes(1); + }); + + it('clears stale errors when filters are searched', () => { + const component = renderDialog( + { + deviceType: { type: 'String' }, + }, + undefined, + { errorMessage: 'Request failed.' } + ); + const dialog = component.getInstance(); + const filter = component.root.findByType(Filter); + + expect(dialog.state.errorMessage).toBe('Request failed.'); + + act(() => { + filter.props.onSearch(); + }); + + expect(dialog.state.errorMessage).toBeUndefined(); + expect(component.root.findByType(FormNote).props.show).toBe(false); + expect(dialog.fetchAudienceSize).toHaveBeenCalledTimes(1); + }); +});