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);
+ });
+});