зеркало из https://github.com/mozilla/normandy.git
Merge pull request #1118 from mythmon/remove-old-config
Fixes #866 - Remove old control interface.
This commit is contained in:
Коммит
5975ed1fff
|
@ -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 "e;{recipe.name}"e;?</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 "e;{recipe.name}"e;?</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 "e;{recipe.name}"e;?</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'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">
|
||||
– {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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
Двоичные данные
recipe-server/client/control_old/fonts/OpenSans-Bold.woff
Двоичные данные
recipe-server/client/control_old/fonts/OpenSans-Bold.woff
Двоичный файл не отображается.
Двоичные данные
recipe-server/client/control_old/fonts/OpenSans-Light.woff
Двоичные данные
recipe-server/client/control_old/fonts/OpenSans-Light.woff
Двоичный файл не отображается.
Двоичные данные
recipe-server/client/control_old/fonts/OpenSans-Regular.woff
Двоичные данные
recipe-server/client/control_old/fonts/OpenSans-Regular.woff
Двоичный файл не отображается.
Двоичные данные
recipe-server/client/control_old/fonts/SourceSansPro-Bold.woff
Двоичные данные
recipe-server/client/control_old/fonts/SourceSansPro-Bold.woff
Двоичный файл не отображается.
Двоичные данные
recipe-server/client/control_old/fonts/SourceSansPro-Light.woff
Двоичные данные
recipe-server/client/control_old/fonts/SourceSansPro-Light.woff
Двоичный файл не отображается.
Двоичный файл не отображается.
|
@ -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;
|
||||
}
|
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Загрузка…
Ссылка в новой задаче