Skip to content
This repository has been archived by the owner on May 27, 2021. It is now read-only.

Commit

Permalink
Merge #943
Browse files Browse the repository at this point in the history
943: Add ability to import from experimenter r=jaredkerim a=rehandalal

Needs a little more work:
- [x] Authentication (jkerim to whitelist the recipe endpoint)
- [x] Show comment field and make it blocking
- [x] Error handling

r?

Co-authored-by: Rehan Dalal <rehandalal@gmail.com>
  • Loading branch information
bors[bot] and rehandalal committed Jul 31, 2019
2 parents d5144a1 + 390aae1 commit 2840c9c
Show file tree
Hide file tree
Showing 11 changed files with 189 additions and 14 deletions.
53 changes: 53 additions & 0 deletions src/components/data/QueryExperiment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { notification } from 'antd';
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';

import { fetchExperimentRecipeData } from 'console/state/recipes/actions';

@connect(
null,
{
fetchExperimentRecipeData,
},
)
class QueryActions extends React.PureComponent {
static propTypes = {
fetchExperimentRecipeData: PropTypes.func.isRequired,
slug: PropTypes.string.isRequired,
};

async componentDidMount() {
const { slug } = this.props;
try {
await this.props.fetchExperimentRecipeData(slug);
} catch (error) {
notification.error({
message: 'Import Error',
description: error.message,
duration: 0,
});
}
}

async componentDidUpdate(prevProps) {
const { slug } = this.props;
if (slug !== prevProps.slug) {
try {
await this.props.fetchExperimentRecipeData(slug);
} catch (error) {
notification.error({
message: 'Import Error',
description: error.message,
duration: 0,
});
}
}
}

render() {
return null;
}
}

export default QueryActions;
5 changes: 5 additions & 0 deletions src/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export const NORMANDY_ADMIN_API_ROOT_URL =
export const NORMANDY_READONLY_API_ROOT_URL =
process.env.REACT_APP_NORMANDY_READ_ONLY_API_ROOT_URL || null;

// Experimenter API
export const EXPERIMENTER_API_ROOT_URL =
process.env.REACT_APP_EXPERIMENTER_API_ROOT_URL ||
'https://experimenter.services.mozilla.com/api/';

// Insecure authentication
export const INSECURE_AUTH_ALLOWED = process.env.REACT_APP_INSECURE_AUTH_ALLOWED || false;

Expand Down
1 change: 1 addition & 0 deletions src/state/action-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const ACTIONS_RECEIVE = 'ACTIONS_RECEIVE';
export const APPROVAL_REQUEST_CREATE = 'APPROVAL_REQUEST_CREATE';
export const APPROVAL_REQUEST_DELETE = 'APPROVAL_REQUEST_DELETE';
export const APPROVAL_REQUEST_RECEIVE = 'APPROVAL_REQUEST_RECEIVE';
export const EXPERIMENT_RECIPE_DATA_RECEIVE = 'EXPERIMENT_RECIPE_DATA_RECEIVE';
export const EXTENSION_LISTING_COLUMNS_CHANGE = 'EXTENSION_LISTING_COLUMNS_CHANGE';
export const EXTENSION_PAGE_RECEIVE = 'EXTENSION_PAGE_RECEIVE';
export const EXTENSION_RECEIVE = 'EXTENSION_RECEIVE';
Expand Down
4 changes: 4 additions & 0 deletions src/state/actions/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export function getAction(state, id, defaultsTo = null) {
return state.getIn(['actions', 'items', intId], defaultsTo);
}

export function getActionByName(state, name, defaultsTo = null) {
return state.getIn(['actions', 'items']).find(action => action.get('name') === name);
}

export function getAllActions(state) {
return state.getIn(['actions', 'items']);
}
22 changes: 21 additions & 1 deletion src/state/recipes/actions.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import {
EXPERIMENT_RECIPE_DATA_RECEIVE,
RECIPE_DELETE,
RECIPE_LISTING_COLUMNS_CHANGE,
RECIPE_PAGE_RECEIVE,
RECIPE_RECEIVE,
RECIPE_FILTERS_RECEIVE,
RECIPE_HISTORY_RECEIVE,
} from 'console/state/action-types';
import { makeApiRequest, makeNormandyApiRequest } from 'console/state/network/actions';
import {
makeApiRequest,
makeNormandyApiRequest,
makeRequest,
} from 'console/state/network/actions';
import { revisionReceived } from 'console/state/revisions/actions';
import { EXPERIMENTER_API_ROOT_URL } from 'console/settings';

export function recipeReceived(recipe) {
return dispatch => {
Expand Down Expand Up @@ -199,3 +205,17 @@ export function saveRecipeListingColumns(columns) {
});
};
}

export function fetchExperimentRecipeData(slug) {
return async dispatch => {
const requestId = `fetch-experiment-recipe-data-${slug}`;
const data = await dispatch(
makeRequest(requestId, `${EXPERIMENTER_API_ROOT_URL}v1/experiments/${slug}/recipe/`),
);
dispatch({
type: EXPERIMENT_RECIPE_DATA_RECEIVE,
slug,
data,
});
};
}
12 changes: 12 additions & 0 deletions src/state/recipes/reducers.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { fromJS, Map } from 'immutable';
import { combineReducers } from 'redux-immutable';

import {
EXPERIMENT_RECIPE_DATA_RECEIVE,
RECIPE_DELETE,
RECIPE_LISTING_COLUMNS_CHANGE,
RECIPE_PAGE_RECEIVE,
Expand Down Expand Up @@ -87,7 +88,18 @@ function listing(state = new Map(), action) {
}
}

function experiments(state = new Map(), action) {
switch (action.type) {
case EXPERIMENT_RECIPE_DATA_RECEIVE:
return state.set(action.slug, fromJS(action.data));

default:
return state;
}
}

export default combineReducers({
experiments,
filters,
history,
items,
Expand Down
10 changes: 10 additions & 0 deletions src/state/recipes/selectors.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { List, Map } from 'immutable';

import { getActionByName } from 'console/state/actions/selectors';
import { DEFAULT_RECIPE_LISTING_COLUMNS } from 'console/state/constants';
import { getRevision } from 'console/state/revisions/selectors';

Expand Down Expand Up @@ -88,3 +89,12 @@ export function getRecipeApprovalHistory(state, id) {
const history = getRecipeHistory(state, id);
return history.filter(revision => revision.get('approval_request'));
}

export function getExperimentRecipeData(state, slug) {
const data = state.getIn(['recipes', 'experiments', slug]);

if (data) {
const action = getActionByName(state, data.get('action_name'));
return data.set('action', action).remove('action_name');
}
}
1 change: 1 addition & 0 deletions src/tests/state/recipes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AutoIncrementField, Factory, Field } from 'console/tests/factory';
import { RevisionFactory } from 'console/tests/state/revisions';

export const INITIAL_STATE = new Map({
experiments: new Map(),
filters: new Map(),
history: new Map(),
items: new Map(),
Expand Down
22 changes: 22 additions & 0 deletions src/workflows/recipes/components/RecipeForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class RecipeForm extends React.PureComponent {
form: PropTypes.object.isRequired,
isLoading: PropTypes.bool,
isCreationForm: PropTypes.bool,
isImportForm: PropTypes.bool,
onSubmit: PropTypes.func.isRequired,
revision: PropTypes.instanceOf(Map),
selectedActionName: PropTypes.string,
Expand Down Expand Up @@ -142,6 +143,26 @@ class RecipeForm extends React.PureComponent {
);
}

renderCommentField() {
const { isImportForm, isLoading, revision } = this.props;

if (!isImportForm) {
return null;
}

return (
<FormItem
name="comment"
label="Import Instructions (Clear before saving)"
required={false}
rules={[{ required: false }]}
initialValue={revision.get('comment')}
>
<Input.TextArea disabled={isLoading} rows={4} />
</FormItem>
);
}

render() {
const { filters, isCreationForm, isLoading, onSubmit, revision, errors } = this.props;

Expand Down Expand Up @@ -176,6 +197,7 @@ class RecipeForm extends React.PureComponent {
</FormItem>
</Col>
</Row>
{this.renderCommentField()}
<FilterObjectForm
form={this.props.form}
disabled={isLoading}
Expand Down
67 changes: 54 additions & 13 deletions src/workflows/recipes/pages/CreateRecipePage.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { message } from 'antd';
import { message, Spin } from 'antd';
import autobind from 'autobind-decorator';
import { Map } from 'immutable';
import { push } from 'connected-react-router';
Expand All @@ -7,16 +7,30 @@ import React from 'react';
import { connect } from 'react-redux';

import AuthenticationAlert from 'console/components/common/AuthenticationAlert';
import QueryExperiment from 'console/components/data/QueryExperiment';
import { getUserProfile } from 'console/state/auth/selectors';
import handleError from 'console/utils/handleError';
import GenericFormContainer from 'console/workflows/recipes/components/GenericFormContainer';
import RecipeForm, { cleanRecipeData } from 'console/workflows/recipes/components/RecipeForm';
import { createRecipe } from 'console/state/recipes/actions';
import { getExperimentRecipeData } from 'console/state/recipes/selectors';
import { getUrlParam } from 'console/state/router/selectors';
import { reverse } from 'console/urls';

@connect(
state => {
return { userProfile: getUserProfile(state) };
const experimentSlug = getUrlParam(state, 'experimentSlug');

let experimentRecipeData;
if (experimentSlug) {
experimentRecipeData = getExperimentRecipeData(state, experimentSlug);
}

return {
userProfile: getUserProfile(state),
experimentRecipeData,
experimentSlug,
};
},
{
createRecipe,
Expand All @@ -27,6 +41,8 @@ import { reverse } from 'console/urls';
class CreateRecipePage extends React.PureComponent {
static propTypes = {
createRecipe: PropTypes.func.isRequired,
experimentRecipeData: PropTypes.instanceOf(Map),
experimentSlug: PropTypes.string,
push: PropTypes.func.isRequired,
userProfile: PropTypes.instanceOf(Map),
};
Expand All @@ -36,7 +52,7 @@ class CreateRecipePage extends React.PureComponent {
};

onFormFailure(err) {
handleError(`Recipe cannot be created.`, err);
handleError(`Recipe cannot be created: ${err.data}`, err);
}

onFormSuccess(recipeId) {
Expand All @@ -45,12 +61,42 @@ class CreateRecipePage extends React.PureComponent {
}

async formAction(data) {
const cleanedData = cleanRecipeData(data);
const { comment, ...recipeData } = data;
const cleanedData = cleanRecipeData(recipeData);

if (comment) {
const err = new Error();
err.data = 'Empty the comment field to continue.';
throw err;
}

return this.props.createRecipe(cleanedData);
}

renderForm() {
const { experimentRecipeData, experimentSlug } = this.props;

if (experimentSlug && !experimentRecipeData) {
return <Spin />;
}

return (
<GenericFormContainer
form={RecipeForm}
formAction={this.formAction}
onSuccess={this.onFormSuccess}
onFailure={this.onFormFailure}
formProps={{
isCreationForm: true,
isImportForm: !!experimentSlug,
revision: experimentRecipeData,
}}
/>
);
}

render() {
const { userProfile } = this.props;
const { experimentSlug, userProfile } = this.props;

if (!userProfile) {
return (
Expand All @@ -64,14 +110,9 @@ class CreateRecipePage extends React.PureComponent {
}
return (
<div className="content-wrapper">
<h2>Create New Recipe</h2>
<GenericFormContainer
form={RecipeForm}
formAction={this.formAction}
onSuccess={this.onFormSuccess}
onFailure={this.onFormFailure}
formProps={{ isCreationForm: true }}
/>
{experimentSlug ? <QueryExperiment slug={experimentSlug} /> : null}
<h2>{experimentSlug ? 'Import Recipe' : 'Create New Recipe'}</h2>
{this.renderForm()}
</div>
);
}
Expand Down
6 changes: 6 additions & 0 deletions src/workflows/recipes/urls.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export default {
crumbText: 'New',
documentTitle: 'New Recipe',
},
'/import/:experimentSlug': {
name: 'recipes.new',
component: CreateRecipePage,
crumbText: 'Import',
documentTitle: 'Import Recipe',
},
'/:recipeId': {
name: 'recipes.details',
component: RecipeDetailPage,
Expand Down

0 comments on commit 2840c9c

Please sign in to comment.