зеркало из https://github.com/mozilla/normandy.git
Add filters to recipe page
This commit is contained in:
Родитель
fbfd7a3843
Коммит
df8ec79bb7
|
@ -27,14 +27,15 @@ export function buildControlField({
|
|||
children,
|
||||
...args // eslint-disable-line comma-dangle
|
||||
}) {
|
||||
const WrappingElement = label ? 'label' : 'div';
|
||||
return (
|
||||
<label className={`${className} form-field`}>
|
||||
<WrappingElement className={`${className} form-field`}>
|
||||
<span className="label">{label}</span>
|
||||
{!hideErrors && error && <span className="error">{error}</span>}
|
||||
<InputComponent {...input} {...args}>
|
||||
{children}
|
||||
</InputComponent>
|
||||
</label>
|
||||
</WrappingElement>
|
||||
);
|
||||
}
|
||||
buildControlField.propTypes = {
|
||||
|
@ -42,7 +43,7 @@ buildControlField.propTypes = {
|
|||
meta: pt.shape({
|
||||
error: pt.oneOfType([pt.string, pt.array]),
|
||||
}).isRequired,
|
||||
label: pt.string.isRequired,
|
||||
label: pt.string,
|
||||
className: pt.string,
|
||||
InputComponent: pt.oneOfType([pt.func, pt.string]),
|
||||
hideErrors: pt.bool,
|
||||
|
@ -110,3 +111,36 @@ buildErrorMessageField.propTypes = {
|
|||
error: pt.oneOfType([pt.string, pt.array]),
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
|
||||
export const CheckboxGroup = ({ name, options = [], input }) =>
|
||||
<div>
|
||||
{
|
||||
options.map((option, index) =>
|
||||
<div className="checkbox" key={index}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={`${name}[${index}]`}
|
||||
value={option.value}
|
||||
checked={input.value.indexOf(option.value) !== -1}
|
||||
onChange={event => {
|
||||
const newValue = [...input.value];
|
||||
if (event.target.checked) {
|
||||
newValue.push(option.value);
|
||||
} else {
|
||||
newValue.splice(newValue.indexOf(option.value), 1);
|
||||
}
|
||||
|
||||
return input.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
{option.value}
|
||||
</div>)
|
||||
}
|
||||
</div>;
|
||||
|
||||
CheckboxGroup.propTypes = {
|
||||
name: pt.string.isRequired,
|
||||
input: pt.object.isRequired,
|
||||
options: pt.array,
|
||||
};
|
||||
|
|
|
@ -0,0 +1,239 @@
|
|||
import React, { PropTypes as pt } from 'react';
|
||||
|
||||
export default class MultiPicker extends React.Component {
|
||||
static propTypes = {
|
||||
unit: pt.string.isRequired,
|
||||
plural: pt.string.isRequired,
|
||||
options: pt.array,
|
||||
// from redux-form
|
||||
value: pt.oneOfType([pt.array, pt.string]),
|
||||
onChange: pt.func,
|
||||
};
|
||||
|
||||
static selectToArray({ options }) {
|
||||
const result = [];
|
||||
const selectOptions = options || [];
|
||||
let opt;
|
||||
|
||||
for (let i = 0, iLen = selectOptions.length; i < iLen; i++) {
|
||||
opt = selectOptions[i];
|
||||
|
||||
if (opt.selected) {
|
||||
result.push(opt.value || opt.text);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
filterText: null,
|
||||
};
|
||||
|
||||
this.handleApplyOption = ::this.handleApplyOption;
|
||||
this.handleRemoveOption = ::this.handleRemoveOption;
|
||||
this.onTextChange = ::this.onTextChange;
|
||||
this.convertValueToObj = ::this.convertValueToObj;
|
||||
}
|
||||
|
||||
onTextChange(event) {
|
||||
const { value } = event.target;
|
||||
|
||||
this.setState({
|
||||
filterText: value,
|
||||
});
|
||||
}
|
||||
|
||||
getDisplayedOptions() {
|
||||
const {
|
||||
options = [],
|
||||
value,
|
||||
} = this.props;
|
||||
|
||||
const selectedOptions = options.filter(option =>
|
||||
value.indexOf(option.value) === -1);
|
||||
|
||||
let displayedOptions = [].concat(selectedOptions.map(option => option.value))
|
||||
.map(val => {
|
||||
if (!val) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const foundOption = this.convertValueToObj(val);
|
||||
|
||||
return { ...foundOption } || null;
|
||||
}).filter(x => x);
|
||||
|
||||
const {
|
||||
filterText,
|
||||
} = this.state;
|
||||
|
||||
if (filterText) {
|
||||
displayedOptions = displayedOptions.filter(option =>
|
||||
JSON.stringify(option).indexOf(filterText) > -1);
|
||||
}
|
||||
|
||||
return displayedOptions;
|
||||
}
|
||||
|
||||
handleApplyOption(event) {
|
||||
event.persist();
|
||||
|
||||
if (!this.availableRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
value,
|
||||
onChange,
|
||||
} = this.props;
|
||||
|
||||
let selectedFilters = MultiPicker.selectToArray(this.availableRef);
|
||||
|
||||
if (!selectedFilters || selectedFilters.length === 0) {
|
||||
selectedFilters = this.getDisplayedOptions();
|
||||
selectedFilters = selectedFilters.map(option => option.value);
|
||||
}
|
||||
|
||||
const newOptions = []
|
||||
.concat(value || [])
|
||||
.concat(selectedFilters);
|
||||
|
||||
// clear user input
|
||||
this.availableRef.value = null;
|
||||
|
||||
onChange(newOptions.join(','));
|
||||
}
|
||||
|
||||
handleRemoveOption(event) {
|
||||
event.persist();
|
||||
if (!this.selectedRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
value,
|
||||
onChange,
|
||||
} = this.props;
|
||||
|
||||
const selectedFilters = MultiPicker.selectToArray(this.selectedRef);
|
||||
let newOptions = []
|
||||
.concat(value || [])
|
||||
.filter(val => selectedFilters.indexOf(val) === -1);
|
||||
|
||||
if (!selectedFilters || selectedFilters.length === 0) {
|
||||
newOptions = [];
|
||||
}
|
||||
|
||||
// clear the user selection
|
||||
this.selectedRef.value = null;
|
||||
|
||||
onChange(newOptions.join(','));
|
||||
}
|
||||
|
||||
convertValueToObj(value) {
|
||||
const {
|
||||
options = [],
|
||||
} = this.props;
|
||||
|
||||
return options.find(option => option.value === value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render
|
||||
*/
|
||||
render() {
|
||||
const {
|
||||
unit,
|
||||
plural,
|
||||
value,
|
||||
} = this.props;
|
||||
|
||||
let pickerValue = value || [];
|
||||
|
||||
if (value && typeof value === 'string') {
|
||||
pickerValue = pickerValue.split(',');
|
||||
}
|
||||
|
||||
pickerValue = pickerValue.map(this.convertValueToObj);
|
||||
|
||||
const displayedOptions = this.getDisplayedOptions();
|
||||
|
||||
const availableLabel = displayedOptions
|
||||
&& (displayedOptions.length === 0 || displayedOptions.length > 1)
|
||||
? plural : unit;
|
||||
|
||||
const selectedLabel = pickerValue
|
||||
&& (pickerValue.length === 0 || pickerValue.length > 1)
|
||||
? plural : unit;
|
||||
|
||||
return (
|
||||
<div className="multipicker">
|
||||
|
||||
<div className="mp-frame mp-from">
|
||||
{`Available ${availableLabel}`}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Search ${plural}`}
|
||||
onChange={this.onTextChange}
|
||||
/>
|
||||
|
||||
<select ref={ref => { this.availableRef = ref || this.availableRef; }} multiple>
|
||||
{
|
||||
(displayedOptions).map((option, idx) =>
|
||||
<option
|
||||
key={idx}
|
||||
title={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
)
|
||||
}
|
||||
</select>
|
||||
<button type="button" onClick={this.handleApplyOption}>Add {unit}</button>
|
||||
</div>
|
||||
|
||||
{
|
||||
!!pickerValue &&
|
||||
<div className="mp-button-group">
|
||||
<button
|
||||
onClick={this.handleRemoveOption}
|
||||
type="button"
|
||||
>
|
||||
⇜
|
||||
</button>
|
||||
<button
|
||||
onClick={this.handleApplyOption}
|
||||
type="button"
|
||||
>
|
||||
⇝
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="mp-frame mp-to">
|
||||
{`Selected ${selectedLabel}`}
|
||||
<select ref={ref => { this.selectedRef = ref || this.selectedRef; }} multiple>
|
||||
{
|
||||
pickerValue.map((option, idx) =>
|
||||
<option
|
||||
key={idx}
|
||||
title={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
)
|
||||
}
|
||||
</select>
|
||||
{ !!pickerValue && !!pickerValue.length &&
|
||||
<button type="button" onClick={this.handleRemoveOption}>Remove {unit}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -13,6 +13,11 @@ import { pick } from 'underscore';
|
|||
|
||||
import makeApiRequest from 'control/api';
|
||||
|
||||
|
||||
import {
|
||||
getFilterObject,
|
||||
} from 'control/selectors/FiltersSelector';
|
||||
|
||||
import {
|
||||
recipeUpdated,
|
||||
recipeAdded,
|
||||
|
@ -22,6 +27,10 @@ import {
|
|||
revisionRecipeUpdated,
|
||||
} from 'control/actions/RecipeActions';
|
||||
|
||||
import {
|
||||
loadFilters,
|
||||
} from 'control/actions/FilterActions';
|
||||
|
||||
import {
|
||||
showNotification,
|
||||
} from 'control/actions/NotificationActions';
|
||||
|
@ -35,8 +44,10 @@ import {
|
|||
} from 'control/selectors/RecipesSelector';
|
||||
|
||||
import composeRecipeContainer from 'control/components/RecipeContainer';
|
||||
import { ControlField } from 'control/components/Fields';
|
||||
import { ControlField, CheckboxGroup } from 'control/components/Fields';
|
||||
import RecipeFormActions from 'control/components/RecipeFormActions';
|
||||
import MultiPicker from 'control/components/MultiPicker';
|
||||
|
||||
import HeartbeatFields from 'control/components/action_fields/HeartbeatFields';
|
||||
import ConsoleLogFields from 'control/components/action_fields/ConsoleLogFields';
|
||||
import PreferenceExperimentFields from
|
||||
|
@ -45,7 +56,7 @@ import RecipeStatus from 'control/components/RecipeStatus';
|
|||
import DraftStatus from 'control/components/DraftStatus';
|
||||
import BooleanIcon from 'control/components/BooleanIcon';
|
||||
|
||||
export const selector = formValueSelector('recipe');
|
||||
export const formSelector = formValueSelector('recipe');
|
||||
|
||||
// The arguments field is handled in initialValuesWrapper.
|
||||
const DEFAULT_FORM_VALUES = {
|
||||
|
@ -79,6 +90,9 @@ export class RecipeForm extends React.Component {
|
|||
routeParams: pt.object.isRequired,
|
||||
// from redux-form
|
||||
pristine: pt.bool,
|
||||
recipeArguments: pt.object,
|
||||
filters: pt.object.isRequired,
|
||||
loadFilters: pt.func.isRequired,
|
||||
};
|
||||
|
||||
static argumentsFields = {
|
||||
|
@ -122,6 +136,8 @@ export class RecipeForm extends React.Component {
|
|||
dispatch,
|
||||
} = this.props;
|
||||
|
||||
dispatch(loadFilters());
|
||||
|
||||
if (!user || !user.id) {
|
||||
dispatch(makeApiRequest('getCurrentUser'))
|
||||
.then(receivedUser => dispatch(userInfoReceived(receivedUser)));
|
||||
|
@ -326,6 +342,8 @@ export class RecipeForm extends React.Component {
|
|||
recipe,
|
||||
revision,
|
||||
recipeId,
|
||||
route,
|
||||
recipeArguments,
|
||||
} = this.props;
|
||||
const noop = () => null;
|
||||
const ArgumentsFields = RecipeForm.argumentsFields[selectedAction] || noop;
|
||||
|
@ -343,6 +361,7 @@ export class RecipeForm extends React.Component {
|
|||
|
||||
const thisRevisionRequest = revision && revision.approval_request;
|
||||
const statusText = renderVars.isEnabled ? 'Enabled' : 'Disabled';
|
||||
const filters = getFilterObject(this.props.filters.list);
|
||||
|
||||
return (
|
||||
<form className="recipe-form" onSubmit={handleSubmit}>
|
||||
|
@ -395,23 +414,53 @@ export class RecipeForm extends React.Component {
|
|||
component="input"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<ControlField
|
||||
disabled={isFormDisabled}
|
||||
label="Filter Expression"
|
||||
name="extra_filter_expression"
|
||||
component="textarea"
|
||||
/>
|
||||
|
||||
<ControlField
|
||||
disabled={isFormDisabled}
|
||||
label="Action"
|
||||
name="action"
|
||||
component="select"
|
||||
>
|
||||
<option value="">Choose an action...</option>
|
||||
<option value="console-log">Log to Console</option>
|
||||
<option value="show-heartbeat">Heartbeat Prompt</option>
|
||||
<option value="preference-experiment">Preference Experiment</option>
|
||||
</ControlField>
|
||||
/>
|
||||
|
||||
<div className="form-frame">
|
||||
<span className="frame-title">
|
||||
Filters
|
||||
</span>
|
||||
<ControlField
|
||||
component={MultiPicker}
|
||||
name="locales"
|
||||
unit={'Locale'}
|
||||
plural={'Locales'}
|
||||
options={filters.locales || []}
|
||||
/>
|
||||
<ControlField
|
||||
component={MultiPicker}
|
||||
name="countries"
|
||||
unit={'Country'}
|
||||
plural={'Countries'}
|
||||
options={filters.countries || []}
|
||||
/>
|
||||
|
||||
<ControlField
|
||||
component={CheckboxGroup}
|
||||
name="channels"
|
||||
label="Release Channels"
|
||||
options={filters.channels || []}
|
||||
/>
|
||||
|
||||
<ControlField
|
||||
label="Additional Filter Expressions"
|
||||
name="extra_filter_expression"
|
||||
component="textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ArgumentsFields disabled={isFormDisabled} />
|
||||
|
||||
|
@ -451,9 +500,18 @@ export const formConfig = {
|
|||
// Filter out unwanted keys for submission.
|
||||
const recipe = pick(values, [
|
||||
'name', 'enabled', 'extra_filter_expression', 'action', 'arguments',
|
||||
'locales', 'countries', 'channels',
|
||||
]);
|
||||
const isCloning = route && route.isCloning;
|
||||
|
||||
// Some values may be strings but the API requires arrays
|
||||
['locales', 'countries', 'channels'].forEach(key => {
|
||||
if (typeof recipe[key] === 'string') {
|
||||
recipe[key] = recipe[key].split(',');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
let result;
|
||||
if (recipeId && !isCloning) {
|
||||
result = updateRecipe(recipeId, recipe);
|
||||
|
@ -518,13 +576,16 @@ export function initialValuesWrapper(Component) {
|
|||
const connector = connect(
|
||||
// Pull selected action from the form state.
|
||||
state => ({
|
||||
selectedAction: selector(state, 'action'),
|
||||
user: state.user,
|
||||
allRevisions: state.recipes.revisions,
|
||||
selectedAction: formSelector(state, 'action'),
|
||||
recipeArguments: formSelector(state, 'arguments'),
|
||||
filters: state.filters || {},
|
||||
}),
|
||||
|
||||
// Bound functions for writing to the server.
|
||||
dispatch => ({
|
||||
loadFilters,
|
||||
addRecipe(recipe) {
|
||||
return dispatch(makeApiRequest('addRecipe', { recipe }))
|
||||
.then(response => {
|
||||
|
|
|
@ -182,10 +182,7 @@ export class DisconnectedRecipeList extends React.Component {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<RecipeFilters
|
||||
displayCount={filteredRecipes.length}
|
||||
totalCount={recipes.length}
|
||||
/>
|
||||
<RecipeFilters />
|
||||
<div className="fluid-8">
|
||||
{
|
||||
!noResults && recipeListNeedsFetch &&
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
$white: #FFF;
|
||||
$cream: #F6F4EF;
|
||||
$darkCream: #ECE7DB;
|
||||
$darkestCream: #DAD1BC;
|
||||
|
|
|
@ -368,3 +368,61 @@ i {
|
|||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.form-frame {
|
||||
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||
padding: 0 1em;
|
||||
|
||||
.frame-title {
|
||||
background: $white;
|
||||
display: inline-block;
|
||||
padding: 0 0.5em;
|
||||
position: relative;
|
||||
top: -0.65em;
|
||||
}
|
||||
}
|
||||
|
||||
.multipicker {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: 2em;
|
||||
|
||||
.mp-frame {
|
||||
width: 50%;
|
||||
|
||||
label,
|
||||
.label,
|
||||
select,
|
||||
button {
|
||||
text-transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mp-button-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
option {
|
||||
&::after {
|
||||
content: " (" attr(title) ")";
|
||||
font-size: 0.75em;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-field .checkbox-list {
|
||||
label {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
input {
|
||||
float: none;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
}
|
||||
|
|
|
@ -54,13 +54,12 @@ export const getActiveFilterOptions = (state = {}) =>
|
|||
const newGroup = { ...group };
|
||||
// remove non-selected filters
|
||||
const activeOptions = [].concat(group.options)
|
||||
.filter(option => option.selected);
|
||||
.filter(option => option.selected)
|
||||
.map(option => ({ ...option }));
|
||||
|
||||
newGroup.options = activeOptions;
|
||||
|
||||
return newGroup;
|
||||
// filtering x=>x will remove any null array members
|
||||
// (e.g. [1,null,2] becomes [1,2])
|
||||
}).filter(x => x);
|
||||
|
||||
|
||||
|
@ -128,3 +127,38 @@ export const getAvailableFilters = state =>
|
|||
*/
|
||||
export const isFilteringActive = state =>
|
||||
getActiveFilterOptions(state).length > 0;
|
||||
|
||||
/**
|
||||
* Given a specific filter slug, returns only that
|
||||
* filter group.
|
||||
*
|
||||
* @param {Array<Object>} groups All filter groups
|
||||
* @param {String} slug Slug of specific group to pull
|
||||
* @return {Object} Found group object (or `undefined`)
|
||||
*/
|
||||
export const getFilterGroup = (groups, slug) =>
|
||||
[].concat(groups)
|
||||
.map(group => ({ ...group }))
|
||||
.filter(group => group.value === slug).pop();
|
||||
|
||||
/**
|
||||
* Get all filters as an object keyed on their slug
|
||||
*
|
||||
* @param {Array<Object>} groups All filter groups
|
||||
* @return {Object} Object of filters keyed by their slug
|
||||
*/
|
||||
export const getFilterObject = groups => {
|
||||
let compiled = {};
|
||||
|
||||
[].concat(groups)
|
||||
.map(group => ({ ...group }))
|
||||
.forEach(group => {
|
||||
compiled = {
|
||||
...compiled,
|
||||
[group.value]: [].concat(group.options).map(option => ({ ...option })),
|
||||
};
|
||||
});
|
||||
|
||||
return compiled;
|
||||
};
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ describe('<ControlField>', () => {
|
|||
InputComponent: 'input',
|
||||
type: 'text',
|
||||
name: 'test',
|
||||
label: 'Test',
|
||||
...props,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -14,7 +14,9 @@ import { recipeFactory } from '../../../tests/utils.js';
|
|||
function propFactory(props = {}) {
|
||||
return {
|
||||
handleSubmit: () => undefined,
|
||||
loadFilters: () => Promise.resolve(),
|
||||
dispatch: () => Promise.resolve(),
|
||||
filters: {},
|
||||
submitting: false,
|
||||
user: {},
|
||||
...props,
|
||||
|
|
Загрузка…
Ссылка в новой задаче