Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 42 additions & 13 deletions src/components/PushAudienceDialog/PushAudienceDialog.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand All @@ -58,7 +58,7 @@ export default class PushAudienceDialog extends React.Component {
audienceName: '',
audienceSize: undefined,
approximate: false,
errorMessage: undefined,
errorMessage: props.errorMessage,
};
}

Expand All @@ -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;
Expand All @@ -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));
}
Expand All @@ -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)
);
Expand Down Expand Up @@ -284,11 +307,19 @@ export default class PushAudienceDialog extends React.Component {
/>
<div className={styles.filter}>
<Filter
schema={this.props.schema}
className="_Installation"
schema={{ _Installation: this.props.schema }}
allClasses={{ _Installation: this.props.schema }}
filters={this.state.filters}
onChange={filters => {
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 => <InstallationCondition {...props} />}
/>
</div>
Expand All @@ -305,13 +336,10 @@ export default class PushAudienceDialog extends React.Component {
</div>
{futureUseSegment}
<FormNote
show={Boolean(
(this.props.errorMessage && this.props.errorMessage.length > 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}
</FormNote>
</Modal>
);
Expand All @@ -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.'),
};
3 changes: 3 additions & 0 deletions src/lib/Filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
22 changes: 22 additions & 0 deletions src/lib/tests/Filters.findRelatedClasses.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
158 changes: 158 additions & 0 deletions src/lib/tests/PushAudienceDialog.test.js
Original file line number Diff line number Diff line change
@@ -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(
<PushAudienceDialog {...defaultProps} audienceInfo={audienceInfo} schema={schema} {...props} />
);
}

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