Merge pull request #1118 from mythmon/remove-old-config

Fixes #866 - Remove old control interface.
This commit is contained in:
R&D 2017-10-31 00:04:48 +00:00 коммит произвёл GitHub
Родитель c9db7a5fc3 f8d8682ba6
Коммит 5975ed1fff
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
163 изменённых файлов: 84 добавлений и 12363 удалений

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

@ -10,6 +10,7 @@
// Pages
@import 'pages/404';
@import 'pages/login';
@import 'pages/gateway';
@import 'pages/recipe-details';
@import 'pages/recipe-form';

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

@ -0,0 +1,40 @@
/* Login Page */
.login {
background: @body-background;
padding: 100px 0;
input {
border: 1px solid @border-color-base;
padding: 5px 10px;
width: 100%;
}
input[type="submit"] {
background: @blue-6;
border-radius: 4px;
color: @white;
margin-top: 16px;
padding: 10px;
text-align: center;
}
/* Layout */
#container {
margin: 0 auto;
width: 28em;
}
#content {
background: @background-color-active;
padding: 20px;
}
#page-header {
background: @heading-color;
}
#page-header h2 {
color: @white;
padding: 15px 30px;
}
}

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

@ -1,3 +0,0 @@
rules:
react/require-default-props: [off]
jsx-a11y/no-static-element-interactions: [off]

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

@ -1,56 +0,0 @@
import * as localForage from 'localforage';
const UPDATE_COLUMN = 'UPDATE_COLUMN';
const LOAD_SAVED_COLUMNS = 'LOAD_SAVED_COLUMNS';
function updateColumn({ slug, isActive }) {
return dispatch => {
dispatch({
type: UPDATE_COLUMN,
slug,
isActive,
});
};
}
const localStorageID = 'columns';
function loadLocalColumns() {
return dispatch =>
// load the column settings the user last used
localForage
.getItem(localStorageID)
.then(found => {
if (found && found.length) {
dispatch({
type: LOAD_SAVED_COLUMNS,
columns: found,
});
}
});
}
/**
* Utility function to save the state via localForage.
* Slightly weird since it doesn't actually dispatch anything
*
* @param {Array} state Filter state
* @return {void}
*/
async function saveLocalColumns(state) {
await localForage.setItem(localStorageID, state);
}
// Exports
export {
// used for testing
localStorageID,
// action constants
UPDATE_COLUMN,
LOAD_SAVED_COLUMNS,
// actions
updateColumn,
loadLocalColumns,
saveLocalColumns,
};

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

@ -1,31 +0,0 @@
import { showNotification } from 'control_old/actions/NotificationActions';
export const REQUEST_IN_PROGRESS = 'REQUEST_IN_PROGRESS';
export const REQUEST_COMPLETE = 'REQUEST_COMPLETE';
export const RECEIVED_USER_INFO = 'RECEIVED_USER_INFO';
export function userInfoReceived(user) {
return {
type: RECEIVED_USER_INFO,
user,
};
}
export function requestInProgress() {
return {
type: REQUEST_IN_PROGRESS,
};
}
export function requestComplete(result) {
return dispatch => {
if (result.notification) {
dispatch(showNotification({
messageType: result.status,
message: result.notification,
}));
}
dispatch({ type: REQUEST_COMPLETE, status: result.status });
};
}

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

@ -1,124 +0,0 @@
import titleize from 'underscore.string/titleize';
import makeApiRequest from 'control_old/api';
import {
recipesNeedFetch,
recipesReceived,
} from 'control_old/actions/RecipeActions';
import {
getFilterParamString,
} from 'control_old/selectors/FiltersSelector';
export const SET_FILTER = 'SET_FILTER';
export const SET_TEXT_FILTER = 'SET_TEXT_FILTER';
export const LOAD_FILTERS = 'LOAD_FILTERS';
export const RESET_FILTERS = 'RESET_FILTERS';
function formatFilterOption(option) {
let label;
let value;
// string type = we were given just an option
// (no keyed value/label)
if (typeof option === 'string') {
label = option;
value = option;
// else if we get an object, then we
// can extract the key/value props
} else if (typeof option === 'object') {
label = option.value;
value = option.key;
}
return {
value,
label,
};
}
/**
* Load list of possible filters from remote API.
* This is stored in the `filters` reducer, and
* later used to populate relevant RecipeFilters components.
*/
export function loadFilters() {
return dispatch =>
dispatch(makeApiRequest('fetchFilters'))
.then(filters => {
if (!filters) {
return;
}
const newFilters = [];
// format each recipe
for (const group in filters) {
if (!filters.hasOwnProperty(group)) {
break;
}
const newGroup = {
value: group,
label: titleize(group),
multiple: filters[group].length > 2,
options: [].concat(filters[group]).map(formatFilterOption),
};
newFilters.push(newGroup);
}
dispatch(filtersReceived(newFilters));
});
}
/**
* Given an option and its parent group, update the
* filter state based on the `isEnabled` prop
*
* @param {Object} group Group the filter belongs to
* @param {Object} option Option that was affected
* @param {Boolean} isEnabled Is the option selected?
*/
export function selectFilter({ group, option, isEnabled }) {
return {
type: group.value === 'text' ? SET_TEXT_FILTER : SET_FILTER,
group,
option,
isEnabled,
};
}
/**
* Detects activated filters, creates the URL param string,
* and queries API for a filtered list based on params.
*/
export function loadFilteredRecipes() {
return (dispatch, getState) => {
dispatch(recipesNeedFetch());
const filterParams = getFilterParamString(getState().filters);
return dispatch(makeApiRequest('fetchFilteredRecipes', filterParams))
.then(recipes => dispatch(recipesReceived(recipes, filterParams)));
};
}
/**
* Dispatches a RESET_FILTERS event, which resets
* the 'active' filters to what was loaded earlier.
*/
export function resetFilters() {
return {
type: RESET_FILTERS,
};
}
export function filtersReceived(filters) {
return {
type: LOAD_FILTERS,
filters,
};
}

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

@ -1,24 +0,0 @@
export const SHOW_NOTIFICATION = 'SHOW_NOTIFICATION';
export const DISMISS_NOTIFICATION = 'DISMISS_NOTIFICATION';
export function showNotification(notification) {
return dispatch => {
// Use time-based id and dismiss automatically after 10 seconds.
notification.id = notification.id || new Date().getTime();
setTimeout(() => {
dispatch(dismissNotification(notification.id));
}, 10000);
dispatch({
type: SHOW_NOTIFICATION,
notification,
});
};
}
export function dismissNotification(notificationId) {
return {
type: DISMISS_NOTIFICATION,
notificationId,
};
}

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

@ -1,92 +0,0 @@
export const RECIPES_RECEIVED = 'RECIPES_RECEIVED';
export const SINGLE_RECIPE_RECEIVED = 'SINGLE_RECIPE_RECEIVED';
export const SINGLE_REVISION_RECEIVED = 'SINGLE_REVISION_RECEIVED';
export const RECIPES_NEED_FETCH = 'RECIPES_NEED_FETCH';
export const SET_SELECTED_RECIPE = 'SET_SELECTED_RECIPE';
export const SET_SELECTED_REVISION = 'SET_SELECTED_REVISION';
export const REVISIONS_RECEIVED = 'REVISIONS_RECEIVED';
export const REVISION_RECIPE_UPDATED = 'REVISION_RECIPE_UPDATED';
export const RECIPE_ADDED = 'RECIPE_ADDED';
export const RECIPE_UPDATED = 'RECIPE_UPDATED';
export const RECIPE_DELETED = 'RECIPE_DELETED';
export function recipesReceived(recipes, cacheKey) {
return {
type: RECIPES_RECEIVED,
recipes,
cacheKey,
};
}
export function recipesNeedFetch() {
return {
type: RECIPES_NEED_FETCH,
};
}
export function singleRecipeReceived(recipe) {
return {
type: SINGLE_RECIPE_RECEIVED,
recipe,
};
}
export function singleRevisionReceived(revision) {
return {
type: SINGLE_REVISION_RECEIVED,
revision,
};
}
export function recipeAdded(recipe) {
return {
type: RECIPE_ADDED,
recipe,
};
}
export function recipeUpdated(recipe) {
return {
type: RECIPE_UPDATED,
recipe,
};
}
export function recipeDeleted(recipeId) {
return {
type: RECIPE_DELETED,
recipeId,
};
}
export function setSelectedRecipe(recipeId) {
return {
type: SET_SELECTED_RECIPE,
recipeId,
};
}
export function setSelectedRevision(revisionId) {
return {
type: SET_SELECTED_REVISION,
revisionId,
};
}
export function revisionsReceived({ recipeId, revisions }) {
return {
type: REVISIONS_RECEIVED,
recipeId,
revisions,
};
}
export function revisionRecipeUpdated({ revisionId, recipe }) {
return {
type: REVISION_RECIPE_UPDATED,
revisionId,
recipe,
};
}

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

@ -1,19 +0,0 @@
export function fetchFilters() {
return {
url: 'filters/',
settings: {
method: 'GET',
},
errorNotification: 'Error fetching filter options.',
};
}
export function fetchFilteredRecipes(filterParams) {
return {
url: `recipe/?${filterParams}`,
settings: {
method: 'GET',
},
errorNotification: 'Error fetching filtered recipes.',
};
}

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

@ -1,164 +0,0 @@
export function fetchAllRecipes() {
return {
url: 'recipe/',
settings: {
method: 'GET',
},
errorNotification: 'Error fetching recipes.',
};
}
export function fetchSingleRecipe({
recipeId,
}) {
return {
url: `recipe/${recipeId}/`,
settings: {
method: 'GET',
},
errorNotification: 'Error fetching recipe.',
};
}
export function fetchSingleRevision({
revisionId,
}) {
return {
url: `recipe_version/${revisionId}/`,
settings: {
method: 'GET',
},
errorNotification: 'Error fetching recipe revision.',
};
}
export function fetchRecipeHistory({
recipeId,
}) {
return {
url: `recipe/${recipeId}/history/`,
settings: {
method: 'GET',
},
errorNotification: 'Error fetching recipe history.',
};
}
export function addRecipe({
recipe,
}) {
return {
url: 'recipe/',
settings: {
body: JSON.stringify(recipe),
method: 'POST',
},
};
}
export function updateRecipe({
recipeId,
recipe,
}) {
return {
url: `recipe/${recipeId}/`,
settings: {
body: JSON.stringify(recipe),
method: 'PATCH',
},
};
}
export function deleteRecipe({
recipeId,
}) {
return {
url: `recipe/${recipeId}/`,
settings: {
method: 'DELETE',
},
successNotification: 'Recipe deleted.',
errorNotification: 'Error deleting recipe.',
};
}
export function openApprovalRequest({
revisionId,
}) {
return {
url: `recipe_revision/${revisionId}/request_approval/`,
settings: {
method: 'POST',
body: JSON.stringify({
revisionId,
}),
},
errorNotification: 'Error creating new approval request.',
};
}
export function approveApprovalRequest({
requestId,
comment = '',
}) {
return {
url: `approval_request/${requestId}/approve/`,
settings: {
method: 'POST',
body: JSON.stringify({
comment,
}),
},
errorNotification: 'Error approving recipe approval.',
};
}
export function rejectApprovalRequest({
requestId,
comment = '',
}) {
return {
url: `approval_request/${requestId}/reject/`,
settings: {
method: 'POST',
body: JSON.stringify({
comment,
}),
},
errorNotification: 'Error rejecting recipe approval.',
};
}
export function closeApprovalRequest({
requestId,
}) {
return {
url: `approval_request/${requestId}/close/`,
settings: {
method: 'POST',
},
errorNotification: 'Error closing recipe approval request.',
};
}
export function enableRecipe(recipeInfo) {
return {
url: `recipe/${recipeInfo.recipeId}/enable/`,
settings: {
method: 'POST',
},
successNotification: 'Recipe enabled.',
errorNotification: 'Error enabling recipe.',
};
}
export function disableRecipe(recipeInfo) {
return {
url: `recipe/${recipeInfo.recipeId}/disable/`,
settings: {
method: 'POST',
},
successNotification: 'Recipe disabled.',
errorNotification: 'Error disabling recipe.',
};
}

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

@ -1,11 +0,0 @@
/* eslint-disable import/prefer-default-export */
export function getCurrentUser() {
return {
url: 'user/me/',
settings: {
method: 'GET',
},
errorNotification: 'Error retrieving user info.',
};
}

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

@ -1,88 +0,0 @@
// request-related actions to dispatch later
import {
requestComplete,
requestInProgress,
} from 'control_old/actions/ControlActions';
// API request configs
import * as recipeApi from 'control_old/api/RecipeApi';
import * as filterApi from 'control_old/api/FilterApi';
import * as userApi from 'control_old/api/UserApi';
// Combined list of API request configs
const apiMap = {
...recipeApi,
...filterApi,
...userApi,
};
// Root URL for API requests
const BASE_API_URL = '/api/v1/';
// Default API request settings
export const API_REQUEST_SETTINGS = {
credentials: 'same-origin',
headers: {
'X-CSRFToken': document.getElementsByTagName('html')[0].dataset.csrf,
Accept: 'application/json',
'Content-Type': 'application/json',
},
};
/**
* Creates an API request based on function name passed in.
* Uses apiMap to find request config settings.
*
* @param {String} requestType Name of API request to fire
* @param {Object} requestData Optional, data to pass into API call
* @return {Promise} API's fetch promise
*/
export default function makeApiRequest(requestType, requestData) {
return dispatch => {
const apiRequestFunc = apiMap[requestType];
// throw an error if the API request doesn't exist or is invalid
if (!apiRequestFunc || typeof apiRequestFunc !== 'function') {
throw new Error(`"${requestType}" is not a valid API request function.`);
}
// get the config object for this request type
const apiRequestConfig = apiMap[requestType](requestData);
// alert the app we're requesting
dispatch(requestInProgress());
// the request config URL is just attached to the base API
const apiRequestUrl = `${BASE_API_URL}${apiRequestConfig.url}`;
// perform the actual request
// (settings are API defaults + any from the config)
return fetch(apiRequestUrl, {
...API_REQUEST_SETTINGS,
...apiRequestConfig.settings,
})
// handler
.then(response => {
// if the response was not something good,
// display an in-app error, along with the config's
// custom error messaging
if (response.status >= 400) {
dispatch(requestComplete({
status: 'error',
notification: apiRequestConfig.errorNotification,
}));
return response.json().then(err => { throw err; });
}
// if we're all good, notify the app the request is done,
// and return the 'success' message from the config
dispatch(requestComplete({
status: 'success',
notification: apiRequestConfig.successNotification,
}));
// finally, return the response (text or data, based on the status)
return (response.status === 204) ? response.text : response.json();
});
};
}

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

@ -1,38 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { Provider } from 'react-redux';
import { Router, browserHistory } from 'react-router';
import { syncHistoryWithStore } from 'react-router-redux';
import configureStore from 'control_old/stores/configureStore';
import ControlAppRoutes from 'control_old/routes';
/**
* Root Component for the entire app.
*/
export function Root({ store, history }) {
return (
<Provider store={store}>
<Router history={history}>
{ControlAppRoutes}
</Router>
</Provider>
);
}
Root.propTypes = {
store: pt.object.isRequired,
history: pt.object.isRequired,
};
/**
* Initialize Redux store, history, and root component.
*/
export function createApp() {
const store = configureStore();
const history = syncHistoryWithStore(browserHistory, store);
return {
store,
history,
rootComponent: (<Root store={store} history={history} />),
};
}

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

@ -1,90 +0,0 @@
import React, { PropTypes as pt } from 'react';
import cx from 'classnames';
/**
* Section above RecipeList's table that displays
* active filter options and settings, and allows
* users to reset all/individual filters.
*/
export default class ActiveFilters extends React.Component {
static propTypes = {
selectedFilters: pt.array.isRequired,
onResetFilters: pt.func.isRequired,
onFilterSelect: pt.func.isRequired,
className: pt.string,
};
constructor(props) {
super(props);
this.state = {};
this.handlerCache = {};
}
handleFilterSelect(group, option) {
const cacheKey = group.value + option.value;
// check if an existing event handler exists
if (!this.handlerCache[cacheKey]) {
// if not, create it with the group and option given
this.handlerCache[cacheKey] = () =>
this.props.onFilterSelect(group, option);
}
// return the handling function
return this.handlerCache[cacheKey];
}
render() {
const {
className,
selectedFilters,
onResetFilters,
} = this.props;
// no filters = we dont render anything at all
if (!selectedFilters || !selectedFilters.length) {
return null;
}
// optional className prop
const displayedClass = cx('active-filters', className);
return (
<div className={displayedClass}>
{ selectedFilters.map(filter => (
<div
key={filter.value}
className="filter-group"
>
<span className="filter-label">
{ filter.label }
</span>
{ filter.options
.filter(option => option.selected)
.map((option, index) =>
(<div
key={option.value + index}
className="filter-option"
onClick={this.handleFilterSelect(filter, option)}
>
{option.label || option.value }
</div>),
)
}
</div>
))}
{ selectedFilters.length &&
<div
className="filter-button reset"
onClick={onResetFilters}
>
Reset Filters
</div>
}
</div>
);
}
}

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

@ -1,17 +0,0 @@
import React, { PropTypes as pt } from 'react';
import cx from 'classnames';
const BooleanIcon = props => {
const iconClass = props.value ? 'fa-check green' : 'fa-times red';
return (
<i title={props.title} className={cx('fa', 'fa-lg', iconClass, props.className)} />
);
};
BooleanIcon.propTypes = {
value: pt.bool.isRequired,
title: pt.string,
className: pt.string,
};
export default BooleanIcon;

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

@ -1,74 +0,0 @@
import React, { PropTypes as pt } from 'react';
/**
* Simple component which lists a bunch of checkboxes,
* and emits input changes.
*
* @prop {Function} onInputChange
* Fires when user has changed a checkbox, with
* params (Item index, Checkbox 'checked' status)
*
* @prop {Array<Object>} options
* List of options to be made into checkboxes, shaped as:
* [{
* label: 'Display label',
* value: 'Checkbox value',
* enabled: true || false, // boolean
* }, ...]
*/
export default class CheckboxList extends React.Component {
static propTypes = {
onInputChange: pt.func.isRequired,
options: pt.array.isRequired,
};
constructor(props) {
super(props);
this.state = {};
// Cache of generated event handlers
this.handlerCache = {};
}
/**
* Handler factory. Given a column value,
* returns an event handler that calls the
* onInputChange prop.
*
* @param {string} colValue Checkbox value which updated
* @return {Function} Wrapped handler
*/
handleCheckboxChange(colValue) {
// check if an existing event handler exists
if (!this.handlerCache[colValue]) {
// if not, create it with the colValue given
this.handlerCache[colValue] = evt =>
this.props.onInputChange(colValue, evt.target.checked);
}
// return the handling function
return this.handlerCache[colValue];
}
render() {
const { options } = this.props;
return (
<ul className="checkbox-list">
{options.map((option, index) => (
<li key={option.value + index}>
<label>
<input
name={option.value}
type="checkbox"
defaultChecked={option.enabled}
onChange={this.handleCheckboxChange(option.value)}
/>
{ option.label }
</label>
</li>
))}
</ul>
);
}
}

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

@ -1,87 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { connect } from 'react-redux';
import CheckboxList from 'control_old/components/CheckboxList';
import DropdownMenu from 'control_old/components/DropdownMenu';
import {
updateColumn,
} from 'control_old/actions/ColumnActions';
/**
* Simple dropdown/checkbox list combo used to handle
* managing visible columns in RecipeList.
*/
class ColumnMenu extends React.Component {
static propTypes = {
columns: pt.array.isRequired,
// connected
dispatch: pt.func.isRequired,
};
// The trigger element never changes,
// so we can define it as a static const
static trigger = (
<span className="col-trigger">
<span className="fa fa-columns" />
Columns
</span>
);
/**
* Constructor
*/
constructor(props) {
super(props);
this.state = {};
this.handleColumnInput = ::this.handleColumnInput;
}
/**
* User has de/activated a column. This handler
* simply updates the component's column state,
* and notifies the parent of what's selected
*
*
* @param {String} columnSlug Value of the column ('name', 'endTime', etc)
* @param {Boolean} isActive Is the column now active?
* @return {void}
*/
handleColumnInput(columnSlug, isActive) {
this.props.dispatch(updateColumn({
slug: columnSlug,
isActive,
}));
}
render() {
const { columns } = this.props;
return (
<DropdownMenu
pinRight
useClick
trigger={ColumnMenu.trigger}
>
<CheckboxList
options={columns.map(column => ({
...column,
value: column.slug,
}))}
onInputChange={this.handleColumnInput}
/>
</DropdownMenu>
);
}
}
const mapStateToProps = state => ({
// columns
columns: state.columns,
});
export default connect(
mapStateToProps,
)(ColumnMenu);

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

@ -1,31 +0,0 @@
import React, { PropTypes as pt } from 'react';
import Header from 'control_old/components/Header';
import Notifications from 'control_old/components/Notifications';
import DevTools from 'control_old/components/DevTools';
export default function ControlApp({ children, location, routes, params }) {
return (
<div>
{DEVELOPMENT && <DevTools />}
<Notifications />
<Header
pageType={children.props.route}
currentLocation={location.pathname}
routes={routes}
params={params}
/>
<div id="content" className="wrapper">
{
React.Children.map(children, child => React.cloneElement(child))
}
</div>
</div>
);
}
ControlApp.propTypes = {
children: pt.object.isRequired,
location: pt.object.isRequired,
routes: pt.array.isRequired,
params: pt.object.isRequired,
};

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

@ -1,57 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { push } from 'react-router-redux';
import makeApiRequest from 'control_old/api';
import { recipeDeleted } from 'control_old/actions/RecipeActions';
import composeRecipeContainer from 'control_old/components/RecipeContainer';
class DeleteRecipe extends React.Component {
propTypes = {
dispatch: pt.func.isRequired,
recipeId: pt.number.isRequired,
recipe: pt.object.isRequired,
}
constructor(props) {
super(props);
this.deleteRecipe = ::this.deleteRecipe;
}
deleteRecipe(event) {
const { dispatch, recipeId } = this.props;
event.preventDefault();
dispatch(makeApiRequest('deleteRecipe', { recipeId }))
.then(() => {
dispatch(recipeDeleted(recipeId));
dispatch(push('/control_old/'));
});
}
render() {
const { recipe } = this.props;
if (recipe) {
return (
<div className="fluid-7">
<form action="" className="crud-form">
<p>Are you sure you want to delete &quote;{recipe.name}&quote;?</p>
<div className="form-action-buttons">
<div className="fluid-2 float-right">
<input
type="submit"
value="Confirm"
className="delete"
onClick={this.deleteRecipe}
/>
</div>
</div>
</form>
</div>
);
}
return null;
}
}
export default composeRecipeContainer(DeleteRecipe);

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

@ -1,16 +0,0 @@
import React from 'react';
import { createDevTools } from 'redux-devtools';
import LogMonitor from 'redux-devtools-log-monitor';
import DockMonitor from 'redux-devtools-dock-monitor';
const DevTools = createDevTools(
<DockMonitor
toggleVisibilityKey="ctrl-h"
changePositionKey="ctrl-q"
defaultIsVisible={false}
>
<LogMonitor theme="tomorrow" />
</DockMonitor>,
);
export default DevTools;

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

@ -1,56 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { push } from 'react-router-redux';
import makeApiRequest from 'control_old/api';
import { singleRecipeReceived } from 'control_old/actions/RecipeActions';
import composeRecipeContainer from 'control_old/components/RecipeContainer';
class DisableRecipe extends React.Component {
static propTypes = {
dispatch: pt.func.isRequired,
recipeId: pt.number.isRequired,
recipe: pt.object,
}
constructor(props) {
super(props);
this.disableRecipe = ::this.disableRecipe;
}
disableRecipe(event) {
const { dispatch, recipe, recipeId } = this.props;
event.preventDefault();
dispatch(makeApiRequest('disableRecipe', { recipeId }))
.then(() => {
dispatch(singleRecipeReceived(recipe));
dispatch(push(`/control_old/recipe/${recipeId}/`));
});
}
render() {
const { recipe } = this.props;
if (!recipe) { return null; }
return (
<div className="fluid-8">
<form action="" className="crud-form">
<p>Are you sure you want to disable &quote;{recipe.name}&quote;?</p>
<div className="form-action-buttons">
<div className="fluid-2 float-right">
<input
type="submit"
value="Confirm"
className="delete"
onClick={this.disableRecipe}
/>
</div>
</div>
</form>
</div>
);
}
}
export default composeRecipeContainer(DisableRecipe);

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

@ -1,65 +0,0 @@
import React, { PropTypes as pt } from 'react';
import RecipeStatus from 'control_old/components/RecipeStatus';
import DraftStatusIcon from 'control_old/components/DraftStatusIcon';
export const STATUS_MESSAGES = {
draft: 'Draft',
pending: 'Pending Review',
approved: 'Approved',
rejected: 'Rejected',
latestDraft: 'Latest Draft',
latestApproved: 'Latest Approved',
};
export default function DraftStatus(props) {
const {
recipe,
latestRevisionId,
lastApprovedRevisionId,
} = props;
let state = 'draft';
const request = recipe.approval_request;
state = request ? 'pending' : 'draft';
if (request) {
if (request.approved === true) {
state = 'approved';
} else if (request.approved === false) {
state = 'rejected';
}
}
// Flavor text consists of 'latest draft', 'last approved', etc
const flavorText = [];
const isLatestRevision = recipe.revision_id === latestRevisionId;
const isLatestApproved = recipe.revision_id === lastApprovedRevisionId;
if (isLatestRevision) {
flavorText.push(STATUS_MESSAGES.latestDraft);
}
if (isLatestApproved) {
flavorText.push(STATUS_MESSAGES.latestApproved);
}
const status = STATUS_MESSAGES[state];
const draftIcon = (
<DraftStatusIcon
request={request}
altText={status}
isLatestRevision={isLatestRevision}
/>
);
return <RecipeStatus className={state} icon={draftIcon} text={status} flavorText={flavorText} />;
}
DraftStatus.propTypes = {
recipe: pt.object.isRequired,
latestRevisionId: pt.string,
lastApprovedRevisionId: pt.string,
};

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

@ -1,48 +0,0 @@
import React, { PropTypes as pt } from 'react';
import cx from 'classnames';
export const STATUS_ICONS = {
draft: 'fa-pencil',
pending: 'fa-question-circle-o',
approved: 'fa-thumbs-up',
rejected: 'fa-thumbs-down',
};
export default function DraftStatusIcon({
request,
className,
altText,
}) {
// We know it's a draft if there is no approval request associated.
const isDraft = !request;
// Is the revision a draft that is in review?
const isPending = request && request.approved === null;
// Has the revision been reviewed, and approved?
const isApproved = request && request.approved === true;
// Has the revision been reviewed, but rejected?
const isRejected = request && request.approved === false;
// Compile all possible classes.
const iconClass = cx(
'draft-status-icon',
'fa',
'pre',
isDraft && STATUS_ICONS.draft,
isApproved && STATUS_ICONS.approved,
isRejected && STATUS_ICONS.rejected,
isPending && STATUS_ICONS.pending,
);
return (
<i
title={altText}
className={cx(iconClass, className)}
/>
);
}
DraftStatusIcon.propTypes = {
request: pt.object,
className: pt.string,
altText: pt.string,
};

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

@ -1,192 +0,0 @@
import React, { PropTypes as pt } from 'react';
import uuid from 'node-uuid';
import cx from 'classnames';
import closest from 'client/utils/closest';
/**
* Simple component used to hide/show a block of content
* based on focus/clicking. Uses a `trigger` property
* to determine the element which should be used to
* trigger the menu's appearance
*
* @prop {node} trigger
* Element the user interacts with to display the menu
* @prop {node} children
* Element(s) displayed in the content section when open
* @prop {boolean} useClick (Optional)
* Track clicks on the display menu?
* @prop {boolean} useFocus (Optional)
* Track focus state on trigger element to display the menu?
* @prop {boolean} pinRight (Optional)
* Should the dropdown be pinned to the right edge?
*/
export default class DropdownMenu extends React.Component {
static propTypes = {
trigger: pt.node.isRequired,
children: pt.node.isRequired,
disabled: pt.bool,
useClick: pt.bool,
useFocus: pt.bool,
pinRight: pt.bool,
pinTop: pt.bool,
display: pt.bool,
};
constructor(props) {
super(props);
// hidden by default
this.state = {
isVisible: false,
};
this.toggleVisibility = ::this.toggleVisibility;
this.enableVisibility = ::this.enableVisibility;
this.disableVisibility = ::this.disableVisibility;
this.onMenuBlur = ::this.onMenuBlur;
}
/**
* On mount, generates a new component ID
* if one does not already exist.
*
* (This prevents components interfering,
* due to how the component uses class selectors)
*
*/
componentDidMount() {
this.id = this.id || `dropdown-menu-${uuid()}`;
// Track mounted state, since the `blur` target handler
// can sometimes fire after the element has been removed
this.mounted = true;
}
/**
* On unmount, hides the modal (if visible)
* and removes all relevant bindings.
*
*/
componentWillUnmount() {
// just toggle it to hide when component unmounts
// (this will automatically remove event bindings etc too)
this.toggleVisibility(false);
// update the 'mounted' flag
this.mounted = false;
}
/**
* Click handler to determine if user clicked inside the menu.
* If the user clicked OUTSIDE of the menu, it is closed.
*
* @param {MouseEvent} evt Original click event
*/
onMenuBlur(evt) {
// determine if the click was inside of this .dropdown-menu
if (!closest(evt.target, `.${this.id}`)) {
// and if so, close it
this.toggleVisibility(false);
}
}
/**
* Adds or removes click event handler on the body
* (used to close menu if user clicks out of menu)
* based on the `shouldBind` param.
*
* @param {boolean} shouldBind Should the event be attached?
*/
updateWindowBinding(shouldBind) {
if (shouldBind) {
document.body.addEventListener('click', this.onMenuBlur, true);
} else {
document.body.removeEventListener('click', this.onMenuBlur);
}
}
enableVisibility() {
return this.toggleVisibility(true);
}
disableVisibility() {
return this.toggleVisibility(false);
}
/**
* Shows or hides the menu based on previous state
* or if the `force` param is passed.
* @param {Boolean} force (Optional) Value to set visibility
*/
toggleVisibility(force) {
// by default we toggle the state
let newVisibleState = !this.state.isVisible;
// check if we are forcing the state
if (typeof force === 'boolean') {
newVisibleState = force;
}
// this event can fire sometimes when the target has already left the page
// so we track if the component is still mounted or not to update state
if (this.mounted) {
this.setState({
isVisible: newVisibleState,
});
}
// add or remove the event based on visibility
this.updateWindowBinding(newVisibleState);
}
render() {
const {
pinRight,
pinTop,
useClick,
useFocus,
trigger,
display,
children,
disabled,
} = this.props;
const {
isVisible,
} = this.state;
const menuClass = cx('dropdown-menu', this.id);
const contentClass = cx('dropdown-content',
pinRight && 'pin-right',
pinTop && 'pin-top',
);
if (display === false) {
return null;
}
return (
<div
className={menuClass}
>
<div
className="dropdown-trigger"
onClick={useClick && this.enableVisibility}
onFocus={useFocus && this.enableVisibility}
onChange={useFocus && this.enableVisibility}
onKeyDown={useFocus && this.enableVisibility}
>
{ trigger }
</div>
{
!disabled && isVisible &&
<div className={contentClass}>
{ children }
</div>
}
</div>
);
}
}

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

@ -1,56 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { push } from 'react-router-redux';
import makeApiRequest from 'control_old/api';
import { singleRecipeReceived } from 'control_old/actions/RecipeActions';
import composeRecipeContainer from 'control_old/components/RecipeContainer';
class EnableRecipe extends React.Component {
static propTypes = {
dispatch: pt.func.isRequired,
recipeId: pt.number.isRequired,
recipe: pt.object,
}
constructor(props) {
super(props);
this.enableRecipe = ::this.enableRecipe;
}
enableRecipe(event) {
const { dispatch, recipe, recipeId } = this.props;
event.preventDefault();
dispatch(makeApiRequest('enableRecipe', { recipeId }))
.then(() => {
dispatch(singleRecipeReceived(recipe));
dispatch(push(`/control_old/recipe/${recipeId}/`));
});
}
render() {
const { recipe } = this.props;
if (!recipe) { return null; }
return (
<div className="fluid-8">
<form action="" className="crud-form">
<p>Are you sure you want to enable &quote;{recipe.name}&quote;?</p>
<div className="form-action-buttons">
<div className="fluid-2 float-right">
<input
type="submit"
value="Confirm"
className="submit"
onClick={this.enableRecipe}
/>
</div>
</div>
</form>
</div>
);
}
}
export default composeRecipeContainer(EnableRecipe);

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

@ -1,135 +0,0 @@
import { Map } from 'immutable';
import React, { PropTypes as pt } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { Field, initialize, reduxForm } from 'redux-form';
import _ from 'underscore';
import QueryExtension from 'control_old/components/data/QueryExtension';
import { ControlField, FileInput } from 'control_old/components/Fields';
import { showNotification } from 'control_old/actions/NotificationActions';
import { createExtension, updateExtension } from 'control_old/state/extensions/actions';
import { getExtension } from 'control_old/state/extensions/selectors';
class ExtensionForm extends React.Component {
static propTypes = {
createExtension: pt.func.isRequired,
extension: pt.object,
extensionId: pt.number,
handleSubmit: pt.func.isRequired,
initializeForm: pt.func.isRequired,
initialValues: pt.object,
showNotification: pt.func.isRequired,
updateExtension: pt.func.isRequired,
}
constructor(props) {
super(props);
this.handleSave = ::this.handleSave;
}
componentWillReceiveProps(nextProps) {
const { initialValues, initializeForm } = this.props;
if (!_.isEqual(initialValues, nextProps.initialValues)) {
initializeForm('extension', nextProps.initialValues);
}
}
async handleSave(values) {
const { extensionId } = this.props;
const data = values;
if (!(data.xpi instanceof File)) {
delete data.xpi;
}
try {
if (extensionId) {
await this.props.updateExtension(extensionId, data);
} else {
await this.props.createExtension(data);
}
this.props.showNotification({
messageType: 'success',
message: 'Extension saved.',
});
} catch (error) {
this.props.showNotification({
messageType: 'error',
message: 'Extension cannot be saved. Please correct any errors listed in the form below.',
});
}
}
render() {
const { extension, extensionId, handleSubmit } = this.props;
return (
<form className="recipe-form">
{
extensionId ?
<QueryExtension pk={extensionId} />
: null
}
<ControlField
label="Name"
name="name"
component="input"
type="text"
/>
{
extension &&
<label className="form-field">
<span className="label">XPI URL</span>
<a
className="display-field"
href={extension.get('xpi', '#')}
target="_blank"
rel="noopener noreferrer"
>
{extension.get('xpi')}
</a>
</label>
}
<label className="form-field">
<span className="label">Upload a new XPI</span>
<Field name="xpi" component={FileInput} accept=".xpi" />
</label>
<div className="form-actions">
<button
className="button action-save submit"
type="submit"
onClick={handleSubmit(this.handleSave)}
>
Save
</button>
</div>
</form>
);
}
}
export default connect(
(state, props) => {
const pk = parseInt(props.params.pk, 10);
return {
extensionId: pk,
extension: getExtension(state, pk, new Map()),
initialValues: getExtension(state, pk, new Map()).toJS(),
};
},
dispatch => (bindActionCreators({
initializeForm: initialize,
createExtension,
showNotification,
updateExtension,
}, dispatch)),
)(reduxForm({ form: 'extension' })(ExtensionForm));

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

@ -1,60 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { connect } from 'react-redux';
import { browserHistory } from 'react-router';
import { Table, Thead, Th, Tr, Td } from 'reactable';
import QueryExtensions from 'control_old/components/data/QueryExtensions';
import { getAllExtensions } from 'control_old/state/extensions/selectors';
import { isRequestInProgress } from 'control_old/state/requests/selectors';
class ExtensionList extends React.Component {
static propTypes = {
extensions: pt.array.isRequired,
isLoading: pt.bool.isRequired,
};
showDetails(extension) {
browserHistory.push(`/control_old/extension/${extension.get('id')}/`);
}
render() {
const { extensions, isLoading } = this.props;
return (
<div>
<QueryExtensions />
<div className="fluid-8">
{isLoading && <div className="loading callout">Loading...</div>}
<Table
className="recipe-list"
sortable
>
<Thead>
<Th column="name">
<span>Name</span>
</Th>
</Thead>
{extensions.map(extension =>
(<Tr
key={extension.get('id')}
onClick={() => this.showDetails(extension)}
>
<Td column="name">{extension.get('name')}</Td>
</Tr>),
)}
</Table>
</div>
</div>
);
}
}
export default connect(
state => ({
extensions: getAllExtensions(state).toArray(),
isLoading: isRequestInProgress(state, 'fetch-all-extensions'),
}),
)(ExtensionList);

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

@ -1,224 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { Field } from 'redux-form';
import { uniq } from 'underscore';
/**
* redux-form Field component that wraps the form input in a label and error
* message container.
*
* See buildControlField for supported props.
*/
export function ControlField({ component, ...args }) {
return <Field component={buildControlField} InputComponent={component} {...args} />;
}
ControlField.propTypes = {
component: pt.oneOfType([pt.func, pt.string]).isRequired,
};
/**
* Builds the React component that is rendered for ControlField.
*/
export function buildControlField({
input,
meta: { error },
label,
className = '',
InputComponent,
hideErrors = false,
children,
wrapper = 'label',
...args // eslint-disable-line comma-dangle
}) {
const WrappingElement = wrapper;
return (
<WrappingElement className={`${className} form-field`}>
{label && <span className="label">{label}</span>}
{!hideErrors && error && <span className="error">{error}</span>}
<InputComponent {...input} {...args}>
{children}
</InputComponent>
</WrappingElement>
);
}
buildControlField.propTypes = {
input: pt.object.isRequired,
meta: pt.shape({
error: pt.oneOfType([pt.string, pt.array]),
}).isRequired,
label: pt.string,
wrapper: pt.oneOfType([pt.func, pt.string]),
className: pt.string,
InputComponent: pt.oneOfType([pt.func, pt.string]),
hideErrors: pt.bool,
children: pt.node,
};
export class IntegerControlField extends React.Component {
parse(value) {
return Number.parseInt(value, 10);
}
render() {
return (
<ControlField
component="input"
type="number"
step="1"
parse={this.parse}
{...this.props}
/>
);
}
}
export class BooleanRadioControlField extends React.Component {
parse(value) {
return value === 'true';
}
format(value) {
if (value) {
return 'true';
} else if (value !== undefined) {
return 'false';
}
return undefined;
}
render() {
return (
<ControlField
component="input"
type="radio"
className="radio-field"
parse={this.parse}
format={this.format}
{...this.props}
/>
);
}
}
export function ErrorMessageField(props) {
return <Field component={buildErrorMessageField} {...props} />;
}
export function buildErrorMessageField({ meta: { error } }) {
if (error) {
return <span className="error">{error}</span>;
}
return null;
}
buildErrorMessageField.propTypes = {
meta: pt.shape({
error: pt.oneOfType([pt.string, pt.array]),
}).isRequired,
};
export class CheckboxGroup extends React.Component {
static propTypes = {
name: pt.string.isRequired,
onChange: pt.func.isRequired,
options: pt.array.isRequired,
value: pt.arrayOf(pt.string),
disabled: pt.bool,
};
constructor(props) {
super(props);
this.state = {};
this.handleChange = ::this.handleChange;
this.renderOption = ::this.renderOption;
}
/**
* Checkbox change event handler. Appends or removes the selected checkbox's
* value to the existing `value` prop, and reports the change up to redux-form.
*
* @param {Event} onChange event object
*/
handleChange({ target }) {
const { onChange } = this.props;
const value = this.props.value || [];
let newValue = [];
if (target.checked) {
newValue = uniq(value.concat([target.value]));
} else {
newValue = value.filter(val => val !== target.value);
}
onChange(newValue);
}
renderOption(option, index) {
const {
name,
disabled,
value = [],
} = this.props;
return (
<label className="checkbox" key={index}>
<input
type="checkbox"
name={name}
value={option.value}
checked={value.includes(option.value)}
onChange={this.handleChange}
disabled={disabled}
/>
<span>{option.label}</span>
</label>
);
}
render() {
const {
options = [],
} = this.props;
return (
<div className="checkbox-list">
{ options.map(this.renderOption) }
</div>
);
}
}
/**
* Input component for handling files
*/
function adaptFileEventToValue(delegate) {
return e => delegate(e.target.files[0]);
}
export function FileInput({
input: {
value: omitValue, // eslint-disable-line no-unused-vars
onChange,
onBlur,
...inputProps
},
meta: omitMeta, // eslint-disable-line no-unused-vars
...props
}) {
return (
<input
onChange={adaptFileEventToValue(onChange)}
onBlur={adaptFileEventToValue(onBlur)}
type="file"
{...inputProps}
{...props}
/>
);
}
FileInput.propTypes = {
input: pt.object,
meta: pt.any,
};

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

@ -1,23 +0,0 @@
import React, { PropTypes as pt } from 'react';
import cx from 'classnames';
export default function Frame({ title, children, className }) {
return (
<fieldset className={cx('form-frame', className)}>
{
title &&
<legend className="frame-title">
{ title }
</legend>
}
{ children }
</fieldset>
);
}
Frame.propTypes = {
children: pt.node,
className: pt.string,
title: pt.string,
};

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

@ -1,168 +0,0 @@
import React, { PropTypes as pt } from 'react';
/**
* Simple menu component which displays groups of items
* under section headers/labels.
*/
export default class GroupMenu extends React.Component {
static propTypes = {
data: pt.array.isRequired,
onItemSelect: pt.func.isRequired,
searchText: pt.string,
};
static INTIAL_DISPLAY_COUNT = 5;
constructor(props) {
super(props);
this.state = {
expanded: {},
};
}
/**
* Creates a click handler based on the
* group/option passed into it, basically
* an event handler factory.
*
* @param {Object} group Group which clicked option belongs to
* @param {Object} option Option which was clicked
* @return {Function} Wrapped handler
*/
makeClickItemHandler(group, option) {
// if no cache exists, build it real quick
this.clickItemCache = this.clickItemCache || {};
// reference variable for brevity
const cache = this.clickItemCache;
const cacheKey = group.value + option.value + option.label;
// if the cache misses,
if (!cache[cacheKey]) {
// generate the handler
cache[cacheKey] = () =>
this.props.onItemSelect(group, option);
}
// return the (now cached) handler function
return cache[cacheKey];
}
makeViewMoreHandler(group) {
this.viewMoreHandler = this.viewMoreHandler || {};
const cache = this.viewMoreHandler;
const groupKey = group.value;
if (!cache[groupKey]) {
cache[groupKey] = () => {
this.setState({
expanded: {
...this.state.expanded,
[groupKey]: !this.state.expanded[groupKey],
},
});
};
}
return cache[groupKey];
}
/**
* Given a searchText param, creates a filter menu item
* indicating user can add a custom text filter.
*
* @param {string} searchText (Optional) Text user has entered into combobox
* @return {Node} Compiled menu item displaying 'add [text] filter'
*/
buildTextSearchMessage(searchText) {
let searchMessage;
if (searchText) {
searchMessage = (
<div
key={'text'}
className={'text'}
>
<h3 className="group-label">Text Search</h3>
<div
className={'menu-item'}
key={searchText}
onClick={this.makeClickItemHandler('text', searchText)}
>
{searchText}
</div>
</div>
);
}
return searchMessage;
}
render() {
const {
data,
searchText,
} = this.props;
const textSearchMessage = this.buildTextSearchMessage(searchText);
const maxCount = GroupMenu.INTIAL_DISPLAY_COUNT;
return (
<div
className="group-menu"
>
{ textSearchMessage }
{
data.map(group => {
const {
value,
options,
label,
} = group;
// the group is expanded if there's search text,
// or if the user has already manually expanded the group
const groupIsExpanded = !!searchText || this.state.expanded[value];
// if expanded, display options are the default options
// if not expanded, truncate list to INITIAL_DISPLAY_COUNT items
const displayedOptions = groupIsExpanded ?
options : options.slice(0, maxCount);
// determine if there are some hidden to the user or not
const hasSomeHidden = !groupIsExpanded &&
options.length > maxCount;
return (
<div
key={value}
className={value}
>
<h3 className={`group-label ${value}`}>{label}</h3>
{
displayedOptions.map((option, index) =>
(<div
className={`menu-item ${option.value}`}
key={index}
onClick={this.makeClickItemHandler(group, option)}
>
{ option.label || option.value }
</div>),
)
}
{
hasSomeHidden &&
<span
className="view-more"
onClick={this.makeViewMoreHandler(group)}
>
View more...
</span>
}
</div>
);
})
}
</div>
);
}
}

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

@ -1,37 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { Link } from 'react-router';
import Breadcrumbs from 'react-breadcrumbs';
import absolutePath from '../../utils/absolute-path';
export default function Header({ pageType: { ctaButtons }, currentLocation, routes, params }) {
let ctaBtns;
if (ctaButtons) {
ctaBtns = ctaButtons.map(({ text, icon, link }, index) =>
(<Link className="button" to={absolutePath(currentLocation, link)} key={index}>
<i className={`pre fa fa-${icon}`} /> {text}
</Link>),
);
}
return (
<div id="page-header">
<h2>
<Breadcrumbs
routes={routes}
params={params}
displayMissing={false}
hideNoPath
separator={<i className="fa fa-chevron-right" />}
/>
</h2>
{ctaBtns}
</div>
);
}
Header.propTypes = {
pageType: pt.object.isRequired,
currentLocation: pt.string.isRequired,
routes: pt.array.isRequired,
params: pt.object.isRequired,
};

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

@ -1,317 +0,0 @@
import React, { PropTypes as pt } from 'react';
import cx from 'classnames';
import Frame from 'control_old/components/Frame';
/**
* Abstracted piece of the multipicker - contains the actual select element and
* the text field that allows filtering of that list. Fires props upon selection change.
*/
class PickerControl extends React.Component {
static propTypes = {
options: pt.array.isRequired,
onSubmit: pt.func.isRequired,
titleLabel: pt.string.isRequired,
buttonLabel: pt.string.isRequired,
noneLabel: pt.string.isRequired,
searchLabel: pt.string.isRequired,
disabled: pt.bool,
className: pt.string,
};
constructor(props) {
super(props);
this.state = {
// `filterText` is the user-inputted string to filter the list options by
filterText: '',
// `focused` is the existing options that the user currently has
focused: [],
};
this.handleTextChange = ::this.handleTextChange;
this.handleSelectUpdate = ::this.handleSelectUpdate;
this.handleConfirmation = ::this.handleConfirmation;
this.renderSelectOption = ::this.renderSelectOption;
this.handleRefMount = ::this.handleRefMount;
}
/**
* Given an array of options and some search text, returns an array of items that
* contain the search text in their value/label. This is used to filter out
* items in the <select> element based on the user's inputted search text.
*
* @param {Array<{value: string, label: string }>} options Available options
* @param {String} search Text to search over options with
* @return {Array<Object>} Array of available options that meet search criteria
*/
getFilteredOptions(options, search) {
if (search) {
// Lowercase-ify the search value to remove case sensitivity
const filterValue = search.toLowerCase();
// Determine if selected option properties contain the filterValue.
return options.filter(({ value, label }) =>
[value, label].some(str => str.toLowerCase().indexOf(filterValue) > -1));
}
return options;
}
/**
* Gathers selected options and reports them as a comma-delineated string to
* the parent component. This is fired when user has made a selection and then
* decided to.. do something with it.
*
* @return {String} Comma-separated string of selected option values
*/
handleConfirmation() {
this.props.onSubmit(this.state.focused);
// Clear the internal selection memory
this.setState({
focused: [],
});
// Clear user selection (otherwise it will remain in place, even after list
// children are added/removed)
this.selectedRef.value = null;
}
/**
* Updates internal `focused` state with currently focused options, which is
* used later when 'submitting' the selection.
*/
handleSelectUpdate(evt) {
// Convert selected options to an array of string values.
const focused = Array.prototype.slice.call(evt.target.selectedOptions).map(opt => opt.value);
this.setState({
focused,
});
}
/**
* Updates internal `filterText` state with user-inputed text value, which is
* later used for filtering displayed options.
*
* @param {Event} event onChange event object
*/
handleTextChange(event) {
this.setState({
filterText: event.target.value,
});
}
/**
* <select>'s ref handler. Simply stores a reference to this component's select
* element.
*
* @param {Element} ref Element for this <select>
*/
handleRefMount(ref) {
this.selectedRef = ref;
}
/**
* Given an option object, renders a correlating <option> element.
*
* @param {Object<{ label: string, value: string }>} option
* @param {any} key Value to use for `key` prop on this element
* @return {Element} Compiled <option> element
*/
renderSelectOption(option, key) {
return (
<option
key={key}
title={option.value}
value={option.value}
>
{option.label}
</option>
);
}
/**
* Returns a "no results found" or "no items selected" message. This is
* primarily used when the user is actively filtering items, or as the general
* 'empty list' state.
*
* @return {Element} <option> element containing 'empty' message
*/
renderEmptyMessage() {
const {
noneLabel,
} = this.props;
const {
filterText,
} = this.state;
return (
<option disabled className="option-label">
{filterText ? `No results found for "${filterText}"` : noneLabel}
</option>
);
}
render() {
const {
filterText,
focused,
} = this.state;
const {
buttonLabel,
className,
options,
searchLabel,
titleLabel,
disabled,
} = this.props;
const filteredOptions = this.getFilteredOptions(options, filterText);
const frameClass = cx('mp-frame', className);
return (
<Frame className={frameClass} title={titleLabel}>
<input
type="search"
placeholder={searchLabel}
onChange={this.handleTextChange}
/>
<select
ref={this.handleRefMount}
onChange={this.handleSelectUpdate}
disabled={disabled}
multiple
>
{
filteredOptions.length ?
filteredOptions.map(this.renderSelectOption)
: this.renderEmptyMessage()
}
</select>
<button
disabled={disabled || focused.length <= 0}
type="button"
onClick={this.handleConfirmation}
>
{ buttonLabel }
</button>
</Frame>
);
}
}
/**
* MultiPicker component - allows user to select multiple options and group them
* together into a separate list. Provides redux-form props (such as `onChange`)
* to work within forms.
*/
export default class MultiPicker extends React.Component {
static propTypes = {
unit: pt.string.isRequired,
onChange: pt.func.isRequired,
options: pt.array,
disabled: pt.bool,
value: pt.arrayOf(pt.string),
};
constructor(props) {
super(props);
this.state = {};
this.handleApplySelection = ::this.handleApplySelection;
this.handleRemoveSelection = ::this.handleRemoveSelection;
}
/**
* Event handler for a PickerControl that has fired its `onConfirm` prop.
* At this point, a user has selected options in one of the pickers, and wants
* to do something with it. In this case, they want to 'apply' the selection,
* which essentially means 'mark it as selected and move it to the other PickerControl'.
*
* @param {Array<string>} selection Array of selected values to apply
*/
handleApplySelection(selection = []) {
const val = this.props.value || [];
// Get a set of unique selected values from existing values and those coming in
const uniqueSelections = new Set(val.concat(selection));
// Convert the Set of unique values into an array.
const newEnabled = Array.from(uniqueSelections);
// Send that value to redux-form.
this.props.onChange(newEnabled);
}
/**
* Event handler for a PickerControl that has fired its `onConfirm` prop.
* At this point, a user has selected options in one of the pickers, and wants
* to do something with it. In this case, they want to 'remove' the selection,
* which essentially means 'mark it as NOT selected and move it to the first PickerControl'.
*
* @param {Array<string>} selection Array of selected values to remove
*/
handleRemoveSelection(selection = []) {
const {
value = [],
} = this.props;
// New enabled selections will be those remaining after filtering de-selections.
const newEnabled = value.filter(val => selection.indexOf(val) === -1);
// Send that value to redux-form.
this.props.onChange(newEnabled);
}
/**
* Render
*/
render() {
const {
unit = '',
value,
options,
disabled,
} = this.props;
const lowercaseUnit = unit.toLowerCase();
// `value` is the currently selected value for the component from redux-form.
// We can compare the given `options` with the `value` to determine what has been selected
// by the user already or not, and then populate the displayed lists accordingly.
const availableOptions = options.filter(option => value.indexOf(option.value) === -1);
const selectedOptions = options.filter(option => value.indexOf(option.value) !== -1);
return (
<div className="multipicker">
<PickerControl
options={availableOptions}
className="mp-from"
titleLabel={`Available ${unit}`}
searchLabel={`Filter Available ${unit}`}
onSubmit={this.handleApplySelection}
buttonLabel={`Add ${unit}`}
noneLabel={`No ${lowercaseUnit} available.`}
disabled={disabled}
/>
<PickerControl
options={selectedOptions}
className="mp-to"
titleLabel={`Selected ${unit}`}
searchLabel={`Filter Selected ${unit}`}
onSubmit={this.handleRemoveSelection}
buttonLabel={`Remove ${unit}`}
noneLabel={`No ${lowercaseUnit} selected.`}
disabled={disabled}
/>
</div>
);
}
}

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

@ -1,15 +0,0 @@
import React from 'react';
import { Link } from 'react-router';
/**
* 404-ish view shown for routes that don't match any valid route.
*/
export default function NoMatch() {
return (
<div className="no-match fluid-8">
<h2>Page Not Found</h2>
<p>Sorry, we could not find the page you&apos;re looking for.</p>
<p><Link to="/control_old/">Click here to return to the control index.</Link></p>
</div>
);
}

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

@ -1,70 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { connect } from 'react-redux';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import { dismissNotification } from 'control_old/actions/NotificationActions';
const notificationPropType = pt.shape({
messageType: pt.string,
message: pt.string,
});
export class DisconnectedNotifications extends React.Component {
static propTypes = {
dispatch: pt.func,
notifications: pt.arrayOf(notificationPropType),
}
removeNotification(notificationId) {
const { dispatch } = this.props;
dispatch(dismissNotification(notificationId));
}
render() {
const { notifications } = this.props;
return (
<ReactCSSTransitionGroup
component="div"
className="notifications"
transitionName="notification"
transitionEnterTimeout={200}
transitionLeaveTimeout={200}
>
{notifications.map(n => (
<Notification
key={n.id}
notification={n}
toRemove={() => this.removeNotification(n.id)}
/>
))}
</ReactCSSTransitionGroup>
);
}
}
export function Notification({ notification, toRemove }) {
return (
<div className="notification">
<p className={`message ${notification.messageType}`}>
{notification.message}
<i
className="fa fa-lg fa-times remove-message"
onClick={toRemove}
/>
</p>
</div>
);
}
Notification.propTypes = {
notification: notificationPropType.isRequired,
toRemove: pt.func,
};
const mapStateToProps = state => ({
notifications: state.notifications,
});
export default connect(
mapStateToProps,
)(DisconnectedNotifications);

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

@ -1,180 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { omit } from 'underscore';
import DropdownMenu from 'control_old/components/DropdownMenu';
import GroupMenu from 'control_old/components/GroupMenu';
/**
* Text/dropdown combobox displayed in RecipeFilters.
* Used to display and search over filter options
* based on user input.
*/
export default class RecipeCombobox extends React.Component {
static propTypes = {
availableFilters: pt.array.isRequired,
onFilterSelect: pt.func.isRequired,
};
/**
* Constructor
*/
constructor(props) {
super(props);
this.state = {
searchText: '',
};
this.updateSearch = ::this.updateSearch;
this.handleFilterSelect = ::this.handleFilterSelect;
}
/**
* Given a set of filter groups + options, and a search text value,
* returns a set of filters in which the search value is found.
*
* Certain properties are NOT searched over, such as `value`,
* `selected`, and `multiple` - this would allow for searching
* the words 'true' or 'false' to return hits incorrectly.
*
* @param {Array<Object>} groups Array of filter groups/options to search
* @param {string} search Text value to find in the filter groups
* @return {Array<Object>} Array of filter groups containing search value
*/
filterGroups(groups, search) {
const searchText = (search || '').toLowerCase();
this.filterCache = this.filterCache || {};
const cacheKey = `${Object.keys(groups).join(',')}::${searchText}`;
if (this.filterCache[cacheKey]) {
return this.filterCache[cacheKey];
}
const searchRegex = new RegExp(searchText, 'ig');
const containsSearch = obj =>
!!Object.keys(obj)
.find(key => searchRegex.test(obj[key]));
const filteredGroups = []
.concat(groups)
.map(group => {
const newGroup = { ...group };
// remove properties user doesnt care to search over
newGroup.options = newGroup.options.filter(option => {
const newOption = omit({ ...option }, [
// if an option has a label,
// remove the hidden value
option.label ? 'value' : 'label',
'selected',
'multiple',
]);
return containsSearch(newOption);
});
return newGroup.options.length ? newGroup : null;
})
.filter(x => x);
this.filterCache[cacheKey] = filteredGroups;
return filteredGroups;
}
/**
* Resets the text field input, blurs the input,
* and updates the 'searchText' state.
*
* Fired after user selects a filter option or enters
* a text filter.
*/
clearInput() {
this.setState({
searchText: '',
});
if (this.inputRef) {
this.inputRef.value = '';
}
}
/**
* Key event handler for text field. Updates local
* searchText state. Also detects if user
* has hit the ENTER key, and if so, will update activated
* filters to include this text search.
*
* @param {node} options.target Event target
* @param {string} options.keyCode Keyboard event key code
*/
updateSearch({ target, keyCode }) {
// Enter key
if (typeof keyCode !== 'undefined' && keyCode === 13) {
this.props.onFilterSelect('text', target.value);
this.clearInput();
} else {
this.setState({
searchText: target.value,
});
}
}
/**
* Handler for user selecting a filter to enable.
* Basically clears the input field and then fires
* the parent onFilterSelect prop.
*
* @param {Object} group Filter group that was activated
* @param {Object} option Filter option that was activated
*/
handleFilterSelect(group, option) {
this.clearInput();
return this.props.onFilterSelect(group, option);
}
render() {
const {
availableFilters,
} = this.props;
const {
searchText,
} = this.state;
// if we have search text,
// use that to filter out the option groups
const result = searchText && this.filterGroups(availableFilters, searchText);
const filterOptions = searchText ? result : availableFilters;
return (
<div className="search input-with-icon">
<DropdownMenu
useFocus
disabled={!searchText && filterOptions.length === 0}
trigger={
<input
type="text"
placeholder="Search"
defaultValue={searchText}
onKeyUp={this.updateSearch}
onChange={this.updateSearch}
ref={input => { this.inputRef = input; }}
/>
}
>
<GroupMenu
searchText={searchText}
data={filterOptions}
onItemSelect={this.handleFilterSelect}
/>
</DropdownMenu>
</div>
);
}
}

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

@ -1,105 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { connect } from 'react-redux';
import makeApiRequest from 'control_old/api';
import {
singleRecipeReceived,
setSelectedRecipe,
setSelectedRevision,
revisionsReceived,
} from 'control_old/actions/RecipeActions';
import {
getSelectedRevision,
} from 'control_old/selectors/RecipesSelector';
export class RecipeContainer extends React.Component {
static propTypes = {
dispatch: pt.func.isRequired,
recipe: pt.object.isRequired,
routeParams: pt.object.isRequired,
}
componentWillMount() {
if (this.props.recipeId) {
this.getRecipeData(this.props.recipeId, this.props.routeParams.revisionId);
}
}
componentWillReceiveProps({ recipeId, routeParams = {} }) {
const isRecipeChanging = recipeId !== this.props.recipeId;
const isRouteRevisionChanging =
routeParams.revisionId !== this.props.routeParams.revisionId;
if (isRecipeChanging || isRouteRevisionChanging) {
this.getRecipeData(recipeId, routeParams && routeParams.revisionId);
}
if (isRouteRevisionChanging) {
this.props.dispatch(setSelectedRevision(routeParams.revisionId));
}
}
getRecipeData(recipeId, revisionId) {
const { dispatch } = this.props;
if (!recipeId) {
return;
}
dispatch(setSelectedRecipe(recipeId));
dispatch(setSelectedRevision(revisionId));
dispatch(makeApiRequest('fetchSingleRecipe', { recipeId }))
.then(newRecipe => {
dispatch(singleRecipeReceived(newRecipe));
this.getRecipeHistory(recipeId, revisionId || newRecipe.revision_id);
});
}
getRecipeHistory(recipeId, revisionId) {
const { dispatch } = this.props;
dispatch(makeApiRequest('fetchRecipeHistory', { recipeId }))
.then(revisions => {
dispatch(setSelectedRevision(revisionId));
dispatch(revisionsReceived({
recipeId,
revisions,
}));
});
}
render() {
return null;
}
}
RecipeContainer.propTypes = {
recipeId: React.PropTypes.number,
recipe: React.PropTypes.object,
dispatch: React.PropTypes.func,
};
export default function composeRecipeContainer(Component) {
class ComposedRecipeContainer extends RecipeContainer {
render() {
return <Component {...this.props} {...this.state} />;
}
}
const mapStateToProps = (state, props) => ({
/* eslint-disable react/prop-types */
dispatch: props.dispatch,
recipeId: state.recipes.selectedRecipe || parseInt(props.params.id, 10) || null,
// Get selected recipe + revision data
...getSelectedRevision(state),
/* eslint-enable react/prop-types */
});
ComposedRecipeContainer.propTypes = { ...RecipeContainer.propTypes };
return connect(
mapStateToProps,
)(ComposedRecipeContainer);
}

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

@ -1,175 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { connect } from 'react-redux';
import { isEqual } from 'underscore';
import ColumnMenu from 'control_old/components/ColumnMenu';
import ActiveFilters from 'control_old/components/ActiveFilters';
import RecipeCombobox from 'control_old/components/RecipeCombobox';
import {
loadLocalColumns,
} from 'control_old/actions/ColumnActions';
import {
loadFilters,
selectFilter,
resetFilters,
loadFilteredRecipes,
} from 'control_old/actions/FilterActions';
import {
getSelectedFilterGroups,
getAvailableFilters,
} from 'control_old/selectors/FiltersSelector';
/**
* Filters displayed above the RecipeList table.
*
* Contains the big filter combobox, the 'active filters' section,
* and the column menu.
*/
export class RecipeFilters extends React.Component {
static propTypes = {
// connected
selectedFilters: pt.array.isRequired,
availableFilters: pt.array.isRequired,
columns: pt.array.isRequired,
loadLocalColumns: pt.func.isRequired,
loadFilters: pt.func.isRequired,
selectFilter: pt.func.isRequired,
resetFilters: pt.func.isRequired,
loadFilteredRecipes: pt.func.isRequired,
};
constructor(props) {
super(props);
this.state = {};
this.handleAddFilter = ::this.handleAddFilter;
this.handleRemoveFilter = ::this.handleRemoveFilter;
this.resetFilters = ::this.resetFilters;
}
/**
* Loads user's last column display setup,
* as well as their last active list filters.
*
* @return {void}
*/
componentWillMount() {
// load the last column setup user was viewing
this.props.loadLocalColumns();
this.props.loadFilters();
}
componentWillReceiveProps({ selectedFilters }) {
if (!isEqual(selectedFilters, this.props.selectedFilters)) {
this.props.loadFilteredRecipes();
}
}
/**
* User has selected a filter option from the
* combobox dropdown. This handler simply fires
* the redux action to SET_FILTER.
*
* @param {Object} group Parent group of selected option
* @param {Object} option Selected option
* @return {void}
*/
handleAddFilter(group, option) {
const filterGroup = typeof group === 'object' ?
group : { value: group };
this.props.selectFilter({
group: filterGroup,
option,
// if this handler is fired, we know the user is
// ADDING the filter - it's removed later
isEnabled: true,
});
}
/**
* User has clicked on an individual filter/option
* in order to deactivate it. Basically calls a
* select filter action with `isEnabled` set to `false`
*
* @param {Object} group Relevant group that was updated
* @param {Object} option Relevant option that was removed
* @return {void}
*/
handleRemoveFilter(group, option) {
this.props.selectFilter({
group,
option,
isEnabled: false,
});
}
/**
* Simple helper to dispatch a 'reset filters' action.
*
* @return {void}
*/
resetFilters() {
this.props.resetFilters();
}
render() {
const {
availableFilters,
columns,
} = this.props;
return (
<div className="fluid-8">
<div id="secondary-header" className="fluid-8">
<div className="header-search fluid-2">
<RecipeCombobox
onFilterSelect={this.handleAddFilter}
availableFilters={availableFilters}
/>
</div>
<div id="filters-container" className="fluid-6">
<ColumnMenu
columns={columns}
onColumnChange={this.handleColumnInput}
/>
</div>
<ActiveFilters
className="fluid-8"
selectedFilters={this.props.selectedFilters}
onResetFilters={this.resetFilters}
onFilterSelect={this.handleRemoveFilter}
/>
</div>
</div>
);
}
}
const mapStateToProps = state => ({
// use selectors to pull specific store data
selectedFilters: getSelectedFilterGroups(state.filters),
availableFilters: getAvailableFilters(state.filters),
// columns
columns: state.columns,
});
const mapDispatchToProps = {
loadFilters,
loadLocalColumns,
selectFilter,
resetFilters,
loadFilteredRecipes,
};
export default connect(
mapStateToProps,
mapDispatchToProps,
)(RecipeFilters);

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

@ -1,615 +0,0 @@
/* eslint-disable import/no-named-as-default */
import React, { PropTypes as pt } from 'react';
import { connect } from 'react-redux';
import { Link, locationShape } from 'react-router';
import { push } from 'react-router-redux';
import {
reduxForm,
formValueSelector,
propTypes as reduxFormPropTypes,
SubmissionError,
} from 'redux-form';
import { pick } from 'underscore';
import makeApiRequest from 'control_old/api';
import {
getFilterObject,
} from 'control_old/selectors/FiltersSelector';
import {
recipeUpdated,
recipeAdded,
setSelectedRecipe,
singleRecipeReceived,
revisionsReceived,
revisionRecipeUpdated,
} from 'control_old/actions/RecipeActions';
import {
loadFilters,
} from 'control_old/actions/FilterActions';
import {
showNotification,
} from 'control_old/actions/NotificationActions';
import {
userInfoReceived,
} from 'control_old/actions/ControlActions';
import {
getLastApprovedRevision,
} from 'control_old/selectors/RecipesSelector';
import composeRecipeContainer from 'control_old/components/RecipeContainer';
import { ControlField, CheckboxGroup } from 'control_old/components/Fields';
import RecipeFormActions from 'control_old/components/RecipeFormActions';
import HeartbeatFields from 'control_old/components/action_fields/HeartbeatFields';
import ConsoleLogFields from 'control_old/components/action_fields/ConsoleLogFields';
import PreferenceExperimentFields from
'control_old/components/action_fields/PreferenceExperimentFields';
import OptOutStudyFields from 'control_old/components/action_fields/OptOutStudyFields';
import MultiPicker from 'control_old/components/MultiPicker';
import Frame from 'control_old/components/Frame';
import RecipeStatus from 'control_old/components/RecipeStatus';
import DraftStatus from 'control_old/components/DraftStatus';
import BooleanIcon from 'control_old/components/BooleanIcon';
export const formSelector = formValueSelector('recipe');
// The arguments field is handled in initialValuesWrapper.
const DEFAULT_FORM_VALUES = {
name: '',
extra_filter_expression: '',
action: '',
};
/**
* Form for creating new recipes or editing existing recipes.
*/
export class RecipeForm extends React.Component {
static propTypes = {
handleSubmit: reduxFormPropTypes.handleSubmit,
submitting: reduxFormPropTypes.submitting,
selectedAction: pt.string,
recipeId: pt.number,
recipe: pt.shape({
name: pt.string.isRequired,
enabled: pt.bool.isRequired,
extra_filter_expression: pt.string.isRequired,
action: pt.string.isRequired,
arguments: pt.object.isRequired,
}),
revision: pt.object,
allRevisions: pt.object,
user: pt.object,
dispatch: pt.func.isRequired,
// route props passed from router
route: pt.object,
routeParams: pt.object.isRequired,
// from redux-form
pristine: pt.bool,
recipeArguments: pt.object,
filters: pt.object.isRequired,
loadFilters: pt.func.isRequired,
};
static argumentsFields = {
'console-log': ConsoleLogFields,
'show-heartbeat': HeartbeatFields,
'preference-experiment': PreferenceExperimentFields,
'opt-out-study': OptOutStudyFields,
};
static LoadingSpinner = (
<div className="recipe-form loading">
<i className="fa fa-spinner fa-spin fa-3x fa-fw" />
<p>Loading recipe...</p>
</div>
);
static renderCloningMessage({ route, recipe }) {
const isCloning = route && route.isCloning;
if (!isCloning || !recipe) {
return null;
}
return (
<span className="cloning-message callout">
{'You are cloning '}
<Link to={`/control_old/recipe/${recipe.id}/`}>
{recipe.name} ({recipe.action})
</Link>.
</span>
);
}
constructor(props) {
super(props);
this.handleFormAction = ::this.handleFormAction;
}
componentWillMount() {
const {
user,
dispatch,
} = this.props;
dispatch(loadFilters());
if (!user || !user.id) {
dispatch(makeApiRequest('getCurrentUser'))
.then(receivedUser => dispatch(userInfoReceived(receivedUser)));
}
}
/**
* Generates an object relevant to the user/draft state. All values returned
* are cast as booleans.
*
* @return {Object} Hash of user/draft state data, formatted as booleans
*/
getRenderVariables() {
const {
route,
routeParams = {},
recipe = {},
revision = {},
allRevisions = {},
pristine,
submitting,
recipeId,
user: {
id: userId,
},
} = this.props;
const requestDetails = revision && revision.approval_request;
const currentUserID = userId;
const isViewingLatestApproved = recipe && recipe.approved_revision_id
&& revision.revision_id === recipe.approved_revision_id;
const hasApprovalRequest = !!requestDetails;
const requestAuthorID = hasApprovalRequest && requestDetails.creator.id;
const isUserViewingOutdated = recipe && routeParams.revisionId
&& routeParams.revisionId !== recipe.latest_revision_id;
const isPendingApproval = hasApprovalRequest && requestDetails.approved === null;
const isFormDisabled = submitting || (isPendingApproval && !isUserViewingOutdated) || !userId;
const isAccepted = hasApprovalRequest && requestDetails.approved === true;
const isRejected = hasApprovalRequest && requestDetails.approved === false;
const recipeRevisions = recipe ? allRevisions[recipe.id] : {};
const lastApprovedRevisionId = getLastApprovedRevision(recipeRevisions).revision_id;
return {
isCloning: !!(route && route.isCloning),
isUserRequester: requestAuthorID === currentUserID,
isPeerApprovalEnforced: document.documentElement.dataset.peerApprovalEnforced === 'true',
isAlreadySaved: !!recipeId,
isFormPristine: pristine,
isApproved: !!recipeId && requestDetails && requestDetails.approved,
isRecipeApproved: !!recipeId && recipe.is_approved,
isEnabled: !!recipeId && !!revision.enabled,
isUserViewingOutdated,
isViewingLatestApproved,
isPendingApproval,
isFormDisabled,
isAccepted,
isRejected,
hasApprovalRequest,
lastApprovedRevisionId,
};
}
getRecipeHistory(recipeId) {
const {
dispatch,
} = this.props;
return dispatch(makeApiRequest('fetchRecipeHistory', { recipeId }))
.then(revisions => {
dispatch(revisionsReceived({
recipeId,
revisions,
}));
});
}
/**
* Event handler for form action buttons.
* Form action buttons remotely fire this handler with a (string) action type,
* which then triggers the appropriate API requests/etc.
*
* @param {string} action Action type to trigger. ex: 'cancel', 'approve', 'reject'
*/
handleFormAction(action, data) {
const {
recipe,
revision,
dispatch,
} = this.props;
const revisionId = revision ? revision.latest_revision_id : recipe.latest_revision_id;
switch (action) {
case 'cancel':
return dispatch(makeApiRequest('closeApprovalRequest', {
requestId: recipe.approval_request.id,
})).then(() => {
// show success
dispatch(showNotification({
messageType: 'success',
message: 'Approval request closed.',
}));
// remove approval request from recipe in memory
dispatch(singleRecipeReceived({
...recipe,
approval_request: null,
}));
dispatch(revisionRecipeUpdated({
recipe: {
...revision,
approval_request: null,
},
revisionId,
}));
});
case 'approve':
return dispatch(makeApiRequest('approveApprovalRequest', {
requestId: recipe.approval_request.id,
...data,
})).then(updatedRequest => {
// show success
dispatch(showNotification({
messageType: 'success',
message: 'Revision was approved.',
}));
// remove approval request from recipe in memory
dispatch(singleRecipeReceived({
...recipe,
is_approved: true,
approved_revision_id: revision.revision_id,
approval_request: updatedRequest,
}));
dispatch(revisionRecipeUpdated({
recipe: {
...revision,
approval_request: updatedRequest,
},
revisionId,
}));
});
case 'reject':
return dispatch(makeApiRequest('rejectApprovalRequest', {
requestId: recipe.approval_request.id,
...data,
})).then(updatedRequest => {
// show success
dispatch(showNotification({
messageType: 'success',
message: 'Revision was rejected.',
}));
// update approval request from recipe in memory
dispatch(singleRecipeReceived({
...recipe,
is_approved: false,
approval_request: updatedRequest,
}));
dispatch(revisionRecipeUpdated({
recipe: {
...revision,
approval_request: updatedRequest,
},
revisionId,
}));
});
case 'request':
return dispatch(makeApiRequest('openApprovalRequest', {
revisionId: revision ? revision.latest_revision_id : recipe.latest_revision_id,
})).then(response => {
// show success message
dispatch(showNotification({
messageType: 'success',
message: 'Approval requested.',
}));
// patch existing recipe with new approval_request
dispatch(singleRecipeReceived({
...recipe,
approval_request: response,
}));
dispatch(revisionRecipeUpdated({
recipe: {
...revision,
approval_request: response,
},
revisionId,
}));
});
default:
throw new Error(`Unrecognized form action "${action}"`);
}
}
render() {
const {
handleSubmit,
selectedAction,
recipe,
revision,
recipeId,
recipeArguments,
filters,
} = this.props;
const noop = () => null;
const ArgumentsFields = RecipeForm.argumentsFields[selectedAction] || noop;
// Show a loading indicator if we haven't yet loaded the recipe.
if (recipeId && (!recipe && !revision)) {
return RecipeForm.LoadingSpinner;
}
const renderVars = this.getRenderVariables();
const {
isFormDisabled,
lastApprovedRevisionId,
} = renderVars;
const thisRevisionRequest = revision && revision.approval_request;
const statusText = renderVars.isEnabled ? 'Enabled' : 'Disabled';
return (
<form className="recipe-form" onSubmit={handleSubmit}>
{ RecipeForm.renderCloningMessage(this.props) }
{
revision &&
<DraftStatus
latestRevisionId={recipe.latest_revision_id}
lastApprovedRevisionId={lastApprovedRevisionId}
recipe={revision}
/>
}
{
recipe && (
<RecipeStatus
className={renderVars.isEnabled ? 'green' : 'red'}
icon={
<BooleanIcon
className="draft-status-icon"
value={renderVars.isEnabled}
title={statusText}
/>
}
text={statusText}
/>
)
}
{
thisRevisionRequest
&& (thisRevisionRequest.approved === true || thisRevisionRequest.approved === false)
&& (
<div className="approval-status">
This revision has been <b>{renderVars.isAccepted ? 'approved' : 'rejected'}</b>:
<pre className="approval-comment">
{revision.approval_request.comment}
<span className="comment-author">
&ndash; {revision.approval_request.approver.email}
</span>
</pre>
</div>
)
}
<ControlField
disabled={isFormDisabled}
label="Name"
name="name"
component="input"
type="text"
/>
<Frame title="Filters">
<ControlField
component={MultiPicker}
disabled={isFormDisabled}
wrapper="div"
name="locales"
unit="Locales"
options={filters.locales || []}
/>
<ControlField
component={MultiPicker}
disabled={isFormDisabled}
wrapper="div"
name="countries"
unit="Countries"
options={filters.countries || []}
/>
<Frame className="channels" title="Release Channels">
<ControlField
component={CheckboxGroup}
disabled={isFormDisabled}
name="channels"
options={filters.channels || []}
/>
</Frame>
<ControlField
label="Additional Filter Expressions"
disabled={isFormDisabled}
name="extra_filter_expression"
component="textarea"
/>
</Frame>
<Frame title="Action Configuration">
<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>
<option value="opt-out-study">Opt-out Study</option>
</ControlField>
<ArgumentsFields disabled={isFormDisabled} fields={recipeArguments} />
</Frame>
<RecipeFormActions
onAction={this.handleFormAction}
recipeId={recipeId}
{...renderVars}
/>
</form>
);
}
}
/**
* Redux-Form config for the RecipeForm.
*/
export const formConfig = {
form: 'recipe',
enableReinitialize: true,
asyncBlurFields: ['extra_filter_expression'],
keepDirtyOnReinitialize: true,
async asyncValidate(values) {
const errors = {};
// Validate that filter expression is not empty
if (!values.extra_filter_expression) {
errors.extra_filter_expression = 'Filter expression cannot be empty.';
}
// Throw if we found any errors.
if (Object.keys(errors).length > 0) {
throw errors;
}
},
onSubmit(values, dispatch, { route, recipeId, updateRecipe, addRecipe }) {
// 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;
let result;
if (recipeId && !isCloning) {
result = updateRecipe(recipeId, recipe);
} else {
result = addRecipe(recipe);
}
// Wrap error responses with a SubmissionError for redux-form.
return result.catch(errors => {
throw new SubmissionError(errors);
});
},
onSubmitSuccess(result, dispatch) {
dispatch(showNotification({
messageType: 'success',
message: 'Recipe saved.',
}));
},
onSubmitFail(errors, dispatch) {
dispatch(showNotification({
messageType: 'error',
message: 'Recipe cannot be saved. Please correct any errors listed in the form below.',
}));
},
};
/**
* Component wrapper that passes the recipe (or currently selected revision) as
* the initialValues prop for the form.
* @param Component Component to wrap.
*/
export function initialValuesWrapper(Component) {
function Wrapped(props) {
const { recipe, revision, selectedAction } = props;
let initialValues = revision || recipe;
// If we still don't have initial values, roll with the defaults.
if (!initialValues) {
initialValues = { ...DEFAULT_FORM_VALUES, arguments: {} };
// ActionField subclasses define their own initial values.
if (selectedAction) {
const ActionFields = RecipeForm.argumentsFields[selectedAction];
initialValues.arguments = { ...ActionFields.initialValues };
}
}
return <Component initialValues={initialValues} {...props} />;
}
Wrapped.propTypes = {
recipe: pt.object,
revision: pt.object,
location: locationShape,
selectedAction: pt.string,
};
return Wrapped;
}
const connector = connect(
// Pull selected action from the form state.
state => ({
user: state.user,
allRevisions: state.recipes.revisions,
selectedAction: formSelector(state, 'action'),
recipeArguments: formSelector(state, 'arguments'),
filters: getFilterObject(state.filters),
}),
// Bound functions for writing to the server.
dispatch => ({
loadFilters,
addRecipe(recipe) {
return dispatch(makeApiRequest('addRecipe', { recipe }))
.then(response => {
dispatch(recipeAdded(response));
dispatch(push(`/control_old/recipe/${response.id}/revision/${response.latest_revision_id}/`));
dispatch(setSelectedRecipe(response.id));
});
},
updateRecipe(recipeId, recipe) {
return dispatch(makeApiRequest('updateRecipe', { recipeId, recipe }))
.then(response => {
dispatch(recipeUpdated({
...response,
recipe: response,
}));
dispatch(push(`/control_old/recipe/${response.id}/revision/${response.latest_revision_id}/`));
});
},
}),
);
// Use reduce to call several wrapper functions in a row.
export default [
reduxForm(formConfig),
initialValuesWrapper,
connector,
composeRecipeContainer,
].reduce((prev, func) => func(prev), RecipeForm);

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

@ -1,263 +0,0 @@
import React, {
PropTypes as pt,
} from 'react';
import { Link } from 'react-router';
import cx from 'classnames';
import DropdownMenu from 'control_old/components/DropdownMenu';
export const FormButton = ({
className,
label,
element = 'button',
type = 'button',
onClick,
display,
...props
}) => {
if (display === false) {
return null;
}
// need titlecase for JSX
const Element = element;
return (
<Element
className={cx('button', className)}
onClick={onClick}
type={type}
key={label + className}
{...props}
>
{label}
</Element>
);
};
FormButton.propTypes = {
display: pt.bool,
className: pt.string,
label: pt.string,
element: pt.any,
type: pt.string,
onClick: pt.func,
};
export default class RecipeFormActions extends React.Component {
static propTypes = {
onAction: pt.func.isRequired,
isApproved: pt.bool,
isEnabled: pt.bool,
isUserViewingOutdated: pt.bool,
isViewingLatestApproved: pt.bool,
isPendingApproval: pt.bool,
isUserRequester: pt.bool,
isAlreadySaved: pt.bool,
isFormPristine: pt.bool,
isCloning: pt.bool,
isFormDisabled: pt.bool,
isAccepted: pt.bool,
isRejected: pt.bool,
hasApprovalRequest: pt.bool,
recipeId: pt.number,
isPeerApprovalEnforced: pt.bool,
};
constructor(props) {
super(props);
this.state = {
comment: {},
};
}
onCommentChange(type) {
return evt => {
this.setState({
comment: {
...this.state.comment,
[type]: evt.target.value,
},
});
};
}
getActions({
isRecipeApproved,
isEnabled,
isViewingLatestApproved,
isUserViewingOutdated,
isPendingApproval,
isUserRequester,
isAlreadySaved,
isFormPristine,
isCloning,
isFormDisabled,
hasApprovalRequest,
recipeId,
isPeerApprovalEnforced,
}) {
return [
// delete
<FormButton
key="delete"
display={isAlreadySaved && !isCloning}
disabled={isFormDisabled}
className="action-delete delete"
label="Delete"
element={Link}
to={`/control_old/recipe/${recipeId}/delete/`}
/>,
// save
<FormButton
key="save"
disabled={isFormPristine}
display={isAlreadySaved && !isCloning}
className="action-save submit"
type="submit"
label="Save Draft"
/>,
// new
<FormButton
key="new"
disabled={isFormPristine}
display={!isAlreadySaved || isCloning}
className="action-new submit"
type="submit"
label="Save New Recipe"
/>,
// enable
<FormButton
key="enable"
display={isRecipeApproved && isViewingLatestApproved && !isEnabled}
className="action-enable submit"
label="Enable"
element={isRecipeApproved ? Link : 'button'}
to={`/control_old/recipe/${recipeId}/enable/`}
/>,
// disable
<FormButton
key="disable"
display={!isUserViewingOutdated && isEnabled}
className="action-disable submit delete"
label="Disable"
element={Link}
to={`/control_old/recipe/${recipeId}/disable/`}
/>,
// cancel
<FormButton
key="cancel"
display={!isUserViewingOutdated && isPendingApproval}
className="action-cancel submit delete"
onClick={this.createActionEmitter('cancel')}
label="Cancel Review"
/>,
// approve
<DropdownMenu
key="approve"
display={!isUserViewingOutdated && isPendingApproval && !isCloning}
pinTop
pinRight
useClick
trigger={
<FormButton
disabled={isUserRequester && isPeerApprovalEnforced}
className="action-approve submit"
label="Approve"
/>
}
>
<div className="approve-dropdown">
Add a review comment
<textarea
defaultValue={this.state.comment.approve}
onChange={this.onCommentChange('approve')}
/>
<FormButton
className="mini-button"
type="button"
onClick={() => {
this.props.onAction('approve', {
comment: this.state.comment.approve,
});
}}
disabled={!this.state.comment.approve}
>
Approve
</FormButton>
</div>
</DropdownMenu>,
// reject
<DropdownMenu
key="reject"
display={!isUserViewingOutdated && isPendingApproval && !isCloning}
pinTop
pinRight
useClick
trigger={
<FormButton
disabled={isUserRequester && isPeerApprovalEnforced}
className="action-reject submit delete"
label="Reject"
/>
}
>
<div className="reject-dropdown">
Add a review comment
<textarea
defaultValue={this.state.comment.reject}
onChange={this.onCommentChange('reject')}
/>
<FormButton
className="mini-button"
type="button"
onClick={() => {
this.props.onAction('reject', {
comment: this.state.comment.reject,
});
}}
disabled={!this.state.comment.reject}
>
Reject
</FormButton>
</div>
</DropdownMenu>,
// request
<FormButton
key="request"
display={!isUserViewingOutdated && !hasApprovalRequest
&& !isPendingApproval && isAlreadySaved && !isCloning}
disabled={!isFormPristine}
className="action-request submit"
onClick={this.createActionEmitter('request')}
label="Request Approval"
/>,
];
}
createActionEmitter(type) {
this.actionCache = this.actionCache || {};
if (!this.actionCache[type]) {
this.actionCache[type] = () => {
this.props.onAction(type);
};
}
return this.actionCache[type];
}
/**
* Render
*/
render() {
return (
<div className="form-actions">
{this.getActions(this.props).map((Action, idx) =>
// Need to key the action buttons to satisfy a React warning
React.cloneElement(Action, { key: idx }), // eslint-disable-line react/no-array-index-key
)}
</div>
);
}
}

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

@ -1,148 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { push } from 'react-router-redux';
import moment from 'moment';
import composeRecipeContainer from 'control_old/components/RecipeContainer';
import makeApiRequest from 'control_old/api';
import DraftStatus from 'control_old/components/DraftStatus';
import {
getLastApprovedRevision,
} from 'control_old/selectors/RecipesSelector';
export class DisconnectedRecipeHistory extends React.Component {
static propTypes = {
dispatch: pt.func.isRequired,
recipeId: pt.number.isRequired,
recipe: pt.object,
}
constructor(props) {
super(props);
this.state = {
revisions: [],
};
}
componentDidMount() {
const { recipeId } = this.props;
this.getHistory(recipeId);
}
getHistory(recipeId) {
const { dispatch } = this.props;
dispatch(makeApiRequest('fetchRecipeHistory', { recipeId }))
.then(history => {
this.setState({
revisions: history,
});
});
}
render() {
const { recipe, dispatch } = this.props;
const { revisions } = this.state;
return <HistoryList recipe={recipe} dispatch={dispatch} revisions={revisions} />;
}
}
export function HistoryList({ recipe, revisions, dispatch }) {
const lastApprovedId = getLastApprovedRevision(revisions).id;
return (
<div className="fluid-8 recipe-history">
<h3>Viewing revision log for: <b>{recipe ? recipe.name : ''}</b></h3>
<table>
<tbody>
{revisions.map(revision =>
(<HistoryItem
key={revision.id}
revision={revision}
recipe={recipe}
dispatch={dispatch}
approvedId={lastApprovedId}
/>),
)}
</tbody>
</table>
</div>
);
}
HistoryList.propTypes = {
dispatch: pt.func.isRequired,
revisions: pt.arrayOf(pt.object).isRequired,
recipe: pt.object,
};
export class HistoryItem extends React.Component {
static propTypes = {
dispatch: pt.func.isRequired,
revision: pt.shape({
recipe: pt.shape({
revision_id: pt.string.isRequired,
}).isRequired,
date_created: pt.string.isRequired,
comment: pt.string.isRequired,
}).isRequired,
recipe: pt.shape({
revision_id: pt.string.isRequired,
}),
approvedId: pt.string,
}
constructor(props) {
super(props);
this.handleClick = ::this.handleClick;
}
/**
* When a revision is clicked, open the recipe form with changes from
* the clicked revision.
*/
handleClick() {
const { dispatch, revision, recipe } = this.props;
// Do not include form state changes if the current revision was
// clicked.
dispatch(push(`/control_old/recipe/${recipe.id}/revision/${revision.id}/`));
}
render() {
const {
revision,
recipe,
approvedId,
} = this.props;
return (
<tr className="history-item" onClick={this.handleClick}>
<td className="revision-number">
{revision && revision.recipe && revision.recipe.revision_id}
</td>
<td className="revision-created">
<span className="label">Created On:</span>
{moment(revision.date_created).format('MMM Do YYYY - h:mmA')}
</td>
<td className="revision-comment">
{
!!revision.comment &&
<span>
<span className="label">Comment:</span>
<span className="comment-text">{revision.comment || '--'}</span>
</span>
}
</td>
<td>
<DraftStatus
latestRevisionId={recipe && recipe.latest_revision_id}
lastApprovedRevisionId={approvedId}
recipe={revision.recipe}
/>
</td>
</tr>
);
}
}
export default composeRecipeContainer(DisconnectedRecipeHistory);

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

@ -1,238 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { connect } from 'react-redux';
import { push } from 'react-router-redux';
import { Table, Thead, Th, Tr, Td } from 'reactable';
import { isObject } from 'underscore';
import makeApiRequest from 'control_old/api';
import {
recipesReceived,
setSelectedRecipe,
} from 'control_old/actions/RecipeActions';
import {
getRecipesList,
} from 'control_old/selectors/RecipesSelector';
import {
getActiveColumns,
} from 'control_old/selectors/ColumnSelector';
import BooleanIcon from 'control_old/components/BooleanIcon';
import RecipeFilters from 'control_old/components/RecipeFilters';
export class DisconnectedRecipeList extends React.Component {
static propTypes = {
// connected
dispatch: pt.func.isRequired,
isFetching: pt.bool.isRequired,
recipeListNeedsFetch: pt.bool.isRequired,
recipes: pt.array.isRequired,
displayedColumns: pt.array.isRequired,
};
/**
* Recipe metadata properties and associated labels to display
* @type {Object}
*/
static ActionMetadata = {
'show-heartbeat': recipe => [
{ label: 'Survey ID', value: recipe.arguments.surveyId },
],
};
/**
* Given a recipe object, determines what type of recipe it is (based on its `action`),
* and then compiles an array of 'displayed metadata props' and their values. This array
* is saved on the recipe as `metadata`, and displayed in the 'metadata' column.
*
* Beyond that, a string of metadata values is created, and attached to the
* recipe as the `searchData` property. This is used by the `Table` component
* to search/filter/sort the metadata.
*
* @param {Object} Original recipe object
* @return {Object} Original recipe but with `metadata` and `searchData` properties added
*/
static applyRecipeMetadata(recipe) {
const { action: recipeAction } = recipe;
const newRecipe = {
...recipe,
// recipes should have empty metadata/searchData props,
// regardless if we set the values or not
metadata: [],
searchData: '',
};
// check if there are specific properties/labels we want to display
const requestedMetaProps = DisconnectedRecipeList.ActionMetadata[recipeAction];
// if we have a metadata definition to fill...
if (requestedMetaProps) {
// ...get the data we want to display
const foundData = requestedMetaProps(newRecipe);
// ...and add it to the existing metadata collection
// (the data comes back as an array of objects,
// so we can just concat it to our existing array)
newRecipe.metadata = newRecipe.metadata.concat(foundData);
}
// update the searchdata string with whatever the values are
// (this is used for sorting/filtering/searching)
newRecipe.metadata.forEach(data => {
newRecipe.searchData = `${newRecipe.searchData} ${data.value}`;
});
return newRecipe;
}
constructor(props) {
super(props);
this.state = {};
this.handlerCache = {};
this.handleViewRecipe = ::this.handleViewRecipe;
}
componentWillMount() {
const { dispatch, isFetching, recipeListNeedsFetch } = this.props;
dispatch(setSelectedRecipe(null));
if (recipeListNeedsFetch && !isFetching) {
dispatch(makeApiRequest('fetchAllRecipes', {}))
.then(recipes => dispatch(recipesReceived(recipes)));
}
}
/**
* Event handler factory for user attempting to 'view recipe.'
* Caches generated functions to prevent lots of function
* creation on each render loop.
*
* @param {string} recipe Recipe object that the user is trying to view
* @return {function} Generated event handler for this recipe
*/
handleViewRecipe(recipe) {
if (!this.handlerCache[recipe.id]) {
const { dispatch } = this.props;
this.handlerCache[recipe.id] = () => {
dispatch(setSelectedRecipe(recipe.id));
dispatch(push(`/control_old/recipe/${recipe.id}/revision/${recipe.latest_revision_id}`));
};
}
return () => {
this.handlerCache[recipe.id]();
};
}
renderTableCell(recipe) {
return ({ slug }) => {
let displayValue = recipe[slug];
// if the value is a straight up boolean value,
if (displayValue === true || displayValue === false) {
// switch the displayed value to a ×/✓ mark
displayValue = (
<BooleanIcon
value={displayValue}
/>
);
} else if (isObject(displayValue)) {
displayValue = (
<ul className="nested-list">
{
/* Display each nested property in a list */
displayValue
.map((nestedProp, index) => (
<li key={index}>
<span className="nested-label">{`${nestedProp.label}: `}</span>
{nestedProp.value}
</li>
))
}
</ul>
);
}
return (
<Td
key={slug}
column={slug}
data={displayValue}
>
{displayValue}
</Td>
);
};
}
render() {
const {
recipes,
displayedColumns,
recipeListNeedsFetch,
} = this.props;
const filteredRecipes = [].concat(recipes)
.map(DisconnectedRecipeList.applyRecipeMetadata);
const noResults = filteredRecipes.length > 0;
return (
<div>
<RecipeFilters />
<div className="fluid-8">
{
!noResults && recipeListNeedsFetch &&
<div className="loading callout">Loading...</div>
}
<Table
className="recipe-list"
sortable
hideFilterInput
filterable={['name', 'action', 'metadata']}
>
<Thead>
{
displayedColumns.map((col, index) =>
(<Th
key={col.slug + index}
column={col.slug}
>
<span>{col.label}</span>
</Th>),
)
}
</Thead>
{filteredRecipes.map(recipe =>
(<Tr
key={recipe.id}
onClick={this.handleViewRecipe(recipe)}
>
{
displayedColumns.map(this.renderTableCell(recipe))
}
</Tr>),
)}
</Table>
</div>
</div>
);
}
}
const mapStateToProps = (state, ownProps) => ({
recipes: getRecipesList(state.recipes, state.filters),
dispatch: ownProps.dispatch,
recipeListNeedsFetch: state.recipes.recipeListNeedsFetch,
isFetching: state.controlApp.isFetching,
displayedColumns: getActiveColumns(state.columns),
});
export default connect(
mapStateToProps,
)(DisconnectedRecipeList);

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

@ -1,32 +0,0 @@
import React, { PropTypes as pt } from 'react';
import cx from 'classnames';
export default function RecipeStatus(props) {
const {
icon,
className,
text,
flavorText = [],
} = props;
return (
<div className={cx('status-indicator', className)}>
{ icon }
<div className="status-text">
<span>{ text }</span>
{
!!flavorText.length &&
<div className="flavor-text">
{ flavorText.map((flav, index) => <div key={index}>{flav}</div>) }
</div>
}
</div>
</div>
);
}
RecipeStatus.propTypes = {
icon: pt.node,
text: pt.string,
className: pt.string,
flavorText: pt.array,
};

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

@ -1,11 +0,0 @@
import React from 'react';
export default class ActionFields extends React.Component {
/**
* Used as the value of the arguments field in the initialValues prop
* passed to RecipeForm. This isn't inherited; subclasses should
* define their own if necessary.
* @type {Object}
*/
static initialValues = {};
}

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

@ -1,29 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { ControlField } from 'control_old/components/Fields';
import ActionFields from 'control_old/components/action_fields/ActionFields';
/**
* Form fields for the console-log action.
*/
export default class ConsoleLogFields extends ActionFields {
static propTypes = {
disabled: pt.bool,
}
render() {
const { disabled } = this.props;
return (
<div className="arguments-fields">
<p className="info">Log a message to the console.</p>
<ControlField
disabled={disabled}
label="Message"
name="arguments.message"
component="input"
type="text"
/>
</div>
);
}
}

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

@ -1,126 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { connect } from 'react-redux';
import { ControlField } from 'control_old/components/Fields';
import { formSelector } from 'control_old/components/RecipeForm';
import ActionFields from 'control_old/components/action_fields/ActionFields';
/**
* Form fields for the show-heartbeat action.
*/
export class HeartbeatFields extends ActionFields {
static propTypes = {
recipeArguments: pt.object.isRequired,
disabled: pt.bool,
}
render() {
const {
recipeArguments = {},
disabled,
} = this.props;
return (
<div className="arguments-fields">
<p className="info">
Shows a single message or survey prompt to the user.
</p>
<ControlField
label="Survey ID"
name="arguments.surveyId"
component="input"
type="text"
disabled={disabled}
/>
<ControlField
label="Message"
name="arguments.message"
component="input"
type="text"
disabled={disabled}
/>
<ControlField
label="Engagement Button Label"
name="arguments.engagementButtonLabel"
component="input"
type="text"
disabled={disabled}
/>
<ControlField
label="Thanks Message"
name="arguments.thanksMessage"
component="input"
type="text"
disabled={disabled}
/>
<ControlField
label="Post-Answer URL"
name="arguments.postAnswerUrl"
component="input"
type="text"
disabled={disabled}
/>
<ControlField
label="Learn More Message"
name="arguments.learnMoreMessage"
component="input"
type="text"
disabled={disabled}
/>
<ControlField
label="Learn More URL"
name="arguments.learnMoreUrl"
component="input"
type="text"
disabled={disabled}
/>
<ControlField
label="How often should the prompt be shown?"
name="arguments.repeatOption"
component="select"
disabled={disabled}
>
<option value="once" default>{`
Do not show this prompt to users more than once.
`}</option>
<option value="nag">{`
Show this prompt until the user clicks the button/stars,
and then never again.
`}</option>
<option value="xdays">{`
Allow re-prompting users who have already seen this prompt
after ${recipeArguments.repeatEvery || 'X'}
days since they last saw it.
`}</option>
</ControlField>
{
recipeArguments.repeatOption === 'xdays' &&
<ControlField
label="Days before user is re-prompted"
name="arguments.repeatEvery"
component="input"
type="number"
/>
}
<ControlField
label="Include unique user ID in Post-Answer URL (and Telemetry)"
name="arguments.includeTelemetryUUID"
component="input"
type="checkbox"
className="checkbox-field"
disabled={disabled}
/>
</div>
);
}
}
export default connect(
state => ({
recipeArguments: formSelector(state, 'arguments'),
}),
)(HeartbeatFields);

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

@ -1,72 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { connect } from 'react-redux';
import { ControlField } from 'control_old/components/Fields';
import ActionFields from 'control_old/components/action_fields/ActionFields';
import QueryExtensions from 'control_old/components/data/QueryExtensions';
import { getAllExtensions } from 'control_old/state/extensions/selectors';
/**
* Form fields for the opt-out-study action.
*/
class OptOutStudyFields extends ActionFields {
static propTypes = {
disabled: pt.bool,
extensions: pt.array,
}
render() {
const { disabled, extensions } = this.props;
return (
<div className="arguments-fields">
<QueryExtensions />
<p className="info">Enroll the user in an opt-out SHIELD study</p>
<ControlField
disabled={disabled}
label="Study Name"
name="arguments.name"
component="input"
type="text"
/>
<ControlField
disabled={disabled}
label="Study Description"
name="arguments.description"
component="textarea"
/>
<ControlField
disabled={disabled}
label="Add-on"
name="arguments.addonUrl"
component="select"
>
{extensions.map(extension => (
<option key={extension.get('id')} value={extension.get('xpi')}>
{extension.get('name')}
</option>
))}
</ControlField>
<ControlField
label="Pause Enrollment (If checked, no new study participants will be enrolled)"
name="arguments.isEnrollmentPaused"
component="input"
type="checkbox"
className="checkbox-field"
disabled={disabled}
/>
</div>
);
}
}
export default connect(
state => ({
extensions: getAllExtensions(state).toArray(),
}),
)(OptOutStudyFields);

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

@ -1,275 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { FieldArray } from 'redux-form';
import { connect } from 'react-redux';
import {
BooleanRadioControlField,
ControlField,
ErrorMessageField,
IntegerControlField,
} from 'control_old/components/Fields';
import { formSelector } from 'control_old/components/RecipeForm';
import ActionFields from 'control_old/components/action_fields/ActionFields';
const VALUE_FIELDS = {
string: StringPreferenceField,
integer: IntegerPreferenceField,
boolean: BooleanPreferenceField,
};
const DEFAULT_BRANCH_VALUES = {
slug: '',
ratio: 1,
};
/**
* Form fields for the preference-experiment action.
*/
export class PreferenceExperimentFields extends ActionFields {
static propTypes = {
disabled: pt.bool,
}
static initialValues = {
slug: '',
experimentDocumentUrl: '',
preferenceName: '',
preferenceType: 'boolean',
preferenceBranchType: 'default',
}
static propTypes = {
preferenceBranchType: pt.string.isRequired,
}
static userBranchWarning = (
<p className="field-warning">
<i className="fa fa-exclamation-triangle" />
Setting user preferences instead of default ones is not recommended.
Do not choose this unless you know what you are doing.
</p>
);
render() {
const { disabled, preferenceBranchType } = this.props;
return (
<div className="arguments-fields">
<p className="info">Run a feature experiment activated by a preference.</p>
<ControlField
label="Slug"
name="arguments.slug"
component="input"
type="text"
disabled={disabled}
/>
<ControlField
label="Experiment Document URL"
name="arguments.experimentDocumentUrl"
component="input"
type="url"
disabled={disabled}
/>
<ControlField
label="Preference Name"
name="arguments.preferenceName"
component="input"
type="text"
disabled={disabled}
/>
<ControlField
label="Preference Type"
name="arguments.preferenceType"
component="select"
disabled={disabled}
>
<option value="boolean">Boolean</option>
<option value="integer">Integer</option>
<option value="string">String</option>
</ControlField>
<ControlField
label="Preference Branch Type"
name="arguments.preferenceBranchType"
component="select"
disabled={disabled}
>
<option value="default">Default</option>
<option value="user">User</option>
</ControlField>
{preferenceBranchType === 'user' && PreferenceExperimentFields.userBranchWarning}
<FieldArray
name="arguments.branches"
component={PreferenceBranches}
disabled={disabled}
/>
</div>
);
}
}
export default connect(
state => ({
preferenceBranchType: formSelector(state, 'arguments.preferenceBranchType') || 'default',
}),
)(PreferenceExperimentFields);
export class PreferenceBranches extends React.Component {
static propTypes = {
fields: pt.object.isRequired,
disabled: pt.bool,
}
constructor(props) {
super(props);
this.handleClickDelete = ::this.handleClickDelete;
this.handleClickAdd = ::this.handleClickAdd;
}
handleClickDelete(index) {
if (!this.props.disabled) {
this.props.fields.remove(index);
}
}
handleClickAdd() {
if (!this.props.disabled) {
this.props.fields.push({ ...DEFAULT_BRANCH_VALUES });
}
}
render() {
const { fields, disabled } = this.props;
return (
<div>
<h4 className="branch-header">Experiment Branches</h4>
<ul className="branch-list">
{fields.map((branch, index) => (
<li key={index} className="branch">
<ConnectedBranchFields
branch={branch}
index={index}
disabled={disabled}
onClickDelete={this.handleClickDelete}
/>
</li>
))}
{!disabled && <AddBranchButton onClick={this.handleClickAdd} />}
</ul>
</div>
);
}
}
export function AddBranchButton({ onClick }) {
return (
<li>
<a className="button" onClick={onClick}>
<i className="fa fa-plus pre" />
Add Branch
</a>
</li>
);
}
AddBranchButton.propTypes = {
onClick: pt.func.isRequired,
};
export class BranchFields extends React.Component {
static propTypes = {
branch: pt.string.isRequired,
onClickDelete: pt.func.isRequired,
preferenceType: pt.string.isRequired,
index: pt.number.isRequired,
disabled: pt.bool,
}
constructor(props) {
super(props);
this.handleClickDelete = ::this.handleClickDelete;
}
handleClickDelete() {
if (!this.props.disabled) {
this.props.onClickDelete(this.props.index);
}
}
render() {
const { branch, preferenceType = 'boolean', disabled } = this.props;
const ValueField = VALUE_FIELDS[preferenceType];
return (
<div className="branch-fields">
<ControlField
label="Branch Slug"
name={`${branch}.slug`}
component="input"
type="text"
disabled={disabled}
/>
<ValueField name={`${branch}.value`} disabled={disabled} />
<IntegerControlField
label="Ratio"
name={`${branch}.ratio`}
disabled={disabled}
/>
{!disabled && <RemoveBranchButton onClick={this.handleClickDelete} />}
</div>
);
}
}
export function RemoveBranchButton({ onClick }) {
return (
<div className="remove-branch">
<a className="button delete" onClick={onClick}>
<i className="fa fa-times pre" />
Remove Branch
</a>
</div>
);
}
RemoveBranchButton.propTypes = {
onClick: pt.func.isRequired,
};
export const ConnectedBranchFields = connect(
state => ({
preferenceType: formSelector(state, 'arguments.preferenceType'),
}),
)(BranchFields);
export function StringPreferenceField(props) {
return (
<ControlField
label="Preference Value"
component="input"
type="text"
{...props}
/>
);
}
export function BooleanPreferenceField(props) {
return (
<fieldset className="fieldset">
<legend className="fieldset-label">Preference Value</legend>
<ErrorMessageField {...props} />
<BooleanRadioControlField
label="True"
value="true"
hideErrors
{...props}
/>
<BooleanRadioControlField
label="False"
value="false"
hideErrors
{...props}
/>
</fieldset>
);
}
export function IntegerPreferenceField(props) {
return (
<IntegerControlField label="Preference Value" {...props} />
);
}

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

@ -1,37 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { fetchExtension } from 'control_old/state/extensions/actions';
class QueryExtension extends React.Component {
static propTypes = {
fetchExtension: pt.func.isRequired,
pk: pt.number.isRequired,
}
componentWillMount() {
const { pk } = this.props;
this.props.fetchExtension(pk);
}
componentWillReceiveProps(nextProps) {
const { pk } = this.props;
if (pk !== nextProps.pk) {
this.props.fetchExtension(nextProps.pk);
}
}
render() {
return null;
}
}
export default connect(
null,
dispatch => (bindActionCreators({
fetchExtension,
}, dispatch)),
)(QueryExtension);

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

@ -1,28 +0,0 @@
import React, { PropTypes as pt } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { fetchAllExtensions } from 'control_old/state/extensions/actions';
class QueryExtensions extends React.Component {
static propTypes = {
fetchAllExtensions: pt.func.isRequired,
}
componentWillMount() {
this.props.fetchAllExtensions();
}
render() {
return null;
}
}
export default connect(
null,
dispatch => (bindActionCreators({
fetchAllExtensions,
}, dispatch)),
)(QueryExtensions);

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

@ -1,19 +0,0 @@
import React, { PropTypes as pt } from 'react';
export default function CheckboxField({ label, field, containerClass }) {
return (
<div className="row">
<div className={containerClass}>
<label htmlFor={field.name}>
<input type="checkbox" field={field} {...field} />
{label}
</label>
</div>
</div>
);
}
CheckboxField.propTypes = {
label: pt.string.isRequired,
field: pt.object.isRequired,
containerClass: pt.string,
};

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

@ -1,71 +0,0 @@
import React, { PropTypes as pt } from 'react';
import NumberField from 'control_old/components/form_fields/NumberField';
const SelectMenu = props => {
const { options, onChange, field } = props;
return (
<select {...field} onChange={onChange}>
<option>Select...</option>
{options.map(name => <option key={name} value={name}>{name}</option>)}
</select>
);
};
SelectMenu.propTypes = {
options: pt.object.isRequired,
onChange: pt.func,
field: pt.object.isRequired,
};
const FormField = props => {
const { label, type, field, containerClass } = props;
let fieldType;
// in some instances (checkbox) we want to nest the label
// so that the input and the text appear inline
let nestInputInLabel = false;
switch (type) {
case 'select':
fieldType = (<SelectMenu {...props} />);
break;
case 'text':
fieldType = (<input type="text" field={field} {...field} />);
break;
case 'number':
fieldType = (<NumberField {...props} />);
break;
case 'textarea':
fieldType = (<textarea field={field} {...field} />);
break;
case 'checkbox':
fieldType = (<input type="checkbox" {...field} />);
nestInputInLabel = true;
break;
default:
throw new Error(`Unexpected field type: "${type}"`);
}
return (
<div className="row">
<div className={containerClass}>
<label htmlFor={field.name}>
{nestInputInLabel && fieldType}
{label}
<span className="validation-error">{field.error}</span>
</label>
{!nestInputInLabel && fieldType}
</div>
</div>
);
};
FormField.propTypes = {
label: pt.string.isRequired,
type: pt.string.isRequired,
field: pt.object.isRequired,
containerClass: pt.string.isRequired,
};
FormField.defaultProps = {
type: 'text',
};
export default FormField;

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

@ -1,48 +0,0 @@
import React, { PropTypes as pt } from 'react';
export default class NumberField extends React.Component {
static propTypes = {
field: pt.object.isRequired,
normalize: pt.func,
onBlur: pt.func,
onChange: pt.func,
}
static defaultProps = {
normalize: value => value && parseInt(value, 10),
}
constructor(props) {
super(props);
this.handleBlur = ::this.handleBlur;
this.handleChange = ::this.handleChange;
}
/* Swallow redux-form's onBlur() so it doesn't reset value to string */
handleBlur() {
if (this.props.onBlur) {
this.props.onBlur();
}
}
/* Trigger redux-form's onChange() after parsing value to integer */
handleChange(event) {
const { normalize, field } = this.props;
const value = event.target.value;
field.onChange(normalize(value));
}
render() {
const { field } = this.props;
return (
<input
type="number"
{...field}
onBlur={this.handleBlur}
onChange={this.handleChange}
/>
);
}
}

Двоичный файл не отображается.

Двоичный файл не отображается.

Двоичный файл не отображается.

Двоичный файл не отображается.

Двоичный файл не отображается.

Двоичный файл не отображается.

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

@ -1,10 +0,0 @@
import ReactDOM from 'react-dom';
import { createApp } from 'control_old/app';
// Initialize the control app and render it.
const app = createApp();
ReactDOM.render(
app.rootComponent,
document.querySelector('#page-container'),
);

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

@ -1,99 +0,0 @@
import { isEqual } from 'underscore';
import {
LOAD_SAVED_COLUMNS,
UPDATE_COLUMN,
saveLocalColumns as saveState,
} from 'control_old/actions/ColumnActions';
const initialState = [{
label: 'Name',
slug: 'name',
enabled: true,
}, {
label: 'Action',
slug: 'action',
enabled: true,
}, {
label: 'Enabled',
slug: 'enabled',
enabled: true,
}, {
label: 'Channels',
slug: 'channels',
}, {
label: 'Locales',
slug: 'locales',
}, {
label: 'Countries',
slug: 'countries',
}, {
label: 'Start Time',
slug: 'startTime',
}, {
label: 'End Time',
slug: 'endTime',
}, {
label: 'Additional Filters',
slug: 'additionalFilter',
}, {
label: 'Last Updated',
slug: 'last_updated',
enabled: true,
}, {
label: 'Metadata',
slug: 'metadata',
enabled: true,
}];
function columnReducer(state = initialState, action) {
let newState;
let slugsMatch;
const {
slug,
isActive,
} = action;
switch (action.type) {
case UPDATE_COLUMN:
newState = [].concat(state);
// find the updated column and set
// its 'enabled' property
newState = newState.map(col => {
const newCol = { ...col };
if (newCol.slug === slug) {
if (isActive) {
newCol.enabled = true;
} else {
delete newCol.enabled;
}
}
return newCol;
});
// save column config locally
saveState(newState);
return newState;
case LOAD_SAVED_COLUMNS:
// double check that the incoming columns
// have the all the same values as our
// initialState. this prevents a user loading
// outdated columns from localStorage
slugsMatch = isEqual(
state.map(option => option.slug + option.label),
action.columns.map(option => option.slug + option.label),
);
if (slugsMatch) {
newState = [].concat(action.columns);
}
return newState || state;
default:
return state;
}
}
export default columnReducer;

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

@ -1,29 +0,0 @@
import {
REQUEST_IN_PROGRESS,
REQUEST_COMPLETE,
} from 'control_old/actions/ControlActions';
const initialState = {
isFetching: false,
};
function controlAppReducer(state = initialState, action) {
switch (action.type) {
case REQUEST_IN_PROGRESS:
return {
...state,
isFetching: true,
};
case REQUEST_COMPLETE:
return {
...state,
isFetching: false,
};
default:
return state;
}
}
export default controlAppReducer;

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

@ -1,177 +0,0 @@
/**
* Store for tracking front-end 'filter list' status
*
* Exists as an array of objects denoting option groups,
* containing each set of options per group.
*
* Active/enabled filters have a `selected` property,
* which is mostly used later in the filter selectors
*/
import {
LOAD_FILTERS,
SET_FILTER,
RESET_FILTERS,
SET_TEXT_FILTER,
} from 'control_old/actions/FilterActions';
/**
* Utility to remove `selected` props from
* filter groups and their options.
*
* @param {Array} filters Array of filter groups + options
* @return {Array} Array with de`select`ed groups + options
*/
const deleteSelects = filters =>
[].concat(filters || [])
.map(filter => {
const newFilter = { ...filter };
delete newFilter.selected;
(newFilter.options || []).forEach(option => {
const newOption = { ...option };
delete newOption.selected;
return newOption;
});
return newFilter;
});
// Filters start out empty, as we need to load them from the API
const initialState = {
list: [],
active: [],
};
function filtersReducer(state = initialState, action) {
let newState;
let textOptions;
switch (action.type) {
case LOAD_FILTERS: {
newState = { ...state };
newState.list = deleteSelects(action.filters);
newState.active = deleteSelects(action.filters);
break;
}
case RESET_FILTERS: {
newState = { ...state };
newState.active = deleteSelects(newState.list);
break;
}
// User has de/activated a filter
case SET_FILTER: {
newState = { ...state };
// for each group,
newState.active = [].concat(newState.active).map(group => {
const newGroup = { ...group };
// determine if this is the action's filter
if (newGroup.value === action.group.value) {
// var to determine if this group has ANY selected options
let hasSelected = false;
// loop through each option..
newGroup.options = [].concat(newGroup.options).map(option => {
const newOption = { ...option };
// ..find the option that this action is targeting..
if (newOption.value === action.option.value) {
const selectStatus = action.isEnabled || false;
if (selectStatus) {
// ..and then de/select it based on the action
newOption.selected = selectStatus;
} else if (newOption.selected) {
delete newOption.selected;
}
}
// hasSelected will be true if any option is selected
hasSelected = hasSelected || newOption.selected;
return newOption;
});
// finally, we check if any options are selected,
// and update the main group accordingly
if (!hasSelected) {
// remove the 'selected' prop all together if it exists
delete newGroup.selected;
} else if (hasSelected) {
newGroup.selected = hasSelected;
}
}
return newGroup;
});
break;
}
case SET_TEXT_FILTER: {
newState = { ...state };
newState.active = [].concat(state.active);
// function which modifies an existing text group
// or creates an entirely new one, and appends the
// text search to the group options
const formatGroup = group => {
const newGroup = { ...group };
// get existing options
textOptions = [].concat(newGroup.options || []);
textOptions = [];
if (action.isEnabled) {
// we only allow for one text filter at a time,
// so just set the whole 'text' options array to just this one
textOptions = [{
value: action.option.value || action.option,
selected: true,
}];
}
// various display options
newGroup.value = 'text';
newGroup.label = 'Text Search';
newGroup.options = textOptions;
newGroup.selected = action.isEnabled || false;
return newGroup;
};
// track if we've found an existing text group
let wasFound = false;
// look through existing groups
newState.active = newState.active.map(group => {
const newGroup = { ...group };
// if a text group is found
if (newGroup.value === 'text') {
wasFound = true;
// update it
return formatGroup(newGroup);
}
return newGroup;
});
// if we do NOT have an existing text group,
// create one
if (!wasFound) {
newState.active.push(formatGroup());
}
break;
}
default: {
break;
}
}
return newState || state;
}
export default filtersReducer;

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

@ -1,21 +0,0 @@
import {
SHOW_NOTIFICATION, DISMISS_NOTIFICATION,
} from 'control_old/actions/NotificationActions';
const initialState = [];
function notificationReducer(state = initialState, action) {
switch (action.type) {
case SHOW_NOTIFICATION:
return state.concat([action.notification]);
case DISMISS_NOTIFICATION:
return state.filter(n => n.id !== action.notificationId);
default:
return state;
}
}
export default notificationReducer;

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

@ -1,214 +0,0 @@
import {
RECIPE_ADDED,
RECIPE_DELETED,
RECIPE_UPDATED,
RECIPES_RECEIVED,
RECIPES_NEED_FETCH,
REVISION_RECIPE_UPDATED,
REVISIONS_RECEIVED,
SET_SELECTED_RECIPE,
SET_SELECTED_REVISION,
SINGLE_RECIPE_RECEIVED,
SINGLE_REVISION_RECEIVED,
} from 'control_old/actions/RecipeActions';
const initialState = {
entries: {},
revisions: {},
cache: {},
selectedRecipe: null,
recipeListNeedsFetch: true,
};
function recipesReducer(state = initialState, action) {
switch (action.type) {
case RECIPES_RECEIVED: {
// convert array of recipes into an obj
// keyed on the recipe id
const recipesObj = {};
const revisionsObj = {};
(action.recipes || []).forEach(recipe => {
recipesObj[recipe.id] = { ...recipe };
revisionsObj[recipe.id] = {
...revisionsObj[recipe.id],
[recipe.revision_id]: {
approval_request: recipe.approval_request,
id: recipe.revision_id,
recipe,
},
};
});
const cacheObj = {};
if (action.cacheKey) {
cacheObj[action.cacheKey] = [].concat(action.recipes);
}
return {
...state,
entries: {
...state.entries,
...recipesObj,
},
revisions: {
...state.revisions,
...revisionsObj,
},
cache: {
...state.cache,
...cacheObj,
},
recipeListNeedsFetch: false,
};
}
case SINGLE_RECIPE_RECEIVED: {
return {
...state,
selectedRecipe: action.recipe.id,
entries: {
...state.entries,
[action.recipe.id]: action.recipe,
},
revisions: {
...state.revisions,
[action.recipe.id]: {
...state.revisions[action.recipe.id],
[action.recipe.revision_id]: {
approval_request: action.recipe.approval_request,
id: action.recipe.revision_id,
recipe: action.recipe,
},
},
},
};
}
case SINGLE_REVISION_RECEIVED: {
return {
...state,
revisions: {
...state.revisions,
[action.revision.recipe.id]: {
...state.revisions[action.revision.recipe.id],
[action.revision.id]: {
...action.revision,
},
},
},
};
}
case REVISION_RECIPE_UPDATED: {
return {
...state,
revisions: {
...state.revisions,
[action.recipe.id]: {
...state.revisions[action.recipe.id],
[action.revisionId]: {
...state.revisions[action.recipe.id][action.revisionId],
recipe: {
...action.recipe,
},
},
},
},
};
}
case RECIPES_NEED_FETCH: {
return {
...state,
selectedRecipe: null,
recipeListNeedsFetch: true,
};
}
case REVISIONS_RECEIVED: {
const newRevisions = {};
[].concat(action.revisions).forEach(rev => {
newRevisions[rev.id] = rev;
});
return {
...state,
revisions: {
...state.revisions,
[action.recipeId]: {
...state.revisions[action.recipeId],
...newRevisions,
},
},
};
}
case SET_SELECTED_RECIPE:
return {
...state,
selectedRecipe: action.recipeId,
};
case SET_SELECTED_REVISION:
return {
...state,
selectedRevision: action.revisionId,
};
case RECIPE_ADDED:
return {
...state,
entries: {
...state.entries,
[action.recipe.id]: { ...action.recipe },
},
revisions: {
...state.revisions,
[action.recipe.id]: {
...state.revisions[action.recipe.id],
[action.recipe.revision_id]: {
approval_request: null,
id: action.recipe.revision_id,
recipe: action.recipe,
},
},
},
};
case RECIPE_UPDATED: {
const newEntries = { ...state.entries };
newEntries[action.recipe.id] = { ...action.recipe };
return {
...state,
entries: newEntries,
revisions: {
...state.revisions,
[action.recipe.id]: {
...state.revisions[action.recipe.id],
[action.recipe.revision_id]: {
...action.recipe,
},
},
},
};
}
case RECIPE_DELETED: {
const newEntries = { ...state.entries };
delete newEntries[action.recipeId];
return {
...state,
entries: newEntries,
};
}
default: {
return state;
}
}
}
export default recipesReducer;

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

@ -1,18 +0,0 @@
import {
RECEIVED_USER_INFO,
} from 'control_old/actions/ControlActions';
const initialState = {};
function userReducer(state = initialState, action) {
switch (action.type) {
case RECEIVED_USER_INFO:
return { ...action.user };
default:
return state;
}
}
export default userReducer;

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

@ -1,27 +0,0 @@
import { combineReducers } from 'redux';
// routing/lib reducer imports
import { reducer as formReducer } from 'redux-form';
import { routerReducer } from 'react-router-redux';
// app reducer imports
import controlAppReducer from 'control_old/reducers/ControlAppReducer';
import filtersReducer from 'control_old/reducers/FiltersReducer';
import columnReducer from 'control_old/reducers/ColumnReducer';
import recipesReducer from 'control_old/reducers/RecipesReducer';
import notificationReducer from 'control_old/reducers/NotificationReducer';
import userReducer from 'control_old/reducers/UserReducer';
import newState from '../state';
export default combineReducers({
columns: columnReducer,
controlApp: controlAppReducer,
filters: filtersReducer,
form: formReducer,
notifications: notificationReducer,
user: userReducer,
recipes: recipesReducer,
routing: routerReducer,
newState,
});

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

@ -1,106 +0,0 @@
import React from 'react';
import { IndexRedirect, IndexRoute, Route } from 'react-router';
import ControlApp from 'control_old/components/ControlApp';
import ExtensionForm from 'control_old/components/ExtensionForm';
import ExtensionList from 'control_old/components/ExtensionList';
import RecipeList from 'control_old/components/RecipeList';
import RecipeForm from 'control_old/components/RecipeForm';
import RecipeHistory from 'control_old/components/RecipeHistory';
import DeleteRecipe from 'control_old/components/DeleteRecipe';
import EnableRecipe from 'control_old/components/EnableRecipe';
import DisableRecipe from 'control_old/components/DisableRecipe';
import NoMatch from 'control_old/components/NoMatch';
export default (
<Route path="/control-old/" component={ControlApp}>
<IndexRedirect to="recipe/" />
<Route path="recipe/" name="Recipes">
<IndexRoute
component={RecipeList}
ctaButtons={[
{ text: 'Add New', icon: 'plus', link: 'new/' },
]}
/>
<Route
path="new/"
component={RecipeForm}
name="Add New"
/>
<Route path=":id/" name="Recipe">
<IndexRoute
name="Latest"
component={RecipeForm}
ctaButtons={[
{ text: 'Clone', icon: 'files-o', link: 'clone/' },
{ text: 'History', icon: 'history', link: 'history/' },
]}
/>
<Route
path="revision/:revisionId"
component={RecipeForm}
name="Revision"
ctaButtons={[
{ text: 'Clone', icon: 'files-o', link: '../../clone/' },
{ text: 'History', icon: 'history', link: '../../history/' },
]}
/>
<Route
path="clone/"
component={RecipeForm}
name="Clone"
isCloning
ctaButtons={[
{ text: 'Cancel', icon: 'ban', link: '../' },
]}
/>
<Route
path="history/"
component={RecipeHistory}
name="History"
/>
<Route
path="delete/"
component={DeleteRecipe}
name="Delete"
/>
<Route
path="enable/"
component={EnableRecipe}
name="Enable"
/>
<Route
path="disable/"
component={DisableRecipe}
name="Disable"
/>
<Route
path=":revisionId/"
component={RecipeForm}
ctaButtons={[
{ text: 'History', icon: 'history', link: '../history/' },
]}
/>
</Route>
</Route>
<Route path="extension/" name="Extensions">
<IndexRoute
component={ExtensionList}
ctaButtons={[
{ text: 'Add New', icon: 'plus', link: 'new/' },
]}
/>
<Route
path="new/"
name="Add New"
component={ExtensionForm}
/>
<Route
path=":pk/"
name="Extension"
component={ExtensionForm}
/>
</Route>
<Route path="*" component={NoMatch} />
</Route>
);

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

@ -1,16 +0,0 @@
.checkbox-list {
input {
display: inline;
float: right;
margin-left: 0.5em;
width: auto;
}
li {
margin: 0.5em 0;
}
label {
cursor: pointer;
}
}

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

@ -1,58 +0,0 @@
.dropdown-menu {
display: block;
float: right;
position: relative;
.dropdown-trigger {
cursor: pointer;
.fa {
display: inline-block;
margin-right: 0.5em;
}
}
.dropdown-content {
background: #FFF;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.15);
-moz-box-sizing: content-box;
box-sizing: content-box;
max-height: 30vh;
max-width: none;
min-width: 150px;
overflow: auto;
padding: 1em 2em;
position: absolute;
top: 100%;
width: auto;
z-index: 25;
// pin left by default
&,
&.pin-left {
left: 0;
right: auto;
}
&.pin-right {
left: auto;
right: 0;
}
&.pin-top {
bottom: 100%;
min-width: 200px;
top: auto;
width: 30vw;
}
ul,
li {
list-style: none;
list-style-type: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}

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

@ -1,28 +0,0 @@
.group-menu {
.menu-item,
.view-more {
background-color: transparent;
cursor: pointer;
transition: 0.15s background-color ease;
&:hover,
&:focus {
background-color: rgba(0, 0, 0, 0.1);
}
}
.menu-item {
margin: 0.25em 0;
&:last-child {
margin-bottom: 1em;
}
}
.view-more {
background: rgba(0, 0, 0, 0.02);
display: block;
margin: 0.75em 0 1em;
width: 100%;
}
}

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

@ -1,59 +0,0 @@
.active-filters {
display: flex;
overflow: auto;
.filter-group,
.filter-button {
background: $darkCream;
display: inline-block;
margin-right: 1em;
min-width: 100px;
padding: 0.5em 1em;
&:hover,
&:focus {
background: $cream;
}
& > * {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-label {
font-weight: bold;
}
.filter-option {
cursor: pointer;
margin: 0.25em 0;
transition: 0.1s all ease;
&:hover,
&:focus {
background: $darkestCream;
text-decoration: line-through;
}
}
}
.filter-button {
cursor: pointer;
display: flex;
flex-direction: column;
font-weight: bold;
justify-content: center;
&:hover,
&:focus {
background: #EEE;
}
}
}
.show-count {
display: inline-block;
// lazy centering
margin-top: 1em;
}

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

@ -1,458 +0,0 @@
@import 'partials/common';
@import 'components/CheckboxList';
@import 'components/DropdownMenu';
@import 'components/GroupMenu';
@import 'components/RecipeFilters';
/* Layout */
#container { margin: 0 auto; }
#content { background: #FFF; }
#header {
color: $darkBrown;
line-height: 55px;
h1 {
display: inline-block;
font-size: 14px;
font-weight: 300;
a:link,
a:visited { color: $darkBrown; }
}
span {
float: right;
font-size: 11px;
a:link,
a:visited { font-weight: 600; }
}
}
#page-header {
background: $darkBrown;
display: flex;
position: relative;
h2 {
color: $cream;
display: block;
flex: 1;
font: normal normal 300 20px/30px $SourceSansPro;
overflow: hidden;
padding: 15px 30px;
width: 100%;
a:link,
a:visited { color: $cream; }
.fa {
font-size: 15px;
margin: 0 5px;
}
span:last-child .fa {
display: none;
}
}
.breadcrumbs {
max-width: 75%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.button {
border-left: 1px solid rgba(255, 255, 255, 0.5);
border-right: 1px solid rgba(0, 0, 0, 0.15);
height: 100%;
line-height: 40px;
min-width: 165px;
}
}
#secondary-header {
background-color: $secondaryColor;
border-top: 1px solid #FFF;
margin: 0 0 30px;
padding: 0;
.input-with-icon.search {
position: absolute;
}
#filters-container {
text-align: right;
> div {
display: inline-block;
float: right;
margin: 0 0 0 16px;
}
.dropdown-menu {
background: #CCC;
}
}
}
.header-search {
h3 {
color: $darkBrown;
display: inline-block;
font-size: 14px;
font-weight: 600;
margin: 15px;
padding: 0 15px;
}
h4 {
display: inline-block;
line-height: 35px;
margin: 0 0 0 35px;
}
input,
.switch {
background: rgba(0, 0, 0, 0.1);
border: 0;
border-radius: 3px;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px rgba(255, 255, 255, 0.1);
font: normal normal 300 13px/22px $OpenSans;
min-width: 210px;
padding: 5px;
position: relative;
&[type='text'] { padding: 7px 14px; }
}
}
/* 404 NoMatch Page */
.no-match {
padding: 30px 45px;
h2 {
margin-bottom: 15px;
}
p {
margin-bottom: 10px;
}
}
/* Add & Edit Forms */
.recipe-form {
padding: 30px 45px;
&.loading {
text-align: center;
}
.form-field {
clear: both;
margin: 0 0 1em;
}
/* Only top-level form-fields should be half-width */
& > .form-field {
position: relative;
width: 100%;
}
.error {
color: $red;
display: block;
padding-bottom: 5px;
text-transform: uppercase;
}
.field-warning {
background: $yellow;
font-weight: bold;
padding: 15px;
.fa {
margin-right: 15px;
}
}
/* A group of related fields, generally for things like radio buttons. */
.fieldset {
@extend .form-field;
border: none;
.fieldset-label {
@extend .label;
}
/* Checkbox/radio fields within fieldsets should be inline in a row. */
.checkbox-field,
.radio-field {
display: inline-flex;
margin-right: 1.5em;
}
}
/* Checkbox/radio inputs should appear to the left of the label. */
.checkbox-field,
.radio-field {
display: flex;
flex-direction: row-reverse;
justify-content: flex-end;
input[type="checkbox"],
input[type="radio"] {
flex: 0;
margin: 6px 6px 6px 0;
}
}
/* Action buttons (submit, delete, etc) */
.form-actions {
@extend %clearfix;
border-top: 1px solid $darkCream;
margin-top: 30px;
padding: 30px 15px 15px;
.button {
float: right;
margin-left: 5px;
&.mini-button {
margin: 0.5em 0 0;
padding: 0.2em 1em;
}
}
.action-delete {
float: left;
margin-left: 0;
margin-right: 5px;
}
}
}
/* Action Forms */
.arguments-fields {
background: $cream;
padding: 15px;
position: relative;
&::before {
border: solid;
border-color: $cream transparent;
border-width: 0 8px 8px;
content: "";
left: 15px;
position: absolute;
top: -8px;
}
.info {
color: $darkBrown;
margin: 0 0 1em;
width: 50%;
}
}
/* Preference Experiment Forms */
.branch-header {
margin: 15px 0;
}
.branch-list {
list-style-type: none;
.branch {
background: $darkCream;
margin: 0 0 15px;
padding: 15px;
}
.branch-fields {
display: flex;
flex-direction: row;
margin: -15px;
& > * {
flex: 2;
margin: 15px;
}
}
.remove-branch {
flex: 1;
padding-top: 26px; /* Empty label gap */
.button {
display: block;
padding-left: 10px;
padding-right: 10px;
}
}
}
/* Recipe History */
.recipe-history {
td {
text-align: left;
}
.revision-number {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* Force table cell to smallest width */
width: 91px;
}
.status-indicator {
margin: 0 20px; /* Reset .status-indicator margin */
}
.label {
padding: 0;
}
}
/* Login Page */
.login {
padding: 100px 0;
#container { width: 28em; }
#header { display: none; }
#content { padding: 20px; }
}
/* Status Indicator */
.status-indicator {
border: 1px solid;
border-radius: 3px;
display: flex;
font-size: 12px;
height: 100%;
line-height: 1em;
margin-left: 1em;
margin-top: 10px;
max-width: 150px;
min-width: 130px;
padding: 1em 0 1em 0.5em;
position: relative;
text-transform: uppercase;
width: 100%;
&.draft {
border: 1px solid rgba(0, 0, 0, 0.2);
.draft-status-icon {
opacity: 0.8;
}
}
&.pending {
background: #E8F8FA;
border-color: $cta;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.15);
}
&.approved {
background: #DCEFDC;
border-color: $green;
box-shadow: 0 2px 2px rgba($green, 0.3);
.fa {
color: #50A950;
}
}
&.rejected {
background: #FBDDDD;
border-color: $red;
box-shadow: 0 2px 2px rgba($red, 0.2);
.fa {
color: $red;
}
}
&.approved,
&.rejected {
.status-text .flavor-text {
color: inherit;
opacity: 0.7;
}
}
.draft-status-icon {
font-size: 1.9em;
line-height: 1em;
margin-left: 0.125em;
min-height: 1em;
}
.status-text {
left: 3em;
position: absolute;
top: 50%;
transform: translateY(-50%);
span {
display: block;
// apply when the last child is not the only child
&:last-child:not(:first-child) {
margin-top: 0.5em;
}
}
.flavor-text {
color: $lightGrey;
}
}
p {
line-height: 15px;
margin: 0 0 10px 20px;
text-transform: none;
}
code {
font-weight: 600;
white-space: nowrap;
}
}
/* Recipe List */
.recipe-list {
td {
overflow: hidden;
text-overflow: ellipsis;
}
.nested-list {
.nested-label {
font-weight: 800;
}
}
}
.callout.loading {
bottom: 2em;
left: 2em;
position: fixed;
right: 2em;
width: auto;
}

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

@ -1,22 +0,0 @@
$white: #FFF;
$cream: #F6F4EF;
$darkCream: #ECE7DB;
$darkestCream: #DAD1BC;
$darkBrown: #585755;
$cta: #3FC7D7;
$ctaHover: #39D7EA;
$secondaryColor: #BAC2C4;
$green: #6DBA6D;
$greenHover: #71D571;
$red: #EC5858;
$redHover: #FF5151;
$yellow: #F8F8C3;
$lightGrey: #AAA;
$grey: #777;
.red { color: $red; }
.green { color: $green; }

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

@ -1,476 +0,0 @@
@import 'fonts';
@import 'colors';
@import 'grid';
body {
background: $cream;
color: $darkBrown;
font: normal normal 400 13px/16px $OpenSans;
margin: 0 auto;
min-width: 640px;
padding: 0 40px;
}
/* Links */
a:link,
a:visited {
color: $cta;
text-decoration: none;
}
a:hover {
color: $ctaHover;
}
/* Form Elements */
label,
.label {
color: rgba($darkBrown, 0.6);
display: block;
font: normal normal 400 12px/16px $OpenSans;
text-transform: uppercase;
}
.label {
padding: 5px 0;
}
input,
select,
textarea,
.display-field {
border: 1px solid $darkCream;
border-radius: 3px;
display: block;
font: normal normal 300 15px/30px $SourceSansPro;
padding: 5px 10px;
text-transform: none;
width: 100%;
&:focus {
border-color: $darkestCream;
outline: 0;
}
}
textarea {
resize: vertical;
}
.input-with-icon {
position: relative;
&.user { content: "\f007"; }
&.key { content: "\f084"; }
&.search { content: "\f002"; }
&.calendar { content: "\f073"; }
&.clock-o { content: "\f017"; }
&.chevron-down { content: "\f078"; }
&::after {
content: inherit;
font: normal normal 400 14px/1 FontAwesome;
position: absolute;
right: 15px;
top: 30%;
}
}
.switch span {
color: $darkBrown;
cursor: pointer;
float: left;
font-size: 12px;
line-height: 26px;
padding: 0;
position: relative;
text-align: center;
text-transform: none;
width: 33%;
z-index: 2;
&.active {
font-weight: 600;
height: 26px;
text-shadow: 0 1px rgba(255, 255, 255, 0.25);
}
}
.switch-selection {
background: linear-gradient(to top, $secondaryColor, rgba(0, 0, 0, 0));
border-radius: 3px;
box-shadow: inset 0 1px rgba(255, 255, 255, 0.5), 0 0 2px rgba(0, 0, 0, 0.2);
display: block;
height: 26px;
left: 5px;
position: absolute;
top: 5px;
transition: left 0.15s ease-out;
width: 33%;
z-index: 1;
&.position-1 { left: 33%; }
&.position-2 { left: 65%; }
}
/* Call to Action Buttons & Links */
.button,
input[type="submit"],
input[type="button"],
.submit-row input,
a.button {
background: $cta;
border: 0;
border-radius: 0;
color: #FFF;
display: inline-block;
font: normal normal 600 13px/30px $OpenSans;
padding: 10px 40px;
text-align: center;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
text-transform: uppercase;
transition: 0.1s all ease;
&.delete {
background-color: $red;
&:hover {
background-color: $redHover;
}
}
&:disabled,
&:disabled:hover {
background-color: #7F9497;
color: $cream;
}
}
.button:active,
input[type="submit"]:active,
input[type="button"]:active,
.button:focus,
input[type="submit"]:focus,
input[type="button"]:focus,
.button:hover,
input[type="submit"]:hover,
input[type="button"]:hover {
background: $ctaHover;
cursor: pointer;
}
.button:disabled {
background: $grey;
cursor: not-allowed;
}
/* Tables */
table {
border-collapse: collapse;
font-family: $SourceSansPro;
table-layout: fixed;
width: 100%;
a:link,
a:visited {
color: $darkBrown;
}
}
thead {
font-family: $OpenSans;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
th {
outline: 0;
padding: 5px 0;
position: relative;
&:hover { cursor: pointer; }
span::after {
color: $secondaryColor;
content: "\f0dc";
display: inline-block;
font: normal normal 400 16px/18px FontAwesome;
margin: 0 0 0 10px;
position: absolute;
}
&.reactable-header-sort-asc {
span::after {
color: $cta;
content: "\f0de";
font-size-adjust: 0.85;
margin-top: 6px;
}
}
&.reactable-header-sort-desc {
span::after {
color: $cta;
content: "\f0dd";
font-size-adjust: 0.85;
margin-top: -5px;
}
}
}
}
tr:nth-child(even) {
background: rgba($cream, 0.5);
}
td {
border-bottom: 0;
padding: 25px;
text-align: center;
}
tbody {
tr:hover {
background: rgba(26, 183, 218, 0.1);
cursor: pointer;
}
}
/* Icons */
i {
&.pre { margin-right: 5px; }
&.post { margin-left: 5px; }
}
/* Notifications */
.notifications {
left: 40px;
position: fixed;
right: 40px;
top: 2px;
z-index: 1;
}
.notification {
$line-height: 22px;
$top-padding: 15px;
/* Notification animation variable(s).
These assume only one line of text but that's fine. */
$notificationHeight: ($top-padding * 2) + $line-height;
background-color: #FFF;
color: rgba(0, 0, 0, 0.6);
font-size: 12px;
line-height: $line-height;
margin-bottom: 2px;
overflow: hidden;
text-indent: 20px;
.message {
padding: $top-padding 30px;
&::before {
font: normal normal 400 14px/1 FontAwesome;
margin-right: 10px;
opacity: 0.75;
}
&.success {
background-color: rgba($green, 0.75);
&::before { content: "\f00c"; }
}
&.error {
background-color: rgba($red, 0.75);
&::before { content: "\f12a"; }
}
&.info {
background-color: rgba($yellow, 0.75);
&::before { content: "\f129"; }
}
}
i.remove-message {
float: right;
padding: 5px;
&:hover {
cursor: pointer;
}
}
/* Notification animation classes */
&.notification-enter {
height: 0;
}
&.notification-enter-active {
height: $notificationHeight;
transition: height 200ms ease-out;
}
&.notification-leave {
height: $notificationHeight;
}
&.notification-leave-active {
height: 0;
transition: height 200ms ease-in;
}
}
.callout {
background: rgba(19, 124, 189, 0.15);
border-radius: 4px;
display: block;
padding: 1em;
text-align: center;
width: 100%;
}
.recipe-form {
.callout {
clear: both;
float: right;
margin: 0 0 2em;
text-align: left;
width: 100%;
}
.status-indicator {
float: right;
min-width: 150px;
width: auto;
}
}
.col-trigger {
border-radius: 2px;
display: block;
padding: 1em;
}
.approval-status {
.approval-comment {
background: #EEE;
border: 1px solid #AAA;
border-radius: 2px;
margin: 1em 0;
max-width: 50%;
padding: 1em;
}
.comment-author {
display: block;
margin-top: 1em;
text-align: right;
}
}
.recipe-form .form-frame {
background: $cream;
}
.form-frame {
border: 1px solid rgba(0, 0, 0, 0.25);
margin-bottom: 2em;
padding: 0.5em 1em 1em;
.frame-title {
display: inline-block;
padding: 0 0.5em;
}
.form-frame {
border-radius: 4px;
}
// Recipe form-specific
&.channels {
width: 50%;
}
}
.multipicker {
display: flex;
flex-direction: row;
margin-bottom: 2em;
.mp-frame {
margin-bottom: 0;
width: 50%;
label,
.label,
select,
button {
text-transform: none;
}
select {
height: 12vh;
margin-bottom: 0.5em;
min-height: 110px;
}
input[type="search"],
input[type="text"] {
font-weight: 400;
}
&.mp-from {
border-radius: 4px 0 0 4px;
border-right: none;
}
&.mp-to {
border-radius: 0 4px 4px 0;
}
}
.mp-button-group {
display: flex;
flex-direction: column;
justify-content: center;
}
option {
&::after {
content: " (" attr(title) ")";
font-size: 0.75em;
font-weight: 400;
margin-left: 0.5em;
}
&.option-label::after {
display: none;
}
}
}
.form-field .checkbox-list {
label {
color: $darkBrown;
margin: 1em 0;
text-transform: none;
span {
padding-left: 1em;
}
}
input {
float: none;
}
}
pre {
overflow: auto;
}

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

@ -1,38 +0,0 @@
@font-face {
font-family: 'OpenSans';
font-weight: 300;
src: url('../fonts/OpenSans-Light.woff');
}
@font-face {
font-family: 'OpenSans';
font-weight: 400;
src: url('../fonts/OpenSans-Regular.woff');
}
@font-face {
font-family: 'OpenSans';
font-weight: 600;
src: url('../fonts/OpenSans-Bold.woff');
}
@font-face {
font-family: 'Source Sans Pro';
font-weight: 300;
src: url('../fonts/SourceSansPro-Light.woff');
}
@font-face {
font-family: 'Source Sans Pro';
font-weight: 400;
src: url('../fonts/SourceSansPro-Regular.woff');
}
@font-face {
font-family: 'Source Sans Pro';
font-weight: 600;
src: url('../fonts/SourceSansPro-Bold.woff');
}
$OpenSans: 'OpenSans', Verdana, Arial, sans-serif;
$SourceSansPro: 'Source Sans Pro', Verdana, Arial, sans-serif;

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

@ -1,53 +0,0 @@
$grid-columns: 8;
@mixin border-box {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
*,
*::after,
*::before {
@include border-box;
margin: 0;
padding: 0;
}
[class*='fluid-'],
.fluid-base {
float: left;
min-height: 1px;
padding: 1em;
width: 100%;
&.float-right {
float: right;
}
}
%clearfix {
*zoom: 1;
&::before,
&::after {
content: " ";
display: table;
}
&::after {
clear: both;
}
}
.row,
.wrapper {
@extend %clearfix;
}
@for $i from 1 through $grid-columns {
.fluid-#{$i} {
width: 100% / $grid-columns * $i;
}
}

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

@ -1,27 +0,0 @@
/**
* RecipeList column selectors
*/
/**
* Given a set of columns, returns an array of
* enabled-only/displayed columns
*
* @param {Array<Object>} columns Initial set of columns to look through
* @return {Array<Object>} Columns that are enabled/visible to user
*/
export function getActiveColumns(columns) {
return [].concat(columns)
.filter(col => col.enabled);
}
/**
* Given a set of columns, returns an array of
* disabled only (hidden) columns
*
* @param {Array<Object>} columns Initial set of columns to look through
* @return {Array<Object>} Columns that are disabled/hidden from the user
*/
export function getInactiveColumns(columns) {
return [].concat(columns)
.filter(col => !col.enabled);
}

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

@ -1,142 +0,0 @@
/**
* Filter selectors
*
* Basically a set of helper functions to apply to a store when connecting with redux.
* This simplifies in-view logic - no more .maps or .filters in mapStateToProps!
*/
/**
* Given a group, determines if any option has been selected
* somewhere within it.
*
* @param {Object} group Group to detect options in
* @return {Boolean} Does the group have a selected option?
*/
const isGroupSelected = group => {
let enabled = false;
if (group.selected) {
group.options.forEach(option => {
enabled = enabled || option.selected;
});
}
return enabled;
};
/**
* Given the current filter state, returns only those with selected options.
*
* @param {Array<Object>} groups List of groups to find selected options within
* @return {Array<Object>} Filtered groups with at least one selected option
*/
export const getSelectedFilterGroups = state => state.active.filter(isGroupSelected);
/**
* Given the current filter state, finds the selected groups,
* and then removes any NON-selected options.
*
* Essentially, returns an array of groups and their selected options.
*
* @param {Object} state Current filter state object
* @return {Array<Object>} Active filter groups and their selected options
*/
export const getActiveFilterOptions = (state = {}) =>
[].concat(state.active || [])
.map(group => {
// group has no selection = remove it
if (!group || !group.selected) {
return null;
}
const newGroup = { ...group };
// remove non-selected filters
const activeOptions = [].concat(group.options)
.filter(option => option.selected)
.map(option => ({ ...option }));
newGroup.options = activeOptions;
return newGroup;
}).filter(x => x);
/**
* Given the current filter state, finds the activated groups and options,
* and then creates a query string with all selected values.
*
* @param {Object} state Current filter state object
* @return {string} URL-safe query param string
*/
export const getFilterParamString = state =>
getActiveFilterOptions(state)
.map(group => {
const param = group.value;
const selected = [];
group.options.forEach(option => {
if (option.selected) {
selected.push(option.value);
}
});
return `${param}=${selected.map(encodeURIComponent).join(',')}`;
})
.join('&');
/**
* Given the current filter state, returns only the groups/options that
* are available for selection. Non-multiple options will be excluded
* entirely if an opposing option has already been selected.
*
* @param {Object} state Current filter state object
* @return {Array<Object>} Array of non-selected options/groups
*/
export const getAvailableFilters = state =>
[].concat(state.active)
.map(group => {
const newGroup = { ...group };
// get the non/selected options
let availableOptions = [].concat(group.options).filter(option => !option.selected);
const activeOptions = [].concat(group.options).filter(option => option.selected);
// if there is at least one option selected,
// and this group DOES NOT allow multiples,
if (activeOptions.length > 0 && !newGroup.multiple) {
// wipe the rest of the options
// (this will prevent it from appearing in menus later)
availableOptions = [];
}
newGroup.options = availableOptions;
// if there are no options left, just remove this group from the list
return newGroup.options.length === 0 ? null : newGroup;
})
// finally, filter nulls out of the array
.filter(x => x);
/**
* Determines if any filters are activated.
*
* @param {Object} state Current filter state object
* @return {boolean} Does user have at least one filter active?
*/
export const isFilteringActive = state =>
getActiveFilterOptions(state).length > 0;
/**
* Get all filters as an object keyed on their slug
*
* @param {Object} state Current filter state object
* @return {Array<Object>} List of filter objects.
*/
export const getFilterObject = state =>
state.list.reduce((optionsMap, group) => {
optionsMap[group.value] = group.options;
return optionsMap;
}, {});

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

@ -1,86 +0,0 @@
/**
* Recipes selectors
*/
import moment from 'moment';
import {
getFilterParamString,
} from 'control_old/selectors/FiltersSelector';
/**
* Utility to find pre-cached recipes based on activated filters.
*
* This is used to cache the results of filtered recipes
* returned from the server - given a set of filters,
* the func will find the list (if any) of previously-returned items.
* This allows us to display the cached recipes while the new call is made.
*
* @param {Object} recipes Recipes object from the redux store
* @param {Array<Object>} filters Filters array from the redux store
* @return {Array<Object>} List of recipes that match the provided 'filters' config
*/
export function getRecipesList(recipes, filters) {
const filterCacheKey = getFilterParamString(filters);
let foundList = filterCacheKey ? recipes.cache[filterCacheKey] : recipes.entries;
foundList = foundList || {};
return Object.keys(foundList).map(recipeId => foundList[recipeId]);
}
/**
* Given the `recipes` state, returns
* the selected recipe definition (or null, if none).
*
* @param {Object} recipes `recipes` store state tree
* @return {Object} Selected recipe object
*/
export function getSelectedRecipe(recipes) {
return recipes.entries[recipes.selectedRecipe] || null;
}
export function getLastApprovedRevision(revisions) {
return [].concat(Object.keys(revisions || {}))
// Array of revision objects
.map(id => revisions[id])
// Which have approval requests
.filter(rev => !!rev.approval_request)
// Which are confirmed approved
.filter(rev => rev.approval_request.approved === true)
.reduce((prev, current) => {
if (!prev.approval_request) {
return current;
}
const prevTime = moment().diff(prev.approval_request.created);
const currentTime = moment().diff(current.approval_request.created);
return prevTime < currentTime ? prev : current;
}, {});
}
export function getSelectedRevision({ recipes = {} }) {
let recipe = null;
let revision = null;
const selectedRecipeId = recipes.selectedRecipe;
let selectedRevisionId = recipes.selectedRevision;
const recipeRevisions = recipes.revisions[selectedRecipeId] || {};
if (selectedRecipeId) {
recipe = recipes.entries[selectedRecipeId];
if (!selectedRevisionId) {
selectedRevisionId = recipe && recipe.latest_revision_id;
}
revision = (recipeRevisions[selectedRevisionId] || {}).recipe;
}
return {
recipe,
revision,
};
}

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

@ -1,14 +0,0 @@
/* Keep this list alphabetized */
export const ACTION_RECEIVE = 'ACTION_RECEIVE';
export const APPROVAL_REQUEST_DELETE = 'APPROVAL_REQUEST_DELETE';
export const APPROVAL_REQUEST_RECEIVE = 'APPROVAL_REQUEST_RECEIVE';
export const EXTENSION_RECEIVE = 'EXTENSION_RECEIVE';
export const RECIPE_DELETE = 'RECIPE_DELETE';
export const RECIPE_RECEIVE = 'RECIPE_RECEIVE';
export const RECIPE_FILTERS_RECEIVE = 'RECIPE_FILTERS_RECEIVE';
export const RECIPE_HISTORY_RECEIVE = 'RECIPE_HISTORY_RECEIVE';
export const REQUEST_FAILURE = 'REQUEST_FAILURE';
export const REQUEST_SEND = 'REQUEST_SEND';
export const REQUEST_SUCCESS = 'REQUEST_SUCCESS';
export const REVISION_RECEIVE = 'REVISION_RECEIVE';

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

@ -1,35 +0,0 @@
import {
ACTION_RECEIVE,
} from '../action-types';
import {
makeApiRequest,
} from '../requests/actions';
export function fetchAction(pk) {
return async dispatch => {
const requestId = `fetch-action-${pk}`;
const action = await dispatch(makeApiRequest(requestId, `v2/action/${pk}/`));
dispatch({
type: ACTION_RECEIVE,
action,
});
};
}
export function fetchAllActions() {
return async dispatch => {
const requestId = 'fetch-all-actions';
const actions = await dispatch(makeApiRequest(requestId, 'v2/action/'));
actions.forEach(action => {
dispatch({
type: ACTION_RECEIVE,
action,
});
});
};
}

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

@ -1,22 +0,0 @@
import { fromJS, Map } from 'immutable';
import { combineReducers } from 'redux';
import {
ACTION_RECEIVE,
} from '../action-types';
function items(state = new Map(), action) {
switch (action.type) {
case ACTION_RECEIVE:
return state.set(action.action.id, fromJS(action.action));
default:
return state;
}
}
export default combineReducers({
items,
});

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

@ -1,5 +0,0 @@
/* eslint import/prefer-default-export: "off" */
export function getAction(state, id, defaultsTo = null) {
return state.newState.actions.items.get(id, defaultsTo);
}

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

@ -1,85 +0,0 @@
import {
APPROVAL_REQUEST_DELETE,
APPROVAL_REQUEST_RECEIVE,
} from '../action-types';
import {
makeApiRequest,
} from '../requests/actions';
export function fetchApprovalRequest(pk) {
return async dispatch => {
const requestId = `fetch-approval-request-${pk}`;
const response = dispatch(makeApiRequest(requestId, `v2/approval_request/${pk}/`));
const approvalRequest = await response;
dispatch({
type: APPROVAL_REQUEST_RECEIVE,
approvalRequest,
});
};
}
export function fetchAllApprovalRequests() {
return async dispatch => {
const requestId = 'fetch-all-approval-requests';
const approvalRequests = await dispatch(makeApiRequest(requestId, 'v2/approval_request/'));
approvalRequests.forEach(approvalRequest => {
dispatch({
type: APPROVAL_REQUEST_RECEIVE,
approvalRequest,
});
});
};
}
export function approveApprovalRequest(pk) {
return async dispatch => {
const requestId = `approve-approval-request-${pk}`;
const approvalRequest = await dispatch(
makeApiRequest(requestId, `v2/approval_request/${pk}/approve/`, {
method: 'POST',
}));
dispatch({
type: APPROVAL_REQUEST_RECEIVE,
approvalRequest,
});
};
}
export function rejectApprovalRequest(pk) {
return async dispatch => {
const requestId = `reject-approval-request-${pk}`;
const approvalRequest = await dispatch(
makeApiRequest(requestId, `v2/approval_request/${pk}/reject/`, {
method: 'POST',
}));
dispatch({
type: APPROVAL_REQUEST_RECEIVE,
approvalRequest,
});
};
}
export function closeApprovalRequest(pk) {
return async dispatch => {
const requestId = `close-approval-request-${pk}`;
await dispatch(makeApiRequest(requestId, `v2/approval_request/${pk}/close/`, {
method: 'POST',
}));
dispatch({
type: APPROVAL_REQUEST_DELETE,
approvalRequestId: pk,
});
};
}

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

@ -1,26 +0,0 @@
import { fromJS, Map } from 'immutable';
import { combineReducers } from 'redux';
import {
APPROVAL_REQUEST_DELETE,
APPROVAL_REQUEST_RECEIVE,
} from '../action-types';
function items(state = new Map(), action) {
switch (action.type) {
case APPROVAL_REQUEST_RECEIVE:
return state.set(action.approvalRequest.id, fromJS(action.approvalRequest));
case APPROVAL_REQUEST_DELETE:
return state.remove(action.approvalRequestId);
default:
return state;
}
}
export default combineReducers({
items,
});

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

@ -1,5 +0,0 @@
/* eslint import/prefer-default-export: "off" */
export function getApprovalRequest(state, id, defaultsTo = null) {
return state.newState.approvalRequests.items.get(id, defaultsTo);
}

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

@ -1,9 +0,0 @@
/* eslint import/prefer-default-export: "off" */
import { Map } from 'immutable';
export const DEFAULT_REQUEST = new Map({
inProgress: false,
error: null,
});

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

@ -1,76 +0,0 @@
import {
EXTENSION_RECEIVE,
} from '../action-types';
import {
makeApiRequest,
} from '../requests/actions';
export function fetchExtension(pk) {
return async dispatch => {
const requestId = `fetch-extension-${pk}`;
const extension = await dispatch(makeApiRequest(requestId, `v2/extension/${pk}/`));
dispatch({
type: EXTENSION_RECEIVE,
extension,
});
};
}
export function fetchAllExtensions() {
return async dispatch => {
const requestId = 'fetch-all-extensions';
const extensions = await dispatch(makeApiRequest(requestId, 'v2/extension/'));
extensions.results.forEach(extension => {
dispatch({
type: EXTENSION_RECEIVE,
extension,
});
});
};
}
function prepareExtensionFormData(extensionData) {
const data = new FormData();
Object.keys(extensionData).forEach(key => {
data.append(key, extensionData[key]);
});
return data;
}
export function createExtension(extensionData) {
return async dispatch => {
const requestId = 'create-extension';
const extension = await dispatch(makeApiRequest(requestId, 'v2/extension/', {
method: 'POST',
body: prepareExtensionFormData(extensionData),
}));
dispatch({
type: EXTENSION_RECEIVE,
extension,
});
};
}
export function updateExtension(pk, extensionData) {
return async dispatch => {
const requestId = `update-extension-${pk}`;
const extension = await dispatch(makeApiRequest(requestId, `v2/extension/${pk}/`, {
method: 'PATCH',
body: prepareExtensionFormData(extensionData),
}));
dispatch({
type: EXTENSION_RECEIVE,
extension,
});
};
}

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

@ -1,22 +0,0 @@
import { fromJS, Map } from 'immutable';
import { combineReducers } from 'redux';
import {
EXTENSION_RECEIVE,
} from '../action-types';
function items(state = new Map(), action) {
switch (action.type) {
case EXTENSION_RECEIVE:
return state.set(action.extension.id, fromJS(action.extension));
default:
return state;
}
}
export default combineReducers({
items,
});

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

@ -1,8 +0,0 @@
export function getExtension(state, id, defaultsTo = null) {
return state.newState.extensions.items.get(id, defaultsTo);
}
export function getAllExtensions(state) {
return state.newState.extensions.items;
}

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

@ -1,24 +0,0 @@
import { routerReducer as routing } from 'react-router-redux';
import { combineReducers } from 'redux';
import { reducer as form } from 'redux-form';
import actions from './actions/reducers';
import approvalRequests from './approvalRequests/reducers';
import extensions from './extensions/reducers';
import recipes from './recipes/reducers';
import requests from './requests/reducers';
import revisions from './revisions/reducers';
const reducer = combineReducers({
actions,
approvalRequests,
extensions,
form,
recipes,
requests,
revisions,
routing,
});
export default reducer;

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

@ -1,171 +0,0 @@
import {
ACTION_RECEIVE,
RECIPE_DELETE,
RECIPE_RECEIVE,
RECIPE_FILTERS_RECEIVE,
RECIPE_HISTORY_RECEIVE,
REVISION_RECEIVE,
} from '../action-types';
import {
makeApiRequest,
} from '../requests/actions';
function recipeReceived(recipe) {
return dispatch => {
dispatch({
type: RECIPE_RECEIVE,
recipe,
});
dispatch({
type: ACTION_RECEIVE,
action: recipe.action,
});
dispatch({
type: REVISION_RECEIVE,
revision: recipe.latest_revision,
});
if (recipe.approved_revision) {
dispatch({
type: REVISION_RECEIVE,
revision: recipe.approved_revision,
});
}
};
}
export function fetchRecipe(pk) {
return async dispatch => {
const requestId = `fetch-recipe-${pk}`;
const recipe = await dispatch(makeApiRequest(requestId, `v2/recipe/${pk}/`));
dispatch(recipeReceived(recipe));
};
}
export function fetchAllRecipes() {
return async dispatch => {
const requestId = 'fetch-all-recipes';
const recipes = await dispatch(makeApiRequest(requestId, 'v2/recipe/'));
recipes.forEach(recipe => {
dispatch(recipeReceived(recipe));
});
};
}
export function fetchFilteredRecipes(filters) {
return async dispatch => {
const filterIds = Object.keys(filters).map(key => `${key}-${filters[key]}`);
const requestId = `fetch-filtered-recipes-${filterIds.join('-')}`;
const recipes = await dispatch(makeApiRequest(requestId, 'v2/recipe/', {
data: filters,
}));
recipes.forEach(recipe => {
dispatch(recipeReceived(recipe));
});
};
}
export function createRecipe(recipeData) {
return async dispatch => {
const requestId = 'create-recipe';
const recipe = await dispatch(makeApiRequest(requestId, 'v2/recipe/', {
method: 'POST',
data: recipeData,
}));
dispatch(recipeReceived(recipe));
};
}
export function updateRecipe(pk, recipeData) {
return async dispatch => {
const requestId = `update-recipe-${pk}`;
const recipe = await dispatch(makeApiRequest(requestId, `v2/recipe/${pk}/`, {
method: 'PATCH',
data: recipeData,
}));
dispatch(recipeReceived(recipe));
};
}
export function deleteRecipe(pk) {
return async dispatch => {
const requestId = `delete-recipe-${pk}`;
await dispatch(makeApiRequest(requestId, `v2/recipe/${pk}/`, {
method: 'DELETE',
}));
dispatch({
type: RECIPE_DELETE,
recipeId: pk,
});
};
}
export function enableRecipe(pk) {
return async dispatch => {
const requestId = `enable-recipe-${pk}`;
const recipe = await dispatch(makeApiRequest(requestId, `v2/recipe/${pk}/enable/`, {
method: 'POST',
}));
dispatch(recipeReceived(recipe));
};
}
export function disableRecipe(pk) {
return async dispatch => {
const requestId = `enable-recipe-${pk}`;
const recipe = await dispatch(makeApiRequest(requestId, `v2/recipe/${pk}/disable/`, {
method: 'POST',
}));
dispatch(recipeReceived(recipe));
};
}
export function fetchRecipeHistory(pk) {
return async dispatch => {
const requestId = `fetch-recipe-history-${pk}`;
const revisions = await dispatch(makeApiRequest(requestId, `v2/recipe/${pk}/history/`));
dispatch({
type: RECIPE_HISTORY_RECEIVE,
recipeId: pk,
revisions,
});
revisions.forEach(revision => {
dispatch({
type: REVISION_RECEIVE,
revision,
});
});
};
}
export function fetchRecipeFilters() {
return async dispatch => {
const requestId = 'fetch-recipe-filters';
const filters = await dispatch(makeApiRequest(requestId, 'v2/filters/'));
dispatch({
type: RECIPE_FILTERS_RECEIVE,
filters,
});
};
}

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

@ -1,67 +0,0 @@
import { fromJS, Map } from 'immutable';
import { combineReducers } from 'redux';
import {
RECIPE_DELETE,
RECIPE_RECEIVE,
RECIPE_FILTERS_RECEIVE,
RECIPE_HISTORY_RECEIVE,
} from '../action-types';
function filters(state = new Map(), action) {
switch (action.type) {
case RECIPE_FILTERS_RECEIVE:
return fromJS(action.filters);
default:
return state;
}
}
function history(state = new Map(), action) {
switch (action.type) {
case RECIPE_HISTORY_RECEIVE:
return state.set(action.recipeId, fromJS(action.revisions.map(revision => revision.id)));
case RECIPE_DELETE:
return state.remove(action.recipeId);
default:
return state;
}
}
function items(state = new Map(), action) {
let recipe;
switch (action.type) {
case RECIPE_RECEIVE:
recipe = fromJS(action.recipe);
recipe = recipe
.set('action_id', recipe.getIn(['action', 'id'], null))
.set('latest_revision_id', recipe.getIn(['latest_revision', 'id'], null))
.set('approved_revision_id', recipe.getIn(['approved_revision', 'id'], null))
.remove('action')
.remove('latest_revision')
.remove('approved_revision');
return state.set(action.recipe.id, recipe);
case RECIPE_DELETE:
return state.remove(action.recipeId);
default:
return state;
}
}
export default combineReducers({
filters,
history,
items,
});

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

@ -1,36 +0,0 @@
import { List } from 'immutable';
import { getAction } from '../actions/selectors';
import { getRevision } from '../revisions/selectors';
export function getRecipe(state, id, defaultsTo = null) {
const recipe = state.newState.recipes.items.get(id);
if (recipe) {
const action = getAction(state, recipe.get('action_id'));
const latestRevision = getRevision(state, recipe.get('latest_revision_id'));
const approvedRevision = getRevision(state, recipe.get('approved_revision_id'));
return recipe
.set('action', action)
.set('latest_revision', latestRevision)
.set('approved_revision', approvedRevision)
.remove('action_id')
.remove('latest_revision_id')
.remove('approved_revision_id');
}
return defaultsTo;
}
export function getRecipeHistory(state, id) {
const history = state.newState.recipes.history.get(id, new List([]));
return history.map(revisionId => getRevision(state, revisionId));
}
export function getRecipeFilters(state) {
return state.newState.recipes.filters;
}

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

@ -1,46 +0,0 @@
/* eslint import/prefer-default-export: "off" */
import { getRequest } from './selectors';
import {
REQUEST_FAILURE,
REQUEST_SEND,
REQUEST_SUCCESS,
} from '../action-types';
import apiFetch from '../../utils/apiFetch';
export function makeApiRequest(requestId, endpoint, options = {}) {
return async (dispatch, getState) => {
const request = getRequest(getState(), requestId);
if (request.inProgress) { return true; }
dispatch({
type: REQUEST_SEND,
requestId,
});
let data;
try {
data = await apiFetch(endpoint, options);
} catch (error) {
dispatch({
type: REQUEST_FAILURE,
requestId,
error,
});
throw error;
}
dispatch({
type: REQUEST_SUCCESS,
requestId,
});
return data;
};
}

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

@ -1,33 +0,0 @@
import { fromJS, Map } from 'immutable';
import {
REQUEST_FAILURE,
REQUEST_SEND,
REQUEST_SUCCESS,
} from '../action-types';
export default function requests(state = new Map(), action) {
switch (action.type) {
case REQUEST_SEND:
return state.set(action.requestId, new Map({
inProgress: true,
error: null,
}));
case REQUEST_SUCCESS:
return state.set(action.requestId, new Map({
inProgress: false,
error: null,
}));
case REQUEST_FAILURE:
return state.set(action.requestId, new Map({
inProgress: false,
error: fromJS(action.error),
}));
default:
return state;
}
}

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

@ -1,14 +0,0 @@
/* eslint import/prefer-default-export: "off" */
import { DEFAULT_REQUEST } from '../constants';
export function getRequest(state, id, defaultsTo = DEFAULT_REQUEST) {
return state.newState.requests.get(id, defaultsTo);
}
export function isRequestInProgress(state, id) {
const request = getRequest(state, id);
return request.get('inProgress');
}

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

@ -1,59 +0,0 @@
import {
ACTION_RECEIVE,
APPROVAL_REQUEST_RECEIVE,
REVISION_RECEIVE,
} from '../action-types';
import {
makeApiRequest,
} from '../requests/actions';
function revisionReceived(revision) {
return dispatch => {
dispatch({
type: REVISION_RECEIVE,
revision,
});
dispatch({
type: ACTION_RECEIVE,
action: revision.recipe.action,
});
};
}
export function fetchRevision(pk) {
return async dispatch => {
const requestId = `fetch-revision-${pk}`;
const revision = await dispatch(makeApiRequest(requestId, `v2/recipe_revision/${pk}/`));
dispatch(revisionReceived(revision));
};
}
export function fetchAllRevisions() {
return async dispatch => {
const requestId = 'fetch-all-revisions';
const revisions = await dispatch(makeApiRequest(requestId, 'v2/recipe_revision/'));
revisions.forEach(revision => {
dispatch(revisionReceived(revision));
});
};
}
export function requestRevisionApproval(pk) {
return async dispatch => {
const requestId = `request-revision-approval-${pk}`;
const approvalRequest = await dispatch(
makeApiRequest(requestId, `v2/recipe_revision/${pk}/request_approval/`));
dispatch({
type: APPROVAL_REQUEST_RECEIVE,
approvalRequest,
});
};
}

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

@ -1,33 +0,0 @@
import { fromJS, Map } from 'immutable';
import { combineReducers } from 'redux';
import {
RECIPE_DELETE,
REVISION_RECEIVE,
} from '../action-types';
function items(state = new Map(), action) {
let revision;
switch (action.type) {
case REVISION_RECEIVE:
revision = fromJS(action.revision);
revision = revision
.setIn(['recipe', 'action_id'], revision.getIn(['recipe', 'action', 'id'], null))
.removeIn(['recipe', 'action']);
return state.set(action.revision.id, revision);
case RECIPE_DELETE:
return state.filterNot(item => item.getIn(['recipe', 'id']) === action.recipeId);
default:
return state;
}
}
export default combineReducers({
items,
});

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

@ -1,16 +0,0 @@
/* eslint import/prefer-default-export: "off" */
import { getAction } from '../actions/selectors';
export function getRevision(state, id, defaultsTo = null) {
const revision = state.newState.revisions.items.get(id);
if (revision) {
const action = getAction(state, revision.getIn(['recipe', 'action_id']));
return revision
.setIn(['recipe', 'action'], action)
.removeIn(['recipe', 'action_id']);
}
return defaultsTo;
}

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше