This commit is contained in:
Andy Mikulski 2017-03-13 12:25:07 -06:00
Родитель fbfd7a3843
Коммит df8ec79bb7
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: A530EAD3CD3EFB3C
9 изменённых файлов: 446 добавлений и 19 удалений

Просмотреть файл

@ -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,