From 47f5187bd2aedd6966d34c3e0769ebc6b9753383 Mon Sep 17 00:00:00 2001 From: Peter Bengtsson Date: Wed, 12 Sep 2018 15:52:26 -0400 Subject: [PATCH 01/22] Add UI for Filter objects Fixes #176 --- package.json | 1 + src/components/data/QueryRecipeFilters.js | 27 + src/less/pages/recipe-details.less | 5 + src/setupTests.js | 8 +- src/state/network/actions.js | 2 +- src/state/recipes/actions.js | 29 +- .../components/FilterObjectForm.test.js | 509 +++++++++++ .../recipes/components/FilterObjectForm.js | 803 ++++++++++++++++++ .../recipes/components/RecipeDetails.js | 146 +++- .../recipes/components/RecipeForm.js | 39 +- yarn.lock | 26 + 11 files changed, 1575 insertions(+), 20 deletions(-) create mode 100644 src/components/data/QueryRecipeFilters.js create mode 100644 src/tests/workflows/recipes/components/FilterObjectForm.test.js create mode 100644 src/workflows/recipes/components/FilterObjectForm.js diff --git a/package.json b/package.json index e7e7e7154..7bb31ccd2 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "promise": "8.0.2", "raf": "3.4.0", "react-dev-utils": "6.0.0-next.3e165448", + "react-testing-library": "5.1.0", "resolve": "1.8.1", "style-loader": "0.23.0", "stylelint": "9.6.0", diff --git a/src/components/data/QueryRecipeFilters.js b/src/components/data/QueryRecipeFilters.js new file mode 100644 index 000000000..f50b6fdd9 --- /dev/null +++ b/src/components/data/QueryRecipeFilters.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; + +import { fetchRecipeFilters } from 'console/state/recipes/actions'; + +@connect( + null, + { + fetchRecipeFilters, + }, +) +class QueryRecipeFilters extends React.PureComponent { + static propTypes = { + fetchRecipeFilters: PropTypes.func.isRequired, + }; + + componentDidMount() { + this.props.fetchRecipeFilters(); + } + + render() { + return null; + } +} + +export default QueryRecipeFilters; diff --git a/src/less/pages/recipe-details.less b/src/less/pages/recipe-details.less index 039453b87..1b8f8427c 100644 --- a/src/less/pages/recipe-details.less +++ b/src/less/pages/recipe-details.less @@ -32,6 +32,11 @@ dl.details { } } + dt.no-padding, + dd.no-padding { + padding: 0; + } + .arguments-value { .value { float: left; diff --git a/src/setupTests.js b/src/setupTests.js index 2844a7926..f39c9a033 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -1,10 +1,11 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { configure, mount, shallow } from 'enzyme'; +import { cleanup } from 'react-testing-library'; import Adapter from 'enzyme-adapter-react-16'; import * as immutableMatchers from 'jest-immutable-matchers'; import fetchMock from 'fetch-mock'; - +import { Headers } from 'whatwg-fetch'; // Configure Enzyme adapter configure({ adapter: new Adapter() }); @@ -40,10 +41,12 @@ global.auth0 = { })), }; global.localStorage = mockLocalStorage(); +global.sessionStorage = mockLocalStorage(); global.mount = mount; global.shallow = shallow; global.React = React; global.ReactDOM = ReactDOM; +global.Headers = Headers; // Set up a global fetch mock, and assert that all calls to it are expected. beforeEach(() => { @@ -60,4 +63,7 @@ afterEach(() => { } finally { fetchMock.restore(); } + + // Unmounts React trees that were mounted with react-testing-library's render. + cleanup(); }); diff --git a/src/state/network/actions.js b/src/state/network/actions.js index d24d46429..a60082520 100644 --- a/src/state/network/actions.js +++ b/src/state/network/actions.js @@ -38,7 +38,7 @@ export function makeApiRequest(requestId, root, endpoint, options = {}) { error, }); - throw error; + throw new Error(error); } dispatch({ diff --git a/src/state/recipes/actions.js b/src/state/recipes/actions.js index c9a1abce6..6b301bee8 100644 --- a/src/state/recipes/actions.js +++ b/src/state/recipes/actions.js @@ -145,13 +145,30 @@ export function fetchRecipeHistory(pk) { export function fetchRecipeFilters() { return async dispatch => { + const localStorageKey = 'recipe_filters'; + const localFilters = window.localStorage.getItem(localStorageKey); + if (localFilters) { + // If the filters *were* in localStorage, it's a JSON *string*. + dispatch({ + type: RECIPE_FILTERS_RECEIVE, + filters: JSON.parse(localFilters), + }); + } + // XXX If we already had the filters in localStorage, can we avoid using this + // makeNormandyApiRequest() with an ID so that it doesn't cause any spinners to appear + // when the localStorage version almost definitely is good enough. const requestId = 'fetch-recipe-filters'; - const filters = await dispatch(makeNormandyApiRequest(requestId, 'v2/filters/')); - - dispatch({ - type: RECIPE_FILTERS_RECEIVE, - filters, - }); + const filters = await dispatch(makeNormandyApiRequest(requestId, 'v3/filters/')); + // After it has been retrieved remotely, it's very possible that the lists + // haven't changed. If it hasn't changed compared to what we had in local Storage, then + // don't bother dispatching again. + if (!localFilters || JSON.stringify(filters) !== localFilters) { + dispatch({ + type: RECIPE_FILTERS_RECEIVE, + filters, + }); + window.localStorage.setItem(localStorageKey, JSON.stringify(filters)); + } }; } diff --git a/src/tests/workflows/recipes/components/FilterObjectForm.test.js b/src/tests/workflows/recipes/components/FilterObjectForm.test.js new file mode 100644 index 000000000..eac652835 --- /dev/null +++ b/src/tests/workflows/recipes/components/FilterObjectForm.test.js @@ -0,0 +1,509 @@ +import { List, fromJS } from 'immutable'; +import { render, fireEvent } from 'react-testing-library'; +import fetchMock from 'fetch-mock'; +import { NORMANDY_ADMIN_API_ROOT_URL } from 'console/settings'; +import FilterObjectForm, { + serializeFilterObjectToMap, + deserializeFilterObjectToList, + InputsWidget, + rewriteSamplingInput, + SamplingInput, + VersionsInput, + parseIntOrNull, +} from 'console/workflows/recipes/components/FilterObjectForm'; +import { createForm } from 'console/utils/forms'; +import { wrapMockStore } from 'console/tests/mockStore'; + +describe('', () => { + it('render editing form with an empty existing filter object', () => { + fetchMock.getOnce(`${NORMANDY_ADMIN_API_ROOT_URL}v3/filters/`, { + channels: [ + { + key: 'beta', + value: 'Beta', + }, + ], + countries: [{ key: 'SE', value: 'Sweden' }], + locales: [{ key: 'sv', value: 'Swedish' }], + }); + + const filterObject = serializeFilterObjectToMap(fromJS([])); + const props = { + disabled: false, + form: { + getFieldValue: key => { + // Remember, 'key' is something like "filter_object.locales" + const value = filterObject.get(key.split('.')[1]); + if (value) { + return value.toJS(); + } + }, + getFieldDecorator: (key, config) => { + return Component => { + return; + }; + }, + }, + filterObject, + onSubmit: jest.fn(), + }; + + const FakeForm = createForm({})(FilterObjectForm); + const { getByText } = render(wrapMockStore()); + expect(getByText('Geo')).toBeTruthy(); + expect(getByText('Browser')).toBeTruthy(); + expect(getByText('Sampling')).toBeTruthy(); + expect(fetchMock.called()).toBeTruthy(); + }); + + it('render editing form with a full existing filter object', () => { + fetchMock.getOnce(`${NORMANDY_ADMIN_API_ROOT_URL}v3/filters/`, { + channels: [ + { + key: 'beta', + value: 'Beta', + }, + ], + countries: [{ key: 'SE', value: 'Sweden' }], + locales: [{ key: 'sv', value: 'Swedish' }], + }); + + const filterObject = serializeFilterObjectToMap( + fromJS([ + { type: 'channel', channels: ['beta'] }, + { type: 'country', countries: ['SE'] }, + { type: 'locale', locales: ['sv'] }, + { type: 'channel', channels: ['beta'] }, + { type: 'version', versions: [67, 68, 69] }, + { type: 'bucketSample', start: 10, count: 20, total: 30, input: [] }, + ]), + ); + const props = { + disabled: false, + form: { + getFieldValue: key => { + // Remember, 'key' is something like "filter_object.locales" + const value = filterObject.get(key.split('.')[1]); + if (value) { + return value.toJS(); + } + }, + getFieldDecorator: (key, config) => { + return Component => { + return; + }; + }, + }, + allChannels: fromJS([{ value: 'Release', key: 'release' }]), + allCountries: fromJS([{ value: 'Sweden', key: 'SE' }]), + allLocales: fromJS([{ value: 'Swedish', key: 'sv' }]), + filterObject, + onSubmit: jest.fn(), + }; + + const FakeForm = createForm({})(FilterObjectForm); + const { getByText } = render(wrapMockStore()); + + // Expect to see "Geo (2)" because a country and a locale is chosen. + expect(getByText('Geo (2)')).toBeTruthy(); + // Same with "Browser (2)" because both channels and versions is chosen. + expect(getByText('Browser (2)')).toBeTruthy(); + // And "Sampling (1)" because a sampling method has been chosen. + expect(getByText('Sampling (1)')).toBeTruthy(); + + expect(fetchMock.called()).toBeTruthy(); + }); + + describe('', () => { + it('serialize an empty List to an empty Map', () => { + const map = serializeFilterObjectToMap(List()); + expect(map).toBeImmutableMap(); + expect(map.size).toEqual(0); + }); + + it('serialize an non-empty List to a Map', () => { + const list = fromJS([ + { type: 'channel', channels: ['beta'] }, + { + type: 'bucketSample', + start: 50, + count: 70, + total: 100, + input: ['normandy.request_time'], + }, + ]); + const map = serializeFilterObjectToMap(list); + expect(map).toBeImmutableMap(); + expect(map.size).toEqual(2); + expect(map.get('channels')).toEqual(List(['beta'])); + expect(map.get('_sampling')).toEqual( + fromJS({ + type: 'bucketSample', + start: 50, + count: 70, + total: 100, + input: ['normandy.request_time'], + }), + ); + }); + + it('serialize an undefined List to an empty Map', () => { + const map = serializeFilterObjectToMap(undefined); + expect(map).toBeImmutableMap(); + expect(map.size).toEqual(0); + }); + + it('throw error on something truthy that is not a List', () => { + expect(() => { + serializeFilterObjectToMap([]); + }).toThrow("'' (object) is not a List"); + }); + it('throw error on an unrecognized key', () => { + const list = fromJS([{ type: 'color', colors: ['red'] }]); + expect(() => { + serializeFilterObjectToMap(list); + }).toThrow("Not sure how to convert 'color'"); + }); + }); + + describe('', () => { + it('deserialize an undefined Map to an empty List', () => { + const map = deserializeFilterObjectToList(undefined); + expect(map).toBeImmutableList(); + expect(map.size).toEqual(0); + }); + + it('serialize an non-empty Map to a List', () => { + const obj = { + channels: ['beta'], + }; + const list = deserializeFilterObjectToList(obj); + expect(list).toBeImmutableList(); + expect(list.size).toEqual(1); + expect(list.first()).toEqual(fromJS({ type: 'channel', channels: ['beta'] })); + }); + + it('throw an error on unrecognized names', () => { + const obj = { + colors: ['red'], + }; + expect(() => { + deserializeFilterObjectToList(obj); + }).toThrow("Key 'colors' no in known mapping."); + }); + + it('serialize a map with exception _sampling', () => { + const obj = { + _sampling: { + type: 'bucketSample', + start: 50, + count: 70, + total: 100, + input: ['normandy.request_time'], + }, + }; + const list = deserializeFilterObjectToList(obj); + expect(list).toBeImmutableList(); + expect(list.size).toEqual(1); + expect(list.first()).toEqual( + fromJS({ + type: 'bucketSample', + start: 50, + count: 70, + total: 100, + input: ['normandy.request_time'], + }), + ); + }); + }); + + describe('utility functions', () => { + it('should parse inputs to either integer or null', () => { + expect(parseIntOrNull(1)).toEqual(1); + expect(parseIntOrNull('2')).toEqual(2); + expect(parseIntOrNull('2.0')).toBeNull(); + expect(parseIntOrNull(Infinity)).toBeNull(); + expect(parseIntOrNull('junk')).toBeNull(); + expect(parseIntOrNull(1.1)).toBeNull(); + expect(parseIntOrNull([])).toBeNull(); + }); + + it('should rewrite sampling input strings in a smart way', () => { + const options = ['normandy.request_time', 'normandy.clientId']; + expect(rewriteSamplingInput('normandy.request_time', options)).toEqual( + 'normandy.request_time', + ); + expect(rewriteSamplingInput('string', options)).toEqual('"string"'); + expect(rewriteSamplingInput('123', options)).toEqual('123'); + }); + }); + + describe('', () => { + it('happy path adding more different inputs', () => { + const props = { + disabled: false, + bubbleUp: jest.fn(), + value: { + input: ['normandy.request_time'], + }, + onSubmit: jest.fn(), + }; + + // Create a Form wrapper to get the child contexts automatically passed. + const FakeForm = createForm({})(InputsWidget); + const { container } = render(); + // Have to use querySelector because there's no text on the button. + const button = container.querySelector('button[type="button"]'); + expect(button.disabled).toBeTruthy(); + fireEvent.click(button); + // Because it's disabled. Because nothing has been typed in. + expect(props.bubbleUp).toHaveBeenCalledTimes(0); + const input = container.querySelector('input'); + // Can't use `fireEvent.change(input)` because it's a controlled component. + fireEvent.change(input, { + target: { value: ' ' }, + }); + // Input is just whitespace, so the button is still disabled. + expect(button.disabled).toBeTruthy(); + fireEvent.change(input, { + target: { value: ' freetext ' }, + }); + expect(button.disabled).toBeFalsy(); + fireEvent.click(button); + expect(props.bubbleUp).toHaveBeenCalledTimes(1); + expect(props.bubbleUp).toBeCalledWith({ input: ['"freetext"', 'normandy.request_time'] }); + + // Type something else in. + expect(input.value).toEqual(''); // should have been wiped + fireEvent.change(input, { + target: { value: '123' }, + }); + expect(button.disabled).toBeFalsy(); + fireEvent.click(button); + expect(props.bubbleUp).toHaveBeenCalledTimes(2); + expect(props.bubbleUp).toBeCalledWith({ + input: ['"freetext"', 123, 'normandy.request_time'], + }); + + expect(input.value).toEqual(''); // should have been wiped + fireEvent.change(input, { + target: { value: 'normandy.clientId' }, + }); + expect(button.disabled).toBeFalsy(); + fireEvent.click(button); + expect(props.bubbleUp).toHaveBeenCalledTimes(3); + expect(props.bubbleUp).toBeCalledWith({ + input: ['"freetext"', 123, 'normandy.clientId', 'normandy.request_time'], + }); + }); + + it('autocomplete suggestions', () => { + const props = { + disabled: false, + bubbleUp: jest.fn(), + value: {}, + defaultInputOptions: ['normandy.foo', 'normandy.man'], + onSubmit: jest.fn(), // otherwise set by createForm() + }; + const FakeForm = createForm({})(InputsWidget); + const { container, queryByText, getByText } = render(); + const input = container.querySelector('input'); + // Can't use `fireEvent.change(input)` because it's a controlled component. + fireEvent.change(input, { + target: { value: 'm' }, + }); + expect(getByText('normandy.man')).not.toBeNull(); + // If should have filtered out 'normandy.foo' even though it naively contains + // the string 'm'. + expect(queryByText('normandy.foo')).toBeNull(); + }); + + it('editing by removing an existing input', () => { + const props = { + disabled: false, + bubbleUp: jest.fn(), + value: { + input: ['normandy.clientId', 'normandy.request_time'], + }, + defaultInputOptions: ['normandy.foo', 'normandy.man'], + onSubmit: jest.fn(), // otherwise set by createForm() + }; + const FakeForm = createForm({})(InputsWidget); + const { getByText } = render(); + const tag = getByText('normandy.clientId'); + const closeIcon = tag.querySelector('.anticon-close'); + fireEvent.click(closeIcon); + expect(props.bubbleUp).toHaveBeenCalledTimes(1); + expect(props.bubbleUp).toBeCalledWith({ + input: ['normandy.request_time'], + }); + }); + }); + + describe('', () => { + // Deliberately commented out for now. + // In Antd the