@@ -54,6 +54,7 @@ class UserDefinedSelect extends React.Component
{
{field.label}
{!isOptional && ' *'}
+ {error && }
{oneof.map((choice: string, index) => {
return (
diff --git a/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx b/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx
index 37b44e6d9ea..763f477aece 100644
--- a/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx
+++ b/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx
@@ -8,13 +8,12 @@ import {
import RenderGuard from 'src/components/RenderGuard';
import TextField from 'src/components/TextField';
-type ClassNames = 'root';
+type ClassNames = 'root' | 'accessPanel';
const styles: StyleRulesCallback = theme => ({
- root: {
- margin: `${theme.spacing.unit * 3}px 0`,
- paddingBottom: theme.spacing.unit * 3,
- borderBottom: `1px solid ${theme.palette.divider}`
+ root: {},
+ accessPanel: {
+ marginTop: 0
}
});
@@ -25,13 +24,14 @@ interface Props {
udf_data: Linode.StackScript.UserDefinedField;
isOptional: boolean;
placeholder?: string;
+ error?: string;
}
type CombinedProps = Props & WithStyles;
class UserDefinedText extends React.Component {
renderTextField = () => {
- const { udf_data, field, placeholder, isOptional } = this.props;
+ const { udf_data, error, field, placeholder, isOptional } = this.props;
return (
{
label={field.label}
value={udf_data[field.name] || ''}
placeholder={placeholder}
+ errorText={error}
/>
);
};
renderPasswordField = () => {
- const { udf_data, field, placeholder, isOptional } = this.props;
+ const {
+ udf_data,
+ error,
+ field,
+ placeholder,
+ isOptional,
+ classes
+ } = this.props;
return (
{
label={field.label}
noPadding
placeholder={placeholder}
+ error={error}
+ hideStrengthLabel
+ className={!isOptional ? classes.accessPanel : ''}
/>
);
};
diff --git a/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx b/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx
index 8b13c6234a2..a921d7dbc0c 100644
--- a/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx
+++ b/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { compose } from 'recompose';
import Paper from 'src/components/core/Paper';
import {
StyleRulesCallback,
@@ -6,13 +7,18 @@ import {
WithStyles
} from 'src/components/core/styles';
import Typography from 'src/components/core/Typography';
-import Notice from 'src/components/Notice';
-import RenderGuard from 'src/components/RenderGuard';
+import Grid from 'src/components/Grid';
+import RenderGuard, { RenderGuardProps } from 'src/components/RenderGuard';
+import ShowMoreExpansion from 'src/components/ShowMoreExpansion';
import UserDefinedMultiSelect from './FieldTypes/UserDefinedMultiSelect';
import UserDefinedSelect from './FieldTypes/UserDefinedSelect';
import UserDefinedText from './FieldTypes/UserDefinedText';
-type ClassNames = 'root' | 'username';
+type ClassNames =
+ | 'root'
+ | 'username'
+ | 'advDescription'
+ | 'optionalFieldWrapper';
const styles: StyleRulesCallback = theme => ({
root: {
@@ -24,9 +30,13 @@ const styles: StyleRulesCallback = theme => ({
paddingBottom: 0
}
},
+ advDescription: {
+ margin: `${theme.spacing.unit * 2}px 0`
+ },
username: {
color: theme.color.grey1
- }
+ },
+ optionalFieldWrapper: {}
});
interface Props {
@@ -40,82 +50,136 @@ interface Props {
type CombinedProps = Props & WithStyles;
-const UserDefinedFieldsPanel: React.StatelessComponent<
- CombinedProps
-> = props => {
- const { userDefinedFields, classes, handleChange } = props;
-
- const renderField = (field: Linode.StackScript.UserDefinedField) => {
+class UserDefinedFieldsPanel extends React.PureComponent {
+ renderField = (
+ field: Linode.StackScript.UserDefinedField,
+ error?: string
+ ) => {
+ const { udf_data, handleChange } = this.props;
// if the 'default' key is returned from the API, the field is optional
const isOptional = field.hasOwnProperty('default');
if (isMultiSelect(field)) {
return (
-
+
+
+
);
}
if (isOneSelect(field)) {
return (
-
+
+ {' '}
+
);
}
if (isPasswordField(field.name)) {
return (
+
+
+
+ );
+ }
+ return (
+
- );
- }
- return (
-
+
);
};
- return (
-
- {props.errors &&
- props.errors.map(error => {
- return ;
+ render() {
+ const { userDefinedFields, classes } = this.props;
+
+ const [requiredUDFs, optionalUDFs] = seperateUDFsByRequiredStatus(
+ userDefinedFields!
+ );
+
+ return (
+
+
+ {`${this.props.selectedLabel} Options`}
+
+
+ {/* Required Fields */}
+ {requiredUDFs.map((field: Linode.StackScript.UserDefinedField) => {
+ const error = getError(field, this.props.errors);
+ return this.renderField(field, error);
})}
-
- {`${
- props.selectedUsername
- } / `}
- {`${props.selectedLabel} Options`}
-
- {userDefinedFields!.map((field: Linode.StackScript.UserDefinedField) => {
- return renderField(field);
- })}
-
- );
+
+ {/* Optional Fields */}
+ {optionalUDFs.length !== 0 && (
+
+
+ These fields are additional configuration options and are not
+ required for creation.
+
+
+
+ {optionalUDFs.map(
+ (field: Linode.StackScript.UserDefinedField) => {
+ const error = getError(field, this.props.errors);
+ return this.renderField(field, error);
+ }
+ )}
+
+
+
+ )}
+
+ );
+ }
+}
+
+const getError = (
+ field: Linode.StackScript.UserDefinedField,
+ errors?: Linode.ApiFieldError[]
+) => {
+ if (!errors) {
+ return;
+ }
+ const error = errors.find(thisError => thisError.field === field.name);
+ return error ? error.reason.replace('the UDF', '') : undefined;
};
const isPasswordField = (udfName: string) => {
@@ -130,6 +194,32 @@ const isMultiSelect = (udf: Linode.StackScript.UserDefinedField) => {
return !!udf.manyof; // if we have a manyof prop, it's a checkbox
};
+/**
+ * Used to separate required UDFs from non-required ones
+ *
+ * @return nested array [[...requiredUDFs], [...nonRequiredUDFs]]
+ */
+const seperateUDFsByRequiredStatus = (
+ udfs: Linode.StackScript.UserDefinedField[]
+) => {
+ return udfs.reduce(
+ (accum, eachUDF) => {
+ /**
+ * if the "default" key exists, it's optional
+ */
+ if (eachUDF.hasOwnProperty('default')) {
+ return [[...accum[0]], [...accum[1], eachUDF]];
+ } else {
+ return [[...accum[0], eachUDF], [...accum[1]]];
+ }
+ },
+ [[], []]
+ );
+};
+
const styled = withStyles(styles);
-export default styled(RenderGuard(UserDefinedFieldsPanel));
+export default compose(
+ RenderGuard,
+ styled
+)(UserDefinedFieldsPanel);
diff --git a/src/features/StackScripts/stackScriptUtils.ts b/src/features/StackScripts/stackScriptUtils.ts
index 3db4e48f7fa..d6581151437 100644
--- a/src/features/StackScripts/stackScriptUtils.ts
+++ b/src/features/StackScripts/stackScriptUtils.ts
@@ -1,8 +1,7 @@
-import { pathOr } from 'ramda';
import { getUsers } from 'src/services/account';
import { getStackScript, getStackscripts } from 'src/services/stackscripts';
-export const emptyResult = {
+export const emptyResult: Linode.ResourcePage = {
data: [],
page: 1,
pages: 1,
@@ -19,39 +18,48 @@ export const getStackScriptsByUser = (
username
});
-export const getAccountStackScripts = (
+export const getMineAndAccountStackScripts = (
currentUser: string,
params?: any,
filter?: any,
stackScriptGrants?: Linode.Grant[]
) => {
- /*
- Secondary users can't see other account users but they have a list of
- available account stackscripts in grant call.
- If user is restricted we get the stackscripts for the list in grants.
- Otherwise we pull all stackscripts for users on the account.
- */
+ /**
+ * Secondary users can't see other account users but they have a list of
+ * available account stackscripts in grant call.
+ * If user is restricted we get the stackscripts for the list in grants.
+ * Otherwise we pull all stackscripts for users on the account.
+ */
if (stackScriptGrants) {
- if (params.page !== 0) {
- // disable other pages loading, we got all the account stackscripts with an initial call
+ /**
+ * don't try to get another page of stackscripts because the request to /grants
+ * already gave us all stackscripts results, non-paginated
+ */
+ if (params.page !== 1) {
return Promise.resolve(emptyResult);
}
+
+ /**
+ * From the grants request, we got the entire list of StackScripts this
+ * user has access to, so we need to iterate over that list to get the
+ * meta data about each StackScript
+ */
return Promise.all(
stackScriptGrants.map(grant => getStackScript(grant.id))
- ).then(data => {
- // Filter out current user stackscripts and add to data of a sample response
+ ).then(response => {
return {
...emptyResult,
- data: data.filter(stackScript => stackScript.username !== currentUser)
+ data: response
};
});
} else {
+ /**
+ * in this case, we are unrestricted user, so instead of getting the
+ * StackScripts from the /grants meta data, need to get a list of all
+ * users on the account and make a GET /stackscripts call with the list
+ * of users as a filter
+ */
return getUsers().then(response => {
- if (response.data.length === 1) {
- // there is only one user on the account. All his stackscripts are in "My StackScripts" tab.
- return Promise.resolve(emptyResult);
- }
-
return getStackscripts(params, {
...filter,
'+and': [
@@ -62,7 +70,7 @@ export const getAccountStackScripts = (
user.username === currentUser
? acc
: [...acc, { username: user.username }],
- []
+ [{ username: currentUser }]
)
}
]
@@ -71,49 +79,25 @@ export const getAccountStackScripts = (
}
};
+/**
+ * Gets all StackScripts that don't belong to user "Linode"
+ * and do not belong to any users on the current account
+ */
export const getCommunityStackscripts = (
currentUser: string,
params?: any,
filter?: any
) =>
- getUsers()
- .catch(
- (): Promise> =>
- Promise.resolve(emptyResult)
- )
- .then(response =>
- getStackscripts(params, {
- ...filter,
- '+and': response.data.reduce(
- // pull all stackScripts except linode and account users
- (acc, user) => [...acc, { username: { '+neq': user.username } }],
- [{ username: { '+neq': 'linode' } }]
- )
- })
- );
-
-export const StackScriptTabs = [
- {
- title: 'My StackScripts',
- request: getStackScriptsByUser,
- category: 'my'
- },
- {
- title: 'Account StackScripts',
- request: getAccountStackScripts,
- category: 'account'
- },
- {
- title: 'Linode StackScripts',
- request: getStackScriptsByUser,
- category: 'linode'
- },
- {
- title: 'Community StackScripts',
- request: getCommunityStackscripts,
- category: 'community'
- }
-];
+ getUsers().then(response => {
+ return getStackscripts(params, {
+ ...filter,
+ '+and': response.data.reduce(
+ // pull all stackScripts except linode and account users
+ (acc, user) => [...acc, { username: { '+neq': user.username } }],
+ [{ username: { '+neq': 'linode' } }]
+ )
+ });
+ });
export type AcceptedFilters = 'username' | 'description' | 'label';
@@ -150,11 +134,29 @@ export const generateCatchAllFilter = (searchTerm: string) => {
};
};
-export const getErrorText = (error: any) => {
- const reason = pathOr('', ['data', 'errors', 0, 'reason'], error);
-
- if (reason === 'Unauthorized') {
- return 'You are not authorized to view StackScripts for this account.';
+export const getStackScriptUrl = (
+ username: string,
+ id: number,
+ label: string,
+ currentUser?: string
+) => {
+ let type;
+ let subtype;
+ switch (username) {
+ case 'linode':
+ // This is a Cloud App (unless it isn't, which we are unable to handle at this time)
+ type = 'One-Click';
+ subtype = 'One-Click%20Apps';
+ break;
+ case currentUser:
+ // My StackScripts
+ type = 'My%20Images';
+ subtype = 'My%20StackScripts';
+ break;
+ default:
+ // Community StackScripts
+ type = 'One-Click';
+ subtype = 'Community%20StackScripts';
}
- return 'There was an error loading your StackScripts. Please try again later.';
+ return `/linodes/create?type=${type}&stackScriptID=${id}&stackScriptUsername=${username}&stackScriptLabel=${label}&subtype=${subtype}`;
};
diff --git a/src/features/Support/SupportTickets/TicketRow.test.tsx b/src/features/Support/SupportTickets/TicketRow.test.tsx
index 68dbadc7214..670957ea244 100644
--- a/src/features/Support/SupportTickets/TicketRow.test.tsx
+++ b/src/features/Support/SupportTickets/TicketRow.test.tsx
@@ -1,5 +1,6 @@
import { shallow } from 'enzyme';
import * as React from 'react';
+
import { supportTicket } from 'src/__data__/supportTicket';
import TicketRow from './TicketRow';
diff --git a/src/features/Support/TicketAttachmentList.tsx b/src/features/Support/TicketAttachmentList.tsx
index 026466416b5..db602ebda3b 100644
--- a/src/features/Support/TicketAttachmentList.tsx
+++ b/src/features/Support/TicketAttachmentList.tsx
@@ -84,6 +84,7 @@ export const TicketAttachmentList: React.StatelessComponent<
name={
!showMoreAttachments ? 'Show More Files' : 'Show Less Files'
}
+ defaultExpanded={false}
>
void;
changePrivateIP: () => void;
- disabled: boolean;
+ disabled?: boolean;
}
-type CombinedProps = Props & RouteComponentProps<{}> & WithStyles;
+type CombinedProps = Props & WithStyles;
class AddonsPanel extends React.Component {
renderBackupsPrice = () => {
const { classes, backupsMonthly } = this.props;
@@ -184,10 +184,7 @@ class AddonsPanel extends React.Component {
}
}
-const enhanced: any = compose(
- styled,
- withRouter,
- RenderGuard
+export default compose(
+ RenderGuard,
+ styled
)(AddonsPanel);
-
-export default enhanced;
diff --git a/src/features/linodes/LinodesCreate/LinodeCreate.tsx b/src/features/linodes/LinodesCreate/LinodeCreate.tsx
new file mode 100644
index 00000000000..de9af128d5b
--- /dev/null
+++ b/src/features/linodes/LinodesCreate/LinodeCreate.tsx
@@ -0,0 +1,502 @@
+import * as React from 'react';
+import { connect, MapDispatchToProps } from 'react-redux';
+import CircleProgress from 'src/components/CircleProgress';
+import AppBar from 'src/components/core/AppBar';
+import MUITab from 'src/components/core/Tab';
+import Tabs from 'src/components/core/Tabs';
+import ErrorState from 'src/components/ErrorState';
+import Grid from 'src/components/Grid';
+import {
+ getCommunityStackscripts,
+ getMineAndAccountStackScripts
+} from 'src/features/StackScripts/stackScriptUtils';
+import { getParamsFromUrl } from 'src/utilities/queryParams';
+import SubTabs, { Tab } from './LinodeCreateSubTabs';
+import FromAppsContent from './TabbedContent/FromAppsContent';
+import FromBackupsContent from './TabbedContent/FromBackupsContent';
+import FromImageContent from './TabbedContent/FromImageContent';
+import FromLinodeContent from './TabbedContent/FromLinodeContent';
+import FromStackScriptContent from './TabbedContent/FromStackScriptContent';
+
+import {
+ CreateTypes,
+ handleChangeCreateType
+} from 'src/store/linodeCreate/linodeCreate.actions';
+
+import {
+ AllFormStateAndHandlers,
+ AppsData,
+ ReduxStatePropsAndSSHKeys,
+ WithDisplayData,
+ WithImagesProps,
+ WithLinodesProps,
+ WithRegionsProps,
+ WithTypesProps
+} from './types';
+
+interface Props {
+ history: any;
+}
+
+type CombinedProps = Props &
+ WithImagesProps &
+ WithLinodesProps &
+ WithRegionsProps &
+ WithTypesProps &
+ WithDisplayData &
+ AppsData &
+ ReduxStatePropsAndSSHKeys &
+ AllFormStateAndHandlers;
+
+interface State {
+ selectedTab: number;
+}
+
+export class LinodeCreate extends React.PureComponent<
+ CombinedProps & DispatchProps,
+ State
+> {
+ constructor(props: CombinedProps & DispatchProps) {
+ super(props);
+
+ /** get the query params as an object, excluding the "?" */
+ const queryParams = getParamsFromUrl(location.search);
+
+ /** will be -1 if the query param is not found */
+ const preSelectedTab = this.tabs.findIndex((eachTab, index) => {
+ return eachTab.title === queryParams.type;
+ });
+
+ this.state = {
+ selectedTab: preSelectedTab !== -1 ? preSelectedTab : 0
+ };
+ }
+
+ mounted: boolean = false;
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ handleTabChange = (
+ event: React.ChangeEvent,
+ value: number
+ ) => {
+ this.props.resetCreationState();
+
+ /** set the tab in redux state */
+ this.props.setTab(this.tabs[value].type);
+
+ this.props.history.push({
+ search: `?type=${event.target.textContent}`
+ });
+ this.setState({
+ selectedTab: value
+ });
+ };
+
+ tabs: Tab[] = [
+ {
+ title: 'Distributions',
+ type: 'fromImage',
+ render: () => {
+ /** ...rest being all the form state props and display data */
+ const {
+ history,
+ handleSelectUDFs,
+ selectedUDFs,
+ updateStackScript,
+ availableStackScriptImages,
+ availableUserDefinedFields,
+ selectedStackScriptID,
+ selectedDiskSize,
+ selectedStackScriptUsername,
+ selectedStackScriptLabel,
+ selectedLinodeID,
+ appInstances,
+ appInstancesError,
+ appInstancesLoading,
+ linodesData,
+ linodesError,
+ linodesLoading,
+ typesData,
+ typesError,
+ typesLoading,
+ regionsData,
+ regionsError,
+ regionsLoading,
+ imagesData,
+ imagesError,
+ imagesLoading,
+ ...rest
+ } = this.props;
+ return (
+
+ );
+ }
+ },
+ {
+ title: 'One-Click',
+ type: 'fromApp',
+ render: () => {
+ return (
+
+ );
+ }
+ },
+ {
+ title: 'My Images',
+ type: 'fromImage',
+ render: () => {
+ return (
+
+ );
+ }
+ }
+ ];
+
+ myImagesTabs = (): Tab[] => [
+ {
+ title: 'Images',
+ type: 'fromImage',
+ render: () => {
+ const {
+ history,
+ linodesData,
+ linodesError,
+ linodesLoading,
+ typesData,
+ typesError,
+ typesLoading,
+ regionsData,
+ regionsError,
+ regionsLoading,
+ imagesData,
+ imagesError,
+ imagesLoading,
+ handleSelectUDFs,
+ selectedUDFs,
+ updateStackScript,
+ availableStackScriptImages,
+ availableUserDefinedFields,
+ selectedStackScriptID,
+ selectedDiskSize,
+ selectedStackScriptUsername,
+ selectedStackScriptLabel,
+ selectedLinodeID,
+ appInstances,
+ appInstancesError,
+ appInstancesLoading,
+ ...rest
+ } = this.props;
+
+ return (
+
+ );
+ }
+ },
+ {
+ title: 'Backups',
+ type: 'fromBackup',
+ render: () => {
+ const {
+ history,
+ handleSelectUDFs,
+ selectedUDFs,
+ updateStackScript,
+ availableStackScriptImages,
+ availableUserDefinedFields,
+ selectedStackScriptID,
+ selectedStackScriptUsername,
+ selectedStackScriptLabel,
+ linodesData,
+ linodesError,
+ linodesLoading,
+ typesData,
+ typesError,
+ typesLoading,
+ regionsData,
+ regionsError,
+ regionsLoading,
+ imagesData,
+ imagesError,
+ imagesLoading,
+ ...rest
+ } = this.props;
+ return (
+
+ );
+ }
+ },
+ {
+ title: 'Clone Linode',
+ type: 'fromLinode',
+ render: () => {
+ /**
+ * rest being just the props that FromLinodeContent needs
+ * AKA CloneFormStateHandlers, WithLinodesImagesTypesAndRegions,
+ * and WithDisplayData
+ */
+ const {
+ handleSelectUDFs,
+ selectedUDFs,
+ selectedStackScriptID,
+ updateStackScript,
+ appInstances,
+ appInstancesError,
+ appInstancesLoading,
+ linodesData,
+ linodesError,
+ linodesLoading,
+ typesData,
+ typesError,
+ typesLoading,
+ regionsData,
+ regionsError,
+ regionsLoading,
+ imagesData,
+ imagesError,
+ imagesLoading,
+ ...rest
+ } = this.props;
+ return (
+
+ );
+ }
+ },
+ {
+ title: 'My StackScripts',
+ type: 'fromStackScript',
+ render: () => {
+ const {
+ accountBackupsEnabled,
+ userCannotCreateLinode,
+ selectedLinodeID,
+ updateLinodeID,
+ selectedBackupID,
+ setBackupID,
+ appInstances,
+ appInstancesError,
+ appInstancesLoading,
+ linodesData,
+ linodesError,
+ linodesLoading,
+ typesData,
+ typesError,
+ typesLoading,
+ regionsData,
+ regionsError,
+ regionsLoading,
+ imagesData,
+ imagesError,
+ imagesLoading,
+ ...rest
+ } = this.props;
+ return (
+
+ );
+ }
+ }
+ ];
+
+ oneClickTabs = (): Tab[] => [
+ {
+ title: 'One-Click Apps',
+ type: 'fromApp',
+ render: () => {
+ const {
+ setTab,
+ linodesData,
+ linodesError,
+ linodesLoading,
+ typesData,
+ typesError,
+ typesLoading,
+ regionsData,
+ regionsError,
+ regionsLoading,
+ imagesData,
+ imagesError,
+ imagesLoading,
+ ...rest
+ } = this.props;
+ return (
+
+ );
+ }
+ },
+ {
+ title: 'Community StackScripts',
+ type: 'fromStackScript',
+ render: () => {
+ const {
+ accountBackupsEnabled,
+ userCannotCreateLinode,
+ selectedLinodeID,
+ updateLinodeID,
+ selectedBackupID,
+ setBackupID,
+ linodesData,
+ linodesError,
+ linodesLoading,
+ typesData,
+ typesError,
+ typesLoading,
+ regionsData,
+ regionsError,
+ regionsLoading,
+ imagesData,
+ imagesError,
+ imagesLoading,
+ ...rest
+ } = this.props;
+ return (
+
+ );
+ }
+ }
+ ];
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ render() {
+ const { selectedTab } = this.state;
+
+ const {
+ regionsLoading,
+ imagesLoading,
+ linodesLoading,
+ imagesError,
+ regionsError,
+ linodesError,
+ typesError,
+ typesLoading
+ } = this.props;
+
+ if (regionsLoading || imagesLoading || linodesLoading || typesLoading) {
+ return ;
+ }
+
+ if (regionsError || imagesError || linodesError || typesError) {
+ return (
+
+ );
+ }
+
+ if (
+ !this.props.regionsData ||
+ !this.props.imagesData ||
+ !this.props.linodesData ||
+ !this.props.typesData
+ ) {
+ return null;
+ }
+
+ const tabRender = this.tabs[selectedTab].render;
+
+ return (
+
+
+
+
+ {this.tabs.map((tab, idx) => (
+
+ ))}
+
+
+
+ {tabRender()}
+
+ );
+ }
+}
+
+interface DispatchProps {
+ setTab: (value: CreateTypes) => void;
+}
+
+const mapDispatchToProps: MapDispatchToProps<
+ DispatchProps,
+ CombinedProps
+> = dispatch => ({
+ setTab: value => dispatch(handleChangeCreateType(value))
+});
+
+const connected = connect(
+ undefined,
+ mapDispatchToProps
+);
+
+export default connected(LinodeCreate);
diff --git a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx
new file mode 100644
index 00000000000..2f350ca98b2
--- /dev/null
+++ b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx
@@ -0,0 +1,629 @@
+import { InjectedNotistackProps, withSnackbar } from 'notistack';
+import { compose, filter, map, pathOr } from 'ramda';
+import * as React from 'react';
+import { connect } from 'react-redux';
+import { RouteComponentProps, withRouter } from 'react-router-dom';
+import { StickyContainer } from 'react-sticky';
+import { compose as recompose } from 'recompose';
+
+import regionsContainer from 'src/containers/regions.container';
+import withImages from 'src/containers/withImages.container';
+import withLinodes from 'src/containers/withLinodes.container';
+import { CreateTypes } from 'src/store/linodeCreate/linodeCreate.actions';
+import {
+ LinodeActionsProps,
+ withLinodeActions
+} from 'src/store/linodes/linode.containers';
+
+import Typography from 'src/components/core/Typography';
+import { DocumentTitleSegment } from 'src/components/DocumentTitle';
+import Grid from 'src/components/Grid';
+import { Tag } from 'src/components/TagsInput';
+
+import { dcDisplayNames } from 'src/constants';
+import withLabelGenerator, {
+ LabelProps
+} from 'src/features/linodes/LinodesCreate/withLabelGenerator';
+import { typeLabelDetails } from 'src/features/linodes/presentation';
+import userSSHKeyHoc from 'src/features/linodes/userSSHKeyHoc';
+import {
+ hasGrant,
+ isRestrictedUser
+} from 'src/features/Profile/permissionsHelpers';
+import { getParamsFromUrl } from 'src/utilities/queryParams';
+import LinodeCreate from './LinodeCreate';
+import { ExtendedType } from './SelectPlanPanel';
+
+import {
+ HandleSubmit,
+ Info,
+ ReduxStateProps,
+ ReduxStatePropsAndSSHKeys,
+ TypeInfo,
+ WithImagesProps,
+ WithLinodesProps,
+ WithRegionsProps,
+ WithTypesProps
+} from './types';
+
+import { resetEventsPolling } from 'src/events';
+import { CloudApp, getCloudApps } from 'src/services/cloud_apps';
+import { cloneLinode } from 'src/services/linodes';
+
+import { ApplicationState } from 'src/store';
+import { upsertLinode } from 'src/store/linodes/linodes.actions';
+import { MapState } from 'src/store/types';
+
+import { allocatePrivateIP } from 'src/utilities/allocateIPAddress';
+import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
+import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView';
+
+interface State {
+ selectedImageID?: string;
+ selectedRegionID?: string;
+ selectedTypeID?: string;
+ selectedLinodeID?: number;
+ selectedBackupID?: number;
+ availableUserDefinedFields?: Linode.StackScript.UserDefinedField[];
+ availableStackScriptImages?: Linode.Image[];
+ selectedStackScriptID?: number;
+ selectedStackScriptLabel?: string;
+ selectedStackScriptUsername?: string;
+ selectedDiskSize?: number;
+ label: string;
+ backupsEnabled: boolean;
+ privateIPEnabled: boolean;
+ password: string;
+ udfs?: any[];
+ tags?: Tag[];
+ errors?: Linode.ApiFieldError[];
+ formIsSubmitting: boolean;
+ appInstances?: CloudApp[];
+ appInstancesLoading: boolean;
+ appInstancesError?: string;
+}
+
+type CombinedProps = InjectedNotistackProps &
+ CreateType &
+ LinodeActionsProps &
+ WithImagesProps &
+ WithTypesProps &
+ WithLinodesProps &
+ WithRegionsProps &
+ ReduxStatePropsAndSSHKeys &
+ DispatchProps &
+ LabelProps &
+ RouteComponentProps<{}>;
+
+const defaultState: State = {
+ privateIPEnabled: false,
+ backupsEnabled: false,
+ label: '',
+ password: '',
+ selectedImageID: 'linode/debian9',
+ selectedBackupID: undefined,
+ selectedDiskSize: undefined,
+ selectedLinodeID: undefined,
+ selectedStackScriptID: undefined,
+ selectedStackScriptLabel: '',
+ selectedStackScriptUsername: '',
+ selectedRegionID: undefined,
+ selectedTypeID: undefined,
+ tags: [],
+ formIsSubmitting: false,
+ errors: undefined,
+ appInstancesLoading: false
+};
+
+const getRegionIDFromLinodeID = (
+ linodes: Linode.Linode[],
+ id: number
+): string | undefined => {
+ const thisLinode = linodes.find(linode => linode.id === id);
+ return thisLinode ? thisLinode.region : undefined;
+};
+
+class LinodeCreateContainer extends React.PureComponent {
+ state: State = defaultState;
+
+ componentDidUpdate(prevProps: CombinedProps) {
+ /**
+ * if we're clicking on the stackscript create flow, we need to stop
+ * defaulting to Debian 9 because it's possible the user chooses a stackscript
+ * that isn't compatible with the defaulted image
+ */
+ if (
+ prevProps.createType !== 'fromStackScript' &&
+ this.props.createType === 'fromStackScript'
+ ) {
+ this.setState({ selectedImageID: undefined });
+ }
+ }
+
+ componentDidMount() {
+ const params = getParamsFromUrl(this.props.location.search);
+ if (params && params !== {}) {
+ this.setState({
+ // Each of these will be undefined if not included in the URL, so this will behave correctly:
+ selectedStackScriptID: Number(params.stackScriptID),
+ selectedStackScriptLabel: params.stackScriptLabel,
+ selectedStackScriptUsername: params.stackScriptUserName,
+
+ // This set is for creating from a Backup
+ selectedBackupID: params.backupID,
+ selectedLinodeID: params.linodeID
+ });
+ }
+ this.setState({ appInstancesLoading: true });
+ getCloudApps()
+ .then(response => {
+ this.setState({
+ appInstancesLoading: false,
+ appInstances: response.data
+ });
+ })
+ .catch(e => {
+ this.setState({
+ appInstancesLoading: false,
+ appInstancesError: 'There was an error loading One-Click Apps.'
+ });
+ });
+ }
+
+ clearCreationState = () => {
+ this.props.resetSSHKeys();
+ this.setState(defaultState);
+ };
+
+ setImageID = (id: string) => {
+ /** allows for de-selecting an image */
+ if (id === this.state.selectedImageID) {
+ return this.setState({ selectedImageID: undefined });
+ }
+ return this.setState({ selectedImageID: id });
+ };
+
+ setBackupID = (id: number) => {
+ this.setState({ selectedBackupID: id });
+ };
+
+ setRegionID = (id: string) => this.setState({ selectedRegionID: id });
+
+ setTypeID = (id: string) => this.setState({ selectedTypeID: id });
+
+ setLinodeID = (id: number, diskSize?: number) => {
+ if (id !== this.state.selectedLinodeID) {
+ /**
+ * reset selected plan and set the selectedDiskSize
+ * for the purpose of disabling plans that are smaller
+ * than the clone source.
+ *
+ * Also, when creating from backup, we set the region
+ * to the same region as the Linode that owns the backup,
+ * since the API does not infer this automatically.
+ */
+
+ /**
+ * safe to ignore possibility of "undefined"
+ * null checking happens in CALinodeCreate
+ */
+ const selectedRegionID = getRegionIDFromLinodeID(
+ this.props.linodesData!,
+ id
+ );
+ this.setState({
+ selectedLinodeID: id,
+ selectedDiskSize: diskSize,
+ selectedTypeID: undefined,
+ selectedRegionID
+ });
+ }
+ };
+
+ setStackScript = (
+ id: number,
+ label: string,
+ username: string,
+ userDefinedFields: Linode.StackScript.UserDefinedField[],
+ images: Linode.Image[],
+ defaultData?: any
+ ) => {
+ /**
+ * If we're switching from one cloud app to another,
+ * usually the only compatible image will be Debian 9. If this
+ * is the case, preselect that value.
+ */
+ const defaultImage = images.length === 1 ? images[0].id : undefined;
+
+ this.setState({
+ selectedStackScriptID: id,
+ selectedStackScriptLabel: label,
+ selectedStackScriptUsername: username,
+ availableUserDefinedFields: userDefinedFields,
+ availableStackScriptImages: images,
+ udfs: defaultData,
+ /** reset image because stackscript might not be compatible with selected one */
+ selectedImageID: defaultImage,
+ errors: undefined
+ });
+ };
+
+ setDiskSize = (size: number) => this.setState({ selectedDiskSize: size });
+
+ setPassword = (password: string) => this.setState({ password });
+
+ toggleBackupsEnabled = () =>
+ this.setState({ backupsEnabled: !this.state.backupsEnabled });
+
+ togglePrivateIPEnabled = () =>
+ this.setState({ privateIPEnabled: !this.state.privateIPEnabled });
+
+ setTags = (tags: Tag[]) => this.setState({ tags });
+
+ setUDFs = (udfs: any[]) => this.setState({ udfs });
+
+ generateLabel = () => {
+ const { getLabel, imagesData, regionsData } = this.props;
+ const {
+ selectedImageID,
+ selectedRegionID,
+ selectedStackScriptLabel
+ } = this.state;
+
+ /* tslint:disable-next-line */
+ let arg1,
+ arg2,
+ arg3 = '';
+
+ /**
+ * lean in favor of using stackscript label
+ * then next priority is image label
+ */
+ if (selectedStackScriptLabel) {
+ arg1 = selectedStackScriptLabel;
+ } else if (selectedImageID) {
+ /**
+ * safe to ignore possibility of "undefined"
+ * null checking happens in CALinodeCreate
+ */
+ const selectedImage = imagesData!.find(img => img.id === selectedImageID);
+ /**
+ * Use 'vendor' if it's a public image, otherwise use label (because 'vendor' will be null)
+ *
+ * If we have no selectedImage, just use an empty string
+ */
+ arg1 = selectedImage
+ ? selectedImage.is_public
+ ? selectedImage.vendor
+ : selectedImage.label
+ : '';
+ }
+
+ if (selectedRegionID) {
+ /**
+ * safe to ignore possibility of "undefined"
+ * null checking happens in CALinodeCreate
+ */
+ const selectedRegion = regionsData!.find(
+ region => region.id === selectedRegionID
+ );
+
+ arg2 = selectedRegion ? selectedRegion.id : '';
+ }
+
+ if (this.props.createType === 'fromLinode') {
+ // @todo handle any other custom label cases we'd like to have here
+ arg3 = 'clone';
+ }
+
+ return getLabel(arg1, arg2, arg3);
+ };
+
+ submitForm: HandleSubmit = (type, payload, linodeID?: number) => {
+ const { createType } = this.props;
+ /**
+ * run a certain linode action based on the type
+ * if clone, run clone service request and upsert linode
+ * if create, run create action
+ */
+ if (createType === 'fromLinode' && !linodeID) {
+ return this.setState(
+ () => ({
+ errors: [
+ {
+ reason: 'You must select a Linode to clone from',
+ field: 'linode_id'
+ }
+ ]
+ }),
+ () => scrollErrorIntoView()
+ );
+ }
+
+ if (type === 'createFromBackup' && !this.state.selectedBackupID) {
+ /* a backup selection is also required */
+ this.setState(
+ {
+ errors: [{ field: 'backup_id', reason: 'You must select a Backup' }]
+ },
+ () => {
+ scrollErrorIntoView();
+ }
+ );
+ return;
+ }
+
+ if (type === 'createFromStackScript' && !this.state.selectedStackScriptID) {
+ return this.setState(
+ () => ({
+ errors: [
+ {
+ reason: 'You must select a StackScript to create from',
+ field: 'stackscript_id'
+ }
+ ]
+ }),
+ () => scrollErrorIntoView()
+ );
+ }
+
+ const request =
+ createType === 'fromLinode'
+ ? () => cloneLinode(linodeID!, payload)
+ : () => this.props.linodeActions.createLinode(payload);
+
+ this.setState({ formIsSubmitting: true });
+
+ return request()
+ .then((response: Linode.Linode) => {
+ this.setState({ formIsSubmitting: false });
+
+ /** if cloning a Linode, upsert Linode in redux */
+ if (createType === 'fromLinode') {
+ this.props.upsertLinode(response);
+ }
+
+ /** show toast */
+ this.props.enqueueSnackbar(
+ `Your Linode ${response.label} is being created.`,
+ {
+ variant: 'success'
+ }
+ );
+
+ /**
+ * allocate private IP if we have one
+ *
+ * @todo we need to update redux state here as well but it's not
+ * crucial now because the networking tab already makes a request to
+ * /ips on componentDidMount
+ */
+ if (payload.private_ip) {
+ allocatePrivateIP(response.id);
+ }
+
+ /** reset the Events polling */
+ resetEventsPolling();
+
+ /** send the user to the Linode detail page */
+ this.props.history.push(`/linodes/${response.id}`);
+ })
+ .catch(error => {
+ this.setState(
+ () => ({
+ errors: getAPIErrorOrDefault(error),
+ formIsSubmitting: false
+ }),
+ () => scrollErrorIntoView()
+ );
+ });
+ };
+
+ getBackupsMonthlyPrice = (): number | undefined | null => {
+ const type = this.getTypeInfo();
+
+ return !type ? undefined : type.backupsMonthly;
+ };
+
+ getTypeInfo = (): TypeInfo => {
+ const { selectedTypeID } = this.state;
+ /**
+ * safe to ignore possibility of "undefined"
+ * null checking happens in CALinodeCreate
+ */
+ const typeInfo = this.reshapeTypeInfo(
+ this.props.typesData!.find(type => type.id === selectedTypeID)
+ );
+
+ return typeInfo;
+ };
+
+ reshapeTypeInfo = (type?: ExtendedType): TypeInfo | undefined => {
+ return (
+ type && {
+ title: type.label,
+ details: `${typeLabelDetails(type.memory, type.disk, type.vcpus)}`,
+ monthly: type.price.monthly,
+ backupsMonthly: type.addons.backups.price.monthly
+ }
+ );
+ };
+
+ getRegionInfo = (): Info | undefined => {
+ const { selectedRegionID } = this.state;
+
+ if (!selectedRegionID) {
+ return;
+ }
+ /**
+ * safe to ignore possibility of "undefined"
+ * null checking happens in CALinodeCreate
+ */
+ const selectedRegion = this.props.regionsData!.find(
+ region => region.id === selectedRegionID
+ );
+
+ return (
+ selectedRegion && {
+ title: selectedRegion.country.toUpperCase(),
+ details: selectedRegion.display
+ }
+ );
+ };
+
+ getImageInfo = (): Info | undefined => {
+ const { selectedImageID } = this.state;
+
+ if (!selectedImageID) {
+ return;
+ }
+
+ /**
+ * safe to ignore possibility of "undefined"
+ * null checking happens in CALinodeCreate
+ */
+ const selectedImage = this.props.imagesData!.find(
+ image => image.id === selectedImageID
+ );
+
+ return (
+ selectedImage && {
+ title: `${selectedImage.vendor || selectedImage.label}`,
+ details: `${selectedImage.vendor ? selectedImage.label : ''}`
+ }
+ );
+ };
+
+ render() {
+ const { enqueueSnackbar, onPresentSnackbar, ...restOfProps } = this.props;
+ const { label, udfs: selectedUDFs, ...restOfState } = this.state;
+ return (
+
+
+
+
+
+ Create New Linode
+
+
+
+
+
+ );
+ }
+}
+
+interface CreateType {
+ createType: CreateTypes;
+}
+
+const mapStateToProps: MapState<
+ ReduxStateProps & CreateType,
+ CombinedProps
+> = state => ({
+ accountBackupsEnabled: pathOr(
+ false,
+ ['__resources', 'accountSettings', 'data', 'backups_enabled'],
+ state
+ ),
+ /**
+ * user cannot create Linodes if they are a restricted user
+ * and do not have the "add_linodes" grant
+ */
+ userCannotCreateLinode:
+ isRestrictedUser(state) && !hasGrant(state, 'add_linodes'),
+ createType: state.createLinode.type
+});
+
+interface DispatchProps {
+ upsertLinode: (l: Linode.Linode) => void;
+}
+
+const connected = connect(
+ mapStateToProps,
+ { upsertLinode }
+);
+
+const withTypes = connect((state: ApplicationState, ownProps) => ({
+ typesData: compose(
+ map(type => {
+ const {
+ label,
+ memory,
+ vcpus,
+ disk,
+ price: { monthly, hourly }
+ } = type;
+ return {
+ ...type,
+ heading: label,
+ subHeadings: [
+ `$${monthly}/mo ($${hourly}/hr)`,
+ typeLabelDetails(memory, disk, vcpus)
+ ]
+ };
+ }),
+ /* filter out all the deprecated types because we don't to display them */
+ filter((eachType: Linode.LinodeType) => {
+ if (!eachType.successor) {
+ return true;
+ }
+ return eachType.successor === null;
+ })
+ )(state.__resources.types.entities),
+ typesLoading: pathOr(false, ['loading'], state.__resources.types),
+ typesError: pathOr(undefined, ['error'], state.__resources.types)
+}));
+
+const withRegions = regionsContainer(({ data, loading, error }) => ({
+ regionsData: data.map(r => ({ ...r, display: dcDisplayNames[r.id] })),
+ regionsLoading: loading,
+ regionsError: error
+}));
+
+export default recompose(
+ withImages((ownProps, imagesData, imagesLoading, imagesError) => ({
+ ...ownProps,
+ imagesData,
+ imagesLoading,
+ imagesError
+ })),
+ withLinodes((ownProps, linodesData, linodesLoading, linodesError) => ({
+ ...ownProps,
+ linodesData,
+ linodesLoading,
+ linodesError
+ })),
+ withRegions,
+ withTypes,
+ withLinodeActions,
+ connected,
+ withRouter,
+ withSnackbar,
+ userSSHKeyHoc,
+ withLabelGenerator
+)(LinodeCreateContainer);
diff --git a/src/features/linodes/LinodesCreate/LinodeCreateSubTabs.tsx b/src/features/linodes/LinodesCreate/LinodeCreateSubTabs.tsx
new file mode 100644
index 00000000000..951faeba955
--- /dev/null
+++ b/src/features/linodes/LinodesCreate/LinodeCreateSubTabs.tsx
@@ -0,0 +1,147 @@
+import { parse } from 'querystring';
+import * as React from 'react';
+import AppBar from 'src/components/core/AppBar';
+import Paper from 'src/components/core/Paper';
+import {
+ StyleRulesCallback,
+ withStyles,
+ WithStyles
+} from 'src/components/core/styles';
+import MUITab from 'src/components/core/Tab';
+import Tabs from 'src/components/core/Tabs';
+import Typography from 'src/components/core/Typography';
+import Grid from 'src/components/Grid';
+import { CreateTypes } from 'src/store/linodeCreate/linodeCreate.actions';
+
+type ClassNames = 'root' | 'inner';
+
+const styles: StyleRulesCallback = theme => ({
+ root: {
+ flexGrow: 1,
+ width: '100%',
+ backgroundColor: theme.color.white
+ },
+ inner: {
+ padding: `${theme.spacing.unit * 2}px ${theme.spacing.unit * 2}px 0 ${theme
+ .spacing.unit * 2}px`,
+ [theme.breakpoints.up('sm')]: {
+ padding: `${theme.spacing.unit * 3}px ${theme.spacing.unit *
+ 3}px 0 ${theme.spacing.unit * 3}px`
+ }
+ }
+});
+
+export interface Tab {
+ title: string;
+ render: () => JSX.Element;
+ type: CreateTypes;
+}
+
+interface Props {
+ history: any;
+ reset: () => void;
+ tabs: Tab[];
+ handleClick: (value: CreateTypes) => void;
+}
+
+interface State {
+ selectedTab: number;
+}
+
+type CombinedProps = Props & WithStyles;
+
+export const determinePreselectedTab = (tabsToRender: Tab[]): number => {
+ /** get the query params as an object, excluding the "?" */
+ const queryParams = parse(location.search.replace('?', ''));
+
+ /** will be -1 if the query param is not found */
+ const preSelectedTab = tabsToRender.findIndex((eachTab, index) => {
+ return eachTab.title === queryParams.subtype;
+ });
+
+ return preSelectedTab !== -1 ? preSelectedTab : 0;
+};
+
+class CALinodeCreateSubTabs extends React.Component {
+ constructor(props: CombinedProps) {
+ super(props);
+
+ this.state = {
+ selectedTab: determinePreselectedTab(props.tabs)
+ };
+ }
+
+ handleTabChange = (
+ event: React.ChangeEvent,
+ value: number
+ ) => {
+ /** Reset the top-level creation flow state */
+ this.props.reset();
+ /** get the query params as an object, excluding the "?" */
+ const queryParams = parse(location.search.replace('?', ''));
+
+ /** set the type in redux state */
+ this.props.handleClick(this.props.tabs[value].type);
+
+ this.props.history.push({
+ search: `?type=${queryParams.type}&subtype=${event.target.textContent}`
+ });
+ this.setState({
+ selectedTab: value
+ });
+ };
+
+ render() {
+ const { tabs, classes } = this.props;
+ const { selectedTab: selectedTabFromState } = this.state;
+
+ const queryParams = parse(location.search.replace('?', ''));
+
+ /**
+ * doing this check here to reset the sub-tab if the
+ * query string doesn't exist to solve the issue where the user
+ * clicks on tab 2, subtab 3 - THEN clicks on tab 1 which only has 2 subtabs.
+ *
+ * In this case, tab 1 has only 2 subtabs so, we need to reset the selected sub-tab
+ * or else we get an error
+ */
+ const selectedTab = !queryParams.subtype ? 0 : selectedTabFromState;
+
+ const selectedTabContentRender = tabs[selectedTab].render;
+
+ return (
+
+
+
+
+
+ Create From:
+
+
+
+ {tabs.map((tab, idx) => (
+
+ ))}
+
+
+
+
+
+ {selectedTabContentRender()}
+
+ );
+ }
+}
+
+export default withStyles(styles)(CALinodeCreateSubTabs);
diff --git a/src/features/linodes/LinodesCreate/LinodesCreate.test.tsx b/src/features/linodes/LinodesCreate/LinodesCreate.test.tsx
deleted file mode 100644
index db95abf0fcd..00000000000
--- a/src/features/linodes/LinodesCreate/LinodesCreate.test.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { shallow } from 'enzyme';
-import * as React from 'react';
-
-import { ExtendedType, images, LinodesWithBackups } from 'src/__data__/index';
-import { reactRouterProps } from 'src/__data__/reactRouterProps';
-
-import { LinodeCreate } from './LinodesCreate';
-
-const dummyProps = {
- types: ExtendedType,
- regions: [],
- images: {
- response: images
- },
- linodes: {
- response: LinodesWithBackups
- }
-};
-
-describe('FromImageContent', () => {
- const component = shallow(
-
- );
-
- it('should render create tabs', () => {
- expect(component.find('WithStyles(Tab)')).toHaveLength(4);
- });
-});
diff --git a/src/features/linodes/LinodesCreate/LinodesCreate.tsx b/src/features/linodes/LinodesCreate/LinodesCreate.tsx
deleted file mode 100644
index 4104885dfc1..00000000000
--- a/src/features/linodes/LinodesCreate/LinodesCreate.tsx
+++ /dev/null
@@ -1,534 +0,0 @@
-import {
- compose,
- filter,
- find,
- lensPath,
- map,
- pathOr,
- prop,
- propEq,
- set
-} from 'ramda';
-import * as React from 'react';
-import { connect } from 'react-redux';
-import { RouteComponentProps, withRouter } from 'react-router-dom';
-import { StickyContainer } from 'react-sticky';
-import { compose as composeComponent } from 'recompose';
-import CircleProgress from 'src/components/CircleProgress';
-import AppBar from 'src/components/core/AppBar';
-import {
- StyleRulesCallback,
- withStyles,
- WithStyles
-} from 'src/components/core/styles';
-import Tab from 'src/components/core/Tab';
-import Tabs from 'src/components/core/Tabs';
-import Typography from 'src/components/core/Typography';
-import { DocumentTitleSegment } from 'src/components/DocumentTitle';
-import Grid from 'src/components/Grid';
-import { ExtendedRegion } from 'src/components/SelectRegionPanel';
-import { dcDisplayNames } from 'src/constants';
-import regionsContainer from 'src/containers/regions.container';
-import withImages from 'src/containers/withImages.container';
-import withLinodes from 'src/containers/withLinodes.container';
-import {
- displayType,
- typeLabelDetails
-} from 'src/features/linodes/presentation';
-import {
- hasGrant,
- isRestrictedUser
-} from 'src/features/Profile/permissionsHelpers';
-import { ApplicationState } from 'src/store';
-import { MapState } from 'src/store/types';
-import { parseQueryParams } from 'src/utilities/queryParams';
-import { ExtendedLinode } from './SelectLinodePanel';
-import { ExtendedType } from './SelectPlanPanel';
-import FromBackupsContent from './TabbedContent/FromBackupsContent';
-import FromImageContent from './TabbedContent/FromImageContent';
-import FromLinodeContent from './TabbedContent/FromLinodeContent';
-import FromStackScriptContent from './TabbedContent/FromStackScriptContent';
-import { Info } from './util';
-
-export type TypeInfo =
- | {
- title: string;
- details: string;
- monthly: number;
- backupsMonthly: number | null;
- }
- | undefined;
-
-type ClassNames = 'root' | 'main';
-
-const styles: StyleRulesCallback = theme => ({
- root: {},
- main: {}
-});
-
-type CombinedProps = WithImagesProps &
- WithLinodesProps &
- WithTypesProps &
- WithRegions &
- WithStyles &
- StateProps &
- RouteComponentProps<{}>;
-
-interface State {
- selectedTab: number;
- selectedLinodeIDFromQueryString: number | undefined;
- selectedBackupIDFromQueryString: number | undefined;
- selectedStackScriptIDFromQueryString: number | undefined;
- selectedStackScriptTabFromQueryString: string | undefined;
- selectedRegionIDFromLinode: string | undefined;
-}
-
-interface QueryStringOptions {
- type: string;
- backupID: string;
- linodeID: string;
- stackScriptID: string;
- stackScriptUsername: string;
-}
-
-const formatLinodeSubheading = (typeInfo: string, imageInfo: string) => {
- const subheading = imageInfo ? `${typeInfo}, ${imageInfo}` : `${typeInfo}`;
- return [subheading];
-};
-
-export class LinodeCreate extends React.Component {
- state: State = {
- selectedTab: pathOr(
- 0,
- ['history', 'location', 'state', 'selectedTab'],
- this.props
- ),
- selectedLinodeIDFromQueryString: undefined,
- selectedBackupIDFromQueryString: undefined,
- selectedStackScriptIDFromQueryString: undefined,
- selectedStackScriptTabFromQueryString: undefined,
- selectedRegionIDFromLinode: undefined
- };
-
- mounted: boolean = false;
-
- componentDidMount() {
- this.mounted = true;
-
- this.updateStateFromQuerystring();
- }
-
- componentDidUpdate(prevProps: CombinedProps) {
- const prevSearch = prevProps.location.search;
- const {
- location: { search: nextSearch }
- } = this.props;
- if (prevSearch !== nextSearch) {
- this.updateStateFromQuerystring();
- }
- }
-
- updateStateFromQuerystring() {
- const {
- location: { search }
- } = this.props;
- const options: QueryStringOptions = parseQueryParams(
- search.replace('?', '')
- ) as QueryStringOptions;
- if (options.type === 'fromBackup') {
- this.setState({ selectedTab: this.backupTabIndex });
- } else if (options.type === 'fromStackScript') {
- this.setState({ selectedTab: this.stackScriptTabIndex });
- }
-
- if (options.stackScriptUsername) {
- this.setState({
- selectedStackScriptTabFromQueryString: options.stackScriptUsername
- });
- }
-
- if (options.stackScriptID) {
- this.setState({
- selectedStackScriptIDFromQueryString:
- +options.stackScriptID || undefined
- });
- }
-
- if (options.linodeID) {
- this.setSelectedRegionByLinodeID(Number(options.linodeID));
- this.setState({
- selectedLinodeIDFromQueryString: Number(options.linodeID) || undefined
- });
- }
-
- if (options.backupID) {
- this.setState({
- selectedBackupIDFromQueryString: Number(options.backupID) || undefined
- });
- }
- }
-
- setSelectedRegionByLinodeID(linodeID: number): void {
- const selectedLinode = filter(
- (linode: Linode.LinodeWithBackups) => linode.id === linodeID,
- this.props.linodesData
- );
- if (selectedLinode.length > 0) {
- this.setState({ selectedRegionIDFromLinode: selectedLinode[0].region });
- }
- }
-
- handleTabChange = (
- event: React.ChangeEvent,
- value: number
- ) => {
- this.setState({
- selectedTab: value
- });
- };
-
- getBackupsMonthlyPrice = (selectedTypeID: string | null): number | null => {
- if (!selectedTypeID || !this.props.typesData) {
- return null;
- }
- const type = this.getTypeInfo(selectedTypeID);
- if (!type) {
- return null;
- }
- return type.backupsMonthly;
- };
-
- extendLinodes = (linodes: Linode.Linode[]): ExtendedLinode[] => {
- const images = this.props.imagesData || [];
- const types = this.props.typesData || [];
- return linodes.map(
- linode =>
- compose<
- Linode.Linode,
- Partial,
- Partial
- >(
- set(lensPath(['heading']), linode.label),
- set(
- lensPath(['subHeadings']),
- formatLinodeSubheading(
- displayType(linode.type, types),
- compose(
- prop('label'),
- find(propEq('id', linode.image))
- )(images)
- )
- )
- )(linode) as ExtendedLinode
- );
- };
-
- tabs = [
- {
- title: 'Create from Image',
- render: () => {
- return (
-
- );
- }
- },
- {
- title: 'Create from Backup',
- render: () => {
- return (
-
- );
- }
- },
- {
- title: 'Clone from Existing',
- render: () => {
- return (
-
- );
- }
- },
- {
- title: 'Create from StackScript',
- render: () => {
- return (
-
- );
- }
- }
- ];
-
- imageTabIndex = this.tabs.findIndex(tab =>
- tab.title.toLowerCase().includes('image')
- );
- backupTabIndex = this.tabs.findIndex(tab =>
- tab.title.toLowerCase().includes('backup')
- );
- cloneTabIndex = this.tabs.findIndex(tab =>
- tab.title.toLowerCase().includes('clone')
- );
- stackScriptTabIndex = this.tabs.findIndex(tab =>
- tab.title.toLowerCase().includes('stackscript')
- );
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- getImageInfo = (image: Linode.Image | undefined): Info => {
- return (
- image && {
- title: `${image.vendor || image.label}`,
- details: `${image.vendor ? image.label : ''}`
- }
- );
- };
-
- getTypeInfo = (selectedTypeID: string | null): TypeInfo => {
- const typeInfo = this.reshapeTypeInfo(
- this.props.typesData.find(type => type.id === selectedTypeID)
- );
-
- return typeInfo;
- };
-
- reshapeTypeInfo = (type: ExtendedType | undefined): TypeInfo => {
- return (
- type && {
- title: type.label,
- details: `${typeLabelDetails(type.memory, type.disk, type.vcpus)}`,
- monthly: type.price.monthly,
- backupsMonthly: type.addons.backups.price.monthly
- }
- );
- };
-
- getRegionInfo = (selectedRegionID?: string | null): Info => {
- const selectedRegion = this.props.regionsData.find(
- region => region.id === selectedRegionID
- );
-
- return (
- selectedRegion && {
- title: selectedRegion.country.toUpperCase(),
- details: selectedRegion.display
- }
- );
- };
-
- handleDisablePasswordField = (imageSelected: boolean) => {
- if (!imageSelected) {
- return {
- disabled: true,
- reason: 'You must first select an image to enter a root password'
- };
- }
- return;
- };
-
- render() {
- const { selectedTab } = this.state;
-
- const { classes, regionsLoading, imagesLoading } = this.props;
-
- if (regionsLoading || imagesLoading) {
- return ;
- }
-
- const tabRender = this.tabs[selectedTab].render;
-
- return (
-
-
-
-
-
- Create New Linode
-
-
-
- {this.tabs.map((tab, idx) => (
-
- ))}
-
-
-
- {tabRender()}
-
-
- );
- }
-}
-
-interface WithTypesProps {
- typesData: ExtendedType[];
-}
-
-const withTypes = connect((state: ApplicationState, ownProps) => ({
- typesData: compose(
- map(type => {
- const {
- label,
- memory,
- vcpus,
- disk,
- price: { monthly, hourly }
- } = type;
- return {
- ...type,
- heading: label,
- subHeadings: [
- `$${monthly}/mo ($${hourly}/hr)`,
- typeLabelDetails(memory, disk, vcpus)
- ]
- };
- }),
- /* filter out all the deprecated types because we don't to display them */
- filter((eachType: Linode.LinodeType) => {
- if (!eachType.successor) {
- return true;
- }
- return eachType.successor === null;
- })
- )(state.__resources.types.entities)
-}));
-
-interface StateProps {
- accountBackups: boolean;
- disabled: boolean;
-}
-
-const mapStateToProps: MapState = state => ({
- accountBackups: pathOr(
- false,
- ['__resources', 'accountSettings', 'data', 'backups_enabled'],
- state
- ),
- // disabled if the profile is restricted and doesn't have add_linodes grant
- disabled: isRestrictedUser(state) && !hasGrant(state, 'add_linodes')
-});
-
-const connected = connect(mapStateToProps);
-
-const styled = withStyles(styles);
-
-interface WithImagesProps {
- imagesData: Linode.Image[];
- imagesLoading: boolean;
- imagesError?: string;
-}
-
-interface WithLinodesProps {
- linodesData: Linode.Linode[];
- linodesLoading: boolean;
- linodesError?: Linode.ApiFieldError[];
-}
-
-interface WithRegions {
- regionsData: ExtendedRegion[];
- regionsLoading: boolean;
- regionsError: Linode.ApiFieldError[];
-}
-
-const withRegions = regionsContainer(({ data, loading, error }) => ({
- regionsData: data
- .filter(region => region.id !== 'ap-northeast-1a')
- .map(r => ({ ...r, display: dcDisplayNames[r.id] })),
- regionsLoading: loading,
- regionsError: error
-}));
-
-export default composeComponent(
- withImages((ownProps, imagesData, imagesLoading, imagesError) => ({
- ...ownProps,
- imagesData,
- imagesLoading,
- imagesError
- })),
- withLinodes((ownProps, linodesData, linodesLoading, linodesError) => ({
- ...ownProps,
- linodesData,
- linodesLoading,
- linodesError
- })),
- withRegions,
- withTypes,
- styled,
- withRouter,
- connected
-)(LinodeCreate);
diff --git a/src/features/linodes/LinodesCreate/Panel.tsx b/src/features/linodes/LinodesCreate/Panel.tsx
new file mode 100644
index 00000000000..d2d551afd71
--- /dev/null
+++ b/src/features/linodes/LinodesCreate/Panel.tsx
@@ -0,0 +1,48 @@
+import * as React from 'react';
+
+import {
+ StyleRulesCallback,
+ withStyles,
+ WithStyles
+} from 'src/components/core/styles';
+
+import Paper from 'src/components/core/Paper';
+import Typography from 'src/components/core/Typography';
+import Notice from 'src/components/Notice';
+
+type ClassNames = 'root' | 'flatImagePanel';
+
+const styles: StyleRulesCallback = theme => ({
+ flatImagePanel: {
+ padding: theme.spacing.unit * 3
+ },
+ root: {}
+});
+
+interface Props {
+ error?: string;
+ title?: string;
+ className?: string;
+}
+
+type CombinedProps = Props & WithStyles;
+
+const Panel: React.StatelessComponent = props => {
+ const { classes, children, error, title } = props;
+ return (
+
+ {error && }
+
+ {title || 'Select an Image'}
+
+ {children}
+
+ );
+};
+
+const styled = withStyles(styles);
+
+export default styled(Panel);
diff --git a/src/features/linodes/LinodesCreate/PrivateImages.tsx b/src/features/linodes/LinodesCreate/PrivateImages.tsx
new file mode 100644
index 00000000000..1ba1de7edf4
--- /dev/null
+++ b/src/features/linodes/LinodesCreate/PrivateImages.tsx
@@ -0,0 +1,51 @@
+import * as React from 'react';
+
+import {
+ StyleRulesCallback,
+ withStyles,
+ WithStyles
+} from 'src/components/core/styles';
+import Grid from 'src/components/Grid';
+import SelectionCard from 'src/components/SelectionCard';
+
+type ClassNames = 'root' | 'flatImagePanelSelections';
+
+const styles: StyleRulesCallback = theme => ({
+ flatImagePanelSelections: {
+ marginTop: theme.spacing.unit * 2,
+ padding: `${theme.spacing.unit}px 0`
+ },
+ root: {}
+});
+interface Props {
+ images: Linode.Image[];
+ disabled?: boolean;
+ selectedImageID?: string;
+ handleSelection: (id: string) => void;
+}
+
+type CombinedProps = Props & WithStyles;
+
+const PrivateImages: React.StatelessComponent = (props) => {
+ const { classes, disabled, handleSelection, images, selectedImageID } = props;
+ return (
+
+ {images &&
+ images.map((image: Linode.Image, idx: number) => (
+ handleSelection(image.id)}
+ renderIcon={() => }
+ heading={image.label as string}
+ subheadings={[image.description as string]}
+ disabled={disabled}
+ />
+ ))}
+
+ )
+}
+
+const styled = withStyles(styles);
+
+export default styled(PrivateImages);
\ No newline at end of file
diff --git a/src/features/linodes/LinodesCreate/PublicImages.tsx b/src/features/linodes/LinodesCreate/PublicImages.tsx
new file mode 100644
index 00000000000..9e23953760c
--- /dev/null
+++ b/src/features/linodes/LinodesCreate/PublicImages.tsx
@@ -0,0 +1,90 @@
+import * as React from 'react';
+
+import {
+ StyleRulesCallback,
+ withStyles,
+ WithStyles
+} from 'src/components/core/styles';
+import Grid from 'src/components/Grid';
+import SelectionCard from 'src/components/SelectionCard';
+import ShowMoreExpansion from 'src/components/ShowMoreExpansion';
+
+type ClassNames = 'root' | 'flatImagePanelSelections';
+
+const styles: StyleRulesCallback = theme => ({
+ flatImagePanelSelections: {
+ marginTop: theme.spacing.unit * 2,
+ padding: `${theme.spacing.unit}px 0`
+ },
+ root: {}
+});
+interface Props {
+ images: Linode.Image[];
+ oldImages: Linode.Image[];
+ selectedImageID?: string;
+ disabled?: boolean;
+ handleSelection: (id: string) => void;
+}
+
+type CombinedProps = Props & WithStyles;
+
+const distroIcons = {
+ Alpine: 'alpine',
+ Arch: 'archlinux',
+ CentOS: 'centos',
+ CoreOS: 'coreos',
+ Debian: 'debian',
+ Fedora: 'fedora',
+ Gentoo: 'gentoo',
+ openSUSE: 'opensuse',
+ Slackware: 'slackware',
+ Ubuntu: 'ubuntu'
+};
+
+const PublicImages: React.StatelessComponent = props => {
+ const {
+ classes,
+ disabled,
+ images,
+ handleSelection,
+ oldImages,
+ selectedImageID
+ } = props;
+ const renderImages = (imageList: Linode.Image[]) =>
+ imageList.length &&
+ imageList.map((image: Linode.Image, idx: number) => (
+ handleSelection(image.id)}
+ renderIcon={() => {
+ return (
+
+ );
+ }}
+ heading={image.vendor as string}
+ subheadings={[image.label]}
+ data-qa-selection-card
+ disabled={disabled}
+ />
+ ));
+
+ return (
+ <>
+
+ {renderImages(images)}
+
+ {oldImages.length > 0 && (
+
+
+ {renderImages(oldImages)}
+
+
+ )}
+ >
+ );
+};
+
+const styled = withStyles(styles);
+
+export default styled(PublicImages);
diff --git a/src/features/linodes/LinodesCreate/SelectAppPanel.tsx b/src/features/linodes/LinodesCreate/SelectAppPanel.tsx
new file mode 100644
index 00000000000..c1b6c38e572
--- /dev/null
+++ b/src/features/linodes/LinodesCreate/SelectAppPanel.tsx
@@ -0,0 +1,162 @@
+import {
+ StyleRulesCallback,
+ withStyles,
+ WithStyles
+} from '@material-ui/core/styles';
+import * as React from 'react';
+import { compose } from 'recompose';
+
+import ErrorState from 'src/components/ErrorState';
+import Grid from 'src/components/Grid';
+import LinearProgress from 'src/components/LinearProgress';
+import SelectionCard from 'src/components/SelectionCard';
+import { APP_ROOT } from 'src/constants';
+import Panel from './Panel';
+import { AppsData } from './types';
+
+type ClassNames = 'flatImagePanelSelections' | 'panel' | 'loading';
+
+const styles: StyleRulesCallback = theme => ({
+ flatImagePanelSelections: {
+ marginTop: theme.spacing.unit * 2,
+ padding: `${theme.spacing.unit}px 0`
+ },
+ panel: {
+ marginBottom: theme.spacing.unit * 3
+ },
+ loading: {
+ marginTop: theme.spacing.unit * 2,
+ marginBottom: theme.spacing.unit * 2
+ }
+});
+
+interface Props extends AppsData {
+ handleClick: (
+ id: number,
+ label: string,
+ username: string,
+ stackScriptImages: string[],
+ userDefinedFields: Linode.StackScript.UserDefinedField[]
+ ) => void;
+ disabled: boolean;
+ selectedStackScriptID?: number;
+ error?: string;
+}
+
+type CombinedProps = Props & WithStyles;
+
+const SelectAppPanel: React.SFC = props => {
+ const {
+ disabled,
+ selectedStackScriptID,
+ classes,
+ error,
+ appInstances,
+ appInstancesError,
+ appInstancesLoading,
+ handleClick
+ } = props;
+
+ if (appInstancesError) {
+ return (
+
+
+
+ );
+ }
+
+ if (appInstancesLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!appInstances) {
+ return null;
+ }
+
+ /** hacky and bad */
+ const interceptedError = error ? 'You must select an App to create from' : '';
+
+ return (
+
+
+ {appInstances.map(eachApp => (
+
+ ))}
+
+
+ );
+};
+
+const styled = withStyles(styles);
+
+export default compose(
+ styled,
+ React.memo
+)(SelectAppPanel);
+
+interface SelectionProps {
+ handleClick: (
+ id: number,
+ label: string,
+ username: string,
+ stackScriptImages: string[],
+ userDefinedFields: Linode.StackScript.UserDefinedField[]
+ ) => void;
+ iconUrl: string;
+ id: number;
+ label: string;
+ userDefinedFields: Linode.StackScript.UserDefinedField[];
+ availableImages: string[];
+ disabled: boolean;
+ checked: boolean;
+}
+
+class SelectionCardWrapper extends React.PureComponent {
+ handleSelectApp = (event: React.SyntheticEvent) => {
+ const { id, label, userDefinedFields, availableImages } = this.props;
+
+ return this.props.handleClick(
+ id,
+ label,
+ '' /** username doesn't matter since we're not displaying it */,
+ availableImages,
+ userDefinedFields
+ );
+ };
+
+ render() {
+ const { iconUrl, id, checked, label, disabled } = this.props;
+ return (
+ {
+ return ;
+ }}
+ heading={label}
+ subheadings={['']}
+ data-qa-selection-card
+ disabled={disabled}
+ />
+ );
+ }
+}
diff --git a/src/features/linodes/LinodesCreate/SelectBackupPanel.tsx b/src/features/linodes/LinodesCreate/SelectBackupPanel.tsx
index be7f62d930e..fe473115790 100644
--- a/src/features/linodes/LinodesCreate/SelectBackupPanel.tsx
+++ b/src/features/linodes/LinodesCreate/SelectBackupPanel.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { compose } from 'recompose';
import Paper from 'src/components/core/Paper';
import {
StyleRulesCallback,
@@ -8,7 +9,7 @@ import {
import Typography from 'src/components/core/Typography';
import Grid from 'src/components/Grid';
import Notice from 'src/components/Notice';
-import RenderGuard from 'src/components/RenderGuard';
+import RenderGuard, { RenderGuardProps } from 'src/components/RenderGuard';
import SelectionCard from 'src/components/SelectionCard';
import { aggregateBackups } from 'src/features/linodes/LinodesDetail/LinodeBackup';
import { formatDate } from 'src/utilities/formatDate';
@@ -180,4 +181,7 @@ class SelectBackupPanel extends React.Component {
const styled = withStyles(styles);
-export default styled(RenderGuard(SelectBackupPanel));
+export default compose(
+ RenderGuard,
+ styled
+)(SelectBackupPanel);
diff --git a/src/features/linodes/LinodesCreate/SelectImagePanel.tsx b/src/features/linodes/LinodesCreate/SelectImagePanel.tsx
index 11c293b79cf..7f1acb84cc4 100644
--- a/src/features/linodes/LinodesCreate/SelectImagePanel.tsx
+++ b/src/features/linodes/LinodesCreate/SelectImagePanel.tsx
@@ -15,52 +15,20 @@ import {
values
} from 'ramda';
import * as React from 'react';
-import Paper from 'src/components/core/Paper';
-import {
- StyleRulesCallback,
- withStyles,
- WithStyles
-} from 'src/components/core/styles';
-import Typography from 'src/components/core/Typography';
-import Grid from 'src/components/Grid';
-import Notice from 'src/components/Notice';
import RenderGuard from 'src/components/RenderGuard';
-import SelectionCard from 'src/components/SelectionCard';
-import ShowMoreExpansion from 'src/components/ShowMoreExpansion';
import TabbedPanel from 'src/components/TabbedPanel';
-type ClassNames = 'root' | 'flatImagePanel' | 'flatImagePanelSelections';
-
-const styles: StyleRulesCallback = theme => ({
- flatImagePanel: {
- padding: theme.spacing.unit * 3
- },
- flatImagePanelSelections: {
- marginTop: theme.spacing.unit * 2,
- padding: `${theme.spacing.unit}px 0`
- },
- root: {}
-});
-
-const distroIcons = {
- Alpine: 'alpine',
- Arch: 'archlinux',
- CentOS: 'centos',
- CoreOS: 'coreos',
- Debian: 'debian',
- Fedora: 'fedora',
- Gentoo: 'gentoo',
- openSUSE: 'opensuse',
- Slackware: 'slackware',
- Ubuntu: 'ubuntu'
-};
+import Panel from './Panel';
+import PrivateImages from './PrivateImages';
+import PublicImages from './PublicImages';
interface Props {
images: Linode.Image[];
+ title?: string;
error?: string;
- selectedImageID: string | null;
+ selectedImageID?: string;
handleSelection: (id: string) => void;
- hideMyImages?: boolean;
+ variant?: 'public' | 'private' | 'all';
initTab?: number;
disabled?: boolean;
}
@@ -105,130 +73,57 @@ export const getMyImages = compose(
filter(propSatisfies(startsWith('private'), 'id'))
);
-type CombinedProps = Props & WithStyles;
+type CombinedProps = Props;
const CreateFromImage: React.StatelessComponent = props => {
- const { images, error, handleSelection, disabled } = props;
+ const { images, error, handleSelection, disabled, title, variant, selectedImageID } = props;
const publicImages = getPublicImages(images);
const olderPublicImages = getOlderPublicImages(images);
const myImages = getMyImages(images);
- const renderPublicImages = () =>
- publicImages.length &&
- publicImages.map((image: Linode.Image, idx: number) => (
- handleSelection(image.id)}
- renderIcon={() => {
- return (
-
- );
- }}
- heading={image.vendor as string}
- subheadings={[image.label]}
- data-qa-selection-card
- disabled={disabled}
- />
- ));
+ const Public = (
+
+
+
+ )
- const renderOlderPublicImages = () =>
- olderPublicImages.length &&
- olderPublicImages.map((image: Linode.Image, idx: number) => (
- handleSelection(image.id)}
- renderIcon={() => {
- return (
-
- );
- }}
- heading={image.vendor as string}
- subheadings={[image.label]}
- disabled={disabled}
- />
- ));
+ const Private = (
+
+
+
+ )
const tabs = [
{
title: 'Public Images',
render: () => (
-
-
- {renderPublicImages()}
-
-
-
- {renderOlderPublicImages()}
-
-
-
+ Public
)
},
{
title: 'My Images',
render: () => (
-
- {myImages &&
- myImages.map((image: Linode.Image, idx: number) => (
- handleSelection(image.id)}
- renderIcon={() => }
- heading={image.label as string}
- subheadings={[image.description as string]}
- disabled={disabled}
- />
- ))}
-
+ Private
)
}
];
- const renderTabs = () => {
- const { hideMyImages } = props;
- if (hideMyImages) {
- return tabs;
- }
- return tabs;
- };
-
- return (
-
- {props.hideMyImages !== true ? ( // if we have no olderPublicImage, hide the dropdown
+ switch (variant) {
+ case 'private':
+ return Private;
+ case 'public':
+ return Public;
+ case 'all':
+ default:
+ return (
- ) : (
-
- {error && }
-
- Select Image
-
-
- {renderPublicImages()}
-
- {olderPublicImages.length > 0 && (
-
-
- {renderOlderPublicImages()}
-
-
- )}
-
- )}
-
- );
+ )
+ }
};
-const styled = withStyles(styles);
-
-export default styled(RenderGuard(CreateFromImage));
+export default (RenderGuard(CreateFromImage));
diff --git a/src/features/linodes/LinodesCreate/SelectLinodePanel.tsx b/src/features/linodes/LinodesCreate/SelectLinodePanel.tsx
index 4749f8220a5..ac15b2cbf60 100644
--- a/src/features/linodes/LinodesCreate/SelectLinodePanel.tsx
+++ b/src/features/linodes/LinodesCreate/SelectLinodePanel.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { compose } from 'recompose';
import Paper from 'src/components/core/Paper';
import {
StyleRulesCallback,
@@ -10,7 +11,7 @@ import Grid from 'src/components/Grid';
import Notice from 'src/components/Notice';
import Paginate from 'src/components/Paginate';
import PaginationFooter from 'src/components/PaginationFooter';
-import RenderGuard from 'src/components/RenderGuard';
+import RenderGuard, { RenderGuardProps } from 'src/components/RenderGuard';
import SelectionCard from 'src/components/SelectionCard';
export interface ExtendedLinode extends Linode.Linode {
@@ -35,13 +36,19 @@ const styles: StyleRulesCallback = theme => ({
}
});
+interface Notice {
+ text: string;
+ level: 'warning' | 'error'; // most likely only going to need these two
+}
+
interface Props {
linodes: ExtendedLinode[];
selectedLinodeID?: number;
- handleSelection: (linode: Linode.Linode) => void;
+ handleSelection: (id: number, diskSize?: number) => void;
error?: string;
header?: string;
disabled?: boolean;
+ notice?: Notice;
}
type StyledProps = Props & WithStyles;
@@ -55,7 +62,7 @@ class SelectLinodePanel extends React.Component {
{
- handleSelection(linode);
+ handleSelection(linode.id, linode.specs.disk);
}}
checked={linode.id === Number(selectedLinodeID)}
heading={linode.heading}
@@ -66,14 +73,14 @@ class SelectLinodePanel extends React.Component {
}
render() {
- const { error, classes, linodes, header } = this.props;
+ const { error, classes, linodes, header, notice, disabled } = this.props;
return (
{({
count,
- data: linodes,
+ data: linodesData,
handlePageChange,
handlePageSizeChange,
page,
@@ -87,6 +94,13 @@ class SelectLinodePanel extends React.Component {
>
{error &&
}
+ {notice && !disabled && (
+
+ )}
{
- {linodes.map(linode => {
+ {linodesData.map(linode => {
return this.renderCard(linode);
})}
@@ -122,4 +136,7 @@ class SelectLinodePanel extends React.Component {
const styled = withStyles(styles);
-export default styled(RenderGuard(SelectLinodePanel));
+export default compose(
+ RenderGuard,
+ styled
+)(SelectLinodePanel);
diff --git a/src/features/linodes/LinodesCreate/SelectPlanPanel.tsx b/src/features/linodes/LinodesCreate/SelectPlanPanel.tsx
index 4b17c4a465a..0091852d2ee 100644
--- a/src/features/linodes/LinodesCreate/SelectPlanPanel.tsx
+++ b/src/features/linodes/LinodesCreate/SelectPlanPanel.tsx
@@ -1,5 +1,6 @@
import { isEmpty } from 'ramda';
import * as React from 'react';
+import { compose } from 'recompose';
import {
StyleRulesCallback,
withStyles,
@@ -7,7 +8,7 @@ import {
} from 'src/components/core/styles';
// import Typography from 'src/components/core/Typography';
import Grid from 'src/components/Grid';
-import RenderGuard from 'src/components/RenderGuard';
+import RenderGuard, { RenderGuardProps } from 'src/components/RenderGuard';
import SelectionCard from 'src/components/SelectionCard';
import TabbedPanel from 'src/components/TabbedPanel';
import { Tab } from 'src/components/TabbedPanel/TabbedPanel';
@@ -33,7 +34,7 @@ interface Props {
types: ExtendedType[];
error?: string;
onSelect: (key: string) => void;
- selectedID: string | null;
+ selectedID?: string;
selectedDiskSize?: number;
currentPlanHeading?: string;
disabled?: boolean;
@@ -176,6 +177,10 @@ export class SelectPlanPanel extends React.Component<
const styled = withStyles(styles);
-export default styled(
- RenderGuard>(SelectPlanPanel)
-);
+export default compose<
+ Props & WithStyles,
+ Props & RenderGuardProps
+>(
+ RenderGuard,
+ styled
+)(SelectPlanPanel);
diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx
new file mode 100644
index 00000000000..64e7fa9a055
--- /dev/null
+++ b/src/features/linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx
@@ -0,0 +1,372 @@
+import { assocPath, pathOr } from 'ramda';
+import * as React from 'react';
+import { Sticky, StickyProps } from 'react-sticky';
+
+import AccessPanel from 'src/components/AccessPanel';
+import CheckoutBar from 'src/components/CheckoutBar';
+import Paper from 'src/components/core/Paper';
+import {
+ StyleRulesCallback,
+ withStyles,
+ WithStyles
+} from 'src/components/core/styles';
+import Typography from 'src/components/core/Typography';
+import CreateLinodeDisabled from 'src/components/CreateLinodeDisabled';
+import Grid from 'src/components/Grid';
+import LabelAndTagsPanel from 'src/components/LabelAndTagsPanel';
+import Notice from 'src/components/Notice';
+import SelectRegionPanel from 'src/components/SelectRegionPanel';
+import { Tag } from 'src/components/TagsInput';
+import UserDefinedFieldsPanel from 'src/features/StackScripts/UserDefinedFieldsPanel';
+import AddonsPanel from '../AddonsPanel';
+import SelectAppPanel from '../SelectAppPanel';
+import SelectImagePanel from '../SelectImagePanel';
+import SelectPlanPanel from '../SelectPlanPanel';
+
+import getAPIErrorsFor from 'src/utilities/getAPIErrorFor';
+import { filterUDFErrors } from './formUtilities';
+import { renderBackupsDisplaySection } from './utils';
+
+import {
+ AppsData,
+ ReduxStatePropsAndSSHKeys,
+ StackScriptFormStateHandlers,
+ WithDisplayData,
+ WithTypesRegionsAndImages
+} from '../types';
+
+type ClassNames = 'sidebar' | 'emptyImagePanel' | 'emptyImagePanelText';
+
+const styles: StyleRulesCallback = theme => ({
+ sidebar: {
+ [theme.breakpoints.up('lg')]: {
+ marginTop: '-130px !important'
+ }
+ },
+ emptyImagePanel: {
+ padding: theme.spacing.unit * 3
+ },
+ emptyImagePanelText: {
+ marginTop: theme.spacing.unit,
+ padding: `${theme.spacing.unit}px 0`
+ }
+});
+
+const errorResources = {
+ type: 'A plan selection',
+ region: 'A region selection',
+ label: 'A label',
+ root_pass: 'A root password',
+ image: 'Image',
+ tags: 'Tags',
+ stackscript_id: 'A StackScript'
+};
+
+type CombinedProps = WithDisplayData &
+ AppsData &
+ WithTypesRegionsAndImages &
+ WithStyles &
+ ReduxStatePropsAndSSHKeys &
+ StackScriptFormStateHandlers;
+
+class FromAppsContent extends React.PureComponent {
+ handleSelectStackScript = (
+ id: number,
+ label: string,
+ username: string,
+ stackScriptImages: string[],
+ userDefinedFields: Linode.StackScript.UserDefinedField[]
+ ) => {
+ /**
+ * based on the list of images we get back from the API, compare those
+ * to our list of master images supported by Linode and filter out the ones
+ * that aren't compatible with our selected StackScript
+ */
+ const compatibleImages = this.props.imagesData.filter(eachImage => {
+ return stackScriptImages.some(
+ eachSSImage => eachSSImage === eachImage.id
+ );
+ });
+
+ /**
+ * if a UDF field comes back from the API with a "default"
+ * value, it means we need to pre-populate the field and form state
+ */
+ const defaultUDFData = userDefinedFields.reduce((accum, eachField) => {
+ if (eachField.default) {
+ accum[eachField.name] = eachField.default;
+ }
+ return accum;
+ }, {});
+
+ this.props.updateStackScript(
+ id,
+ label,
+ username,
+ userDefinedFields,
+ compatibleImages,
+ defaultUDFData
+ );
+ };
+
+ handleChangeUDF = (key: string, value: string) => {
+ // either overwrite or create new selection
+ const newUDFData = assocPath([key], value, this.props.selectedUDFs);
+
+ this.props.handleSelectUDFs({ ...this.props.selectedUDFs, ...newUDFData });
+ };
+
+ handleCreateLinode = () => {
+ const {
+ backupsEnabled,
+ password,
+ userSSHKeys,
+ handleSubmitForm,
+ selectedImageID,
+ selectedRegionID,
+ selectedStackScriptID,
+ selectedTypeID,
+ selectedUDFs,
+ privateIPEnabled,
+ tags
+ } = this.props;
+
+ handleSubmitForm('createFromStackScript', {
+ region: selectedRegionID,
+ type: selectedTypeID,
+ stackscript_id: selectedStackScriptID,
+ stackscript_data: selectedUDFs,
+ label: this.props.label /* optional */,
+ root_pass: password /* required if image ID is provided */,
+ image: selectedImageID /* optional */,
+ backups_enabled: backupsEnabled /* optional */,
+ booted: true,
+ private_ip: privateIPEnabled,
+ authorized_users: userSSHKeys
+ .filter(u => u.selected)
+ .map(u => u.username),
+ tags: tags ? tags.map((item: Tag) => item.value) : []
+ });
+ };
+
+ render() {
+ const {
+ accountBackupsEnabled,
+ classes,
+ typesData,
+ regionsData,
+ imageDisplayInfo,
+ regionDisplayInfo,
+ typeDisplayInfo,
+ backupsMonthlyPrice,
+ userSSHKeys,
+ userCannotCreateLinode,
+ selectedImageID,
+ selectedRegionID,
+ selectedStackScriptID,
+ selectedStackScriptLabel,
+ selectedTypeID,
+ selectedUDFs: udf_data,
+ label,
+ tags,
+ availableUserDefinedFields: userDefinedFields,
+ availableStackScriptImages: compatibleImages,
+ updateImageID,
+ updateLabel,
+ updatePassword,
+ updateRegionID,
+ updateTags,
+ updateTypeID,
+ formIsSubmitting,
+ password,
+ backupsEnabled,
+ toggleBackupsEnabled,
+ privateIPEnabled,
+ togglePrivateIPEnabled,
+ errors,
+ appInstances,
+ appInstancesError,
+ appInstancesLoading
+ } = this.props;
+
+ const hasBackups = backupsEnabled || accountBackupsEnabled;
+ const hasErrorFor = getAPIErrorsFor(errorResources, errors);
+ const generalError = hasErrorFor('none');
+
+ return (
+
+
+
+ {generalError && }
+
+ {!userCannotCreateLinode &&
+ userDefinedFields &&
+ userDefinedFields.length > 0 && (
+
+ )}
+ {!userCannotCreateLinode &&
+ compatibleImages &&
+ compatibleImages.length > 0 ? (
+
+ ) : (
+
+ {/* empty state for images */}
+ {hasErrorFor('image') && (
+
+ )}
+
+ Select Image
+
+
+ No Compatible Images Available
+
+
+ )}
+
+
+
+ 0 && selectedImageID ? userSSHKeys : []}
+ />
+
+
+
+
+ {(stickyProps: StickyProps) => {
+ const displaySections = [];
+ if (imageDisplayInfo) {
+ displaySections.push(imageDisplayInfo);
+ }
+
+ if (regionDisplayInfo) {
+ displaySections.push({
+ title: regionDisplayInfo.title,
+ details: regionDisplayInfo.details
+ });
+ }
+
+ if (typeDisplayInfo) {
+ displaySections.push(typeDisplayInfo);
+ }
+
+ if (
+ hasBackups &&
+ typeDisplayInfo &&
+ typeDisplayInfo.backupsMonthly
+ ) {
+ displaySections.push(
+ renderBackupsDisplaySection(
+ accountBackupsEnabled,
+ typeDisplayInfo.backupsMonthly
+ )
+ );
+ }
+
+ let calculatedPrice = pathOr(0, ['monthly'], typeDisplayInfo);
+ if (
+ hasBackups &&
+ typeDisplayInfo &&
+ typeDisplayInfo.backupsMonthly
+ ) {
+ calculatedPrice += typeDisplayInfo.backupsMonthly;
+ }
+
+ return (
+
+ );
+ }}
+
+
+
+ );
+ }
+}
+
+const styled = withStyles(styles);
+
+export default styled(FromAppsContent);
diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx
index 040b3f08347..d7bbd41c4bc 100644
--- a/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx
+++ b/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx
@@ -1,87 +1,48 @@
import { shallow } from 'enzyme';
import * as React from 'react';
import { LinodesWithBackups } from 'src/__data__/LinodesWithBackups';
-import withLinodeActions from 'src/__data__/withLinodeActions';
-import { FromBackupsContent } from './FromBackupsContent';
+import { CombinedProps, FromBackupsContent } from './FromBackupsContent';
-const mockProps = {
- linodes: [],
- types: [],
- accountBackups: false,
- extendLinodes: jest.fn(),
- getBackupsMonthlyPrice: jest.fn(),
- getTypeInfo: jest.fn(),
- getRegionInfo: jest.fn(),
- history: null,
- tagObject: {
- accountTags: [],
- selectedTags: [],
- newTags: [],
- errors: [],
- actions: {
- addTag: jest.fn(),
- createTag: jest.fn(),
- getLinodeTagList: jest.fn()
- }
+const mockProps: CombinedProps = {
+ typeDisplayInfo: undefined,
+ classes: {
+ root: '',
+ main: '',
+ sidebar: ''
},
- enqueueSnackbar: jest.fn(),
- onPresentSnackbar: jest.fn(),
- customLabel: '',
- updateCustomLabel: jest.fn(),
- getLabel: jest.fn()
-};
-
-const mockPropsWithNotice = {
- notice: {
- text: 'example text',
- level: 'warning' as 'warning' | 'error'
- },
- linodes: [],
- accountBackups: false,
- types: [],
- extendLinodes: jest.fn(),
- getBackupsMonthlyPrice: jest.fn(),
- getTypeInfo: jest.fn(),
- getRegionInfo: jest.fn(),
- history: null,
- tagObject: {
- accountTags: [],
- selectedTags: [],
- newTags: [],
- errors: [],
- actions: {
- addTag: jest.fn(),
- createTag: jest.fn(),
- getLinodeTagList: jest.fn()
- }
- },
- enqueueSnackbar: jest.fn(),
- onPresentSnackbar: jest.fn(),
- customLabel: '',
- updateCustomLabel: jest.fn(),
- getLabel: jest.fn()
+ updateDiskSize: jest.fn(),
+ updateImageID: jest.fn(),
+ updateLabel: jest.fn(),
+ updateLinodeID: jest.fn(),
+ updatePassword: jest.fn(),
+ updateRegionID: jest.fn(),
+ updateTags: jest.fn(),
+ updateTypeID: jest.fn(),
+ formIsSubmitting: false,
+ label: '',
+ password: '',
+ backupsEnabled: false,
+ accountBackupsEnabled: false,
+ toggleBackupsEnabled: jest.fn(),
+ togglePrivateIPEnabled: jest.fn(),
+ handleSubmitForm: jest.fn(),
+ privateIPEnabled: false,
+ resetCreationState: jest.fn(),
+ resetSSHKeys: jest.fn(),
+ imagesData: [],
+ regionsData: [],
+ typesData: [],
+ userCannotCreateLinode: false,
+ linodesData: [],
+ setBackupID: jest.fn(),
+ userSSHKeys: []
};
describe('FromBackupsContent', () => {
- const component = shallow(
-
- );
-
- const componentWithNotice = shallow(
-
- );
+ const component = shallow();
component.setState({ isGettingBackups: false }); // get rid of loading state
- componentWithNotice.setState({ isGettingBackups: false }); // get rid of loading state
it('should render Placeholder if no valid backups exist', () => {
expect(component.find('WithStyles(Placeholder)')).toHaveLength(1);
@@ -89,26 +50,14 @@ describe('FromBackupsContent', () => {
describe('FromBackupsContent When Valid Backups Exist', () => {
beforeAll(async () => {
- componentWithNotice.setState({ linodesWithBackups: LinodesWithBackups });
- await componentWithNotice.update();
-
component.setState({ linodesWithBackups: LinodesWithBackups });
await component.update();
});
- it('should render a notice when passed a Notice prop', () => {
- // give our components a Linode with a valid backup
- expect(componentWithNotice.find('WithStyles(Notice)')).toHaveLength(1);
- });
-
- it('should not render a notice when no notice prop passed', () => {
- expect(component.find('WithStyles(Notice)')).toHaveLength(0);
- });
-
it('should render SelectLinode panel', () => {
expect(
component.find(
- 'WithStyles(WithTheme(WithRenderGuard(SelectLinodePanel)))'
+ 'WithTheme(WithRenderGuard(WithStyles(SelectLinodePanel)))'
)
).toHaveLength(1);
});
@@ -116,7 +65,7 @@ describe('FromBackupsContent', () => {
it('should render SelectBackup panel', () => {
expect(
component.find(
- 'WithStyles(WithTheme(WithRenderGuard(SelectBackupPanel)))'
+ 'WithTheme(WithRenderGuard(WithStyles(SelectBackupPanel)))'
)
).toHaveLength(1);
});
@@ -124,22 +73,20 @@ describe('FromBackupsContent', () => {
it('should render SelectPlan panel', () => {
expect(
component.find(
- 'WithStyles(WithTheme(WithRenderGuard(SelectPlanPanel)))'
+ 'WithTheme(WithRenderGuard(WithStyles(SelectPlanPanel)))'
)
).toHaveLength(1);
});
it('should render SelectLabel panel', () => {
expect(
- component.find('WithStyles(WithTheme(WithRenderGuard(InfoPanel)))')
+ component.find('WithTheme(WithRenderGuard(WithStyles(InfoPanel)))')
).toHaveLength(1);
});
it('should render SelectAddOns panel', () => {
expect(
- component.find(
- 'WithStyles(withRouter(WithTheme(WithRenderGuard(AddonsPanel))))'
- )
+ component.find('WithTheme(WithRenderGuard(WithStyles(AddonsPanel)))')
).toHaveLength(1);
});
});
diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx
index a0139592967..df615d0d372 100644
--- a/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx
+++ b/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx
@@ -1,12 +1,11 @@
import * as Promise from 'bluebird';
-import { InjectedNotistackProps, withSnackbar } from 'notistack';
import { compose as ramdaCompose, pathOr } from 'ramda';
import * as React from 'react';
import { Sticky, StickyProps } from 'react-sticky';
-import { compose } from 'recompose';
import VolumeIcon from 'src/assets/addnewmenu/volume.svg';
import CheckoutBar from 'src/components/CheckoutBar';
import CircleProgress from 'src/components/CircleProgress';
+import Paper from 'src/components/core/Paper';
import {
StyleRulesCallback,
withStyles,
@@ -15,27 +14,21 @@ import {
import CreateLinodeDisabled from 'src/components/CreateLinodeDisabled';
import Grid from 'src/components/Grid';
import LabelAndTagsPanel from 'src/components/LabelAndTagsPanel';
-import Notice from 'src/components/Notice';
import Placeholder from 'src/components/Placeholder';
-import { Tag } from 'src/components/TagsInput';
-import { resetEventsPolling } from 'src/events';
import { getLinodeBackups } from 'src/services/linodes';
-import {
- LinodeActionsProps,
- withLinodeActions
-} from 'src/store/linodes/linode.containers';
-import { allocatePrivateIP } from 'src/utilities/allocateIPAddress';
-import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
import getAPIErrorsFor from 'src/utilities/getAPIErrorFor';
-import getLinodeInfo from 'src/utilities/getLinodeInfo';
-import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView';
-import { aggregateBackups } from '../../LinodesDetail/LinodeBackup';
import AddonsPanel from '../AddonsPanel';
import SelectBackupPanel from '../SelectBackupPanel';
-import SelectLinodePanel, { ExtendedLinode } from '../SelectLinodePanel';
-import SelectPlanPanel, { ExtendedType } from '../SelectPlanPanel';
-import { Info } from '../util';
-import withLabelGenerator, { LabelProps } from '../withLabelGenerator';
+import SelectLinodePanel from '../SelectLinodePanel';
+import SelectPlanPanel from '../SelectPlanPanel';
+import {
+ BackupFormStateHandlers,
+ Info,
+ ReduxStatePropsAndSSHKeys,
+ WithDisplayData,
+ WithLinodesTypesRegionsAndImages
+} from '../types';
+import { extendLinodes } from '../utilities';
import { renderBackupsDisplaySection } from './utils';
type ClassNames = 'root' | 'main' | 'sidebar';
@@ -45,69 +38,34 @@ const styles: StyleRulesCallback = theme => ({
main: {},
sidebar: {
[theme.breakpoints.up('lg')]: {
- marginTop: -130
+ marginTop: '-130px !important'
}
}
});
-export type TypeInfo =
- | {
- title: string;
- details: string;
- monthly: number;
- backupsMonthly: number | null;
- }
- | undefined;
-
interface Props {
- notice?: Notice;
- linodes: Linode.Linode[];
- types: ExtendedType[];
- extendLinodes: (linodes: Linode.Linode[]) => ExtendedLinode[];
- getBackupsMonthlyPrice: (selectedTypeID: string | null) => number | null;
- getTypeInfo: (selectedTypeID: string | null) => TypeInfo;
- getRegionInfo: (selectedRegionID: string | null) => Info;
- history: any;
- selectedBackupFromQuery?: number;
- selectedLinodeFromQuery?: number;
- selectedRegionIDFromLinode?: string;
- accountBackups: boolean;
disabled?: boolean;
}
interface State {
linodesWithBackups: Linode.LinodeWithBackups[] | null;
- isGettingBackups: boolean;
userHasBackups: boolean;
- selectedLinodeID: number | undefined;
- selectedBackupID: number | undefined;
- selectedDiskSize: number | undefined;
- selectedRegionID: string | null;
- selectedTypeID: string | null;
- label: string;
- errors?: Linode.ApiFieldError[];
backups: boolean;
- privateIP: boolean;
selectedBackupInfo: Info;
- isMakingRequest: boolean;
backupInfo: Info;
- tags: Tag[];
+ isGettingBackups: boolean;
}
-type CombinedProps = Props &
- LinodeActionsProps &
- InjectedNotistackProps &
- LabelProps &
+export type CombinedProps = Props &
+ BackupFormStateHandlers &
+ WithLinodesTypesRegionsAndImages &
+ ReduxStatePropsAndSSHKeys &
+ WithDisplayData &
WithStyles;
-interface Notice {
- text: string;
- level: 'warning' | 'error'; // most likely only going to need these two
-}
-
const errorResources = {
type: 'A plan selection',
- region: 'region',
+ region: 'A region selection',
label: 'A label',
root_pass: 'A root password',
tags: 'Tags for this Linode'
@@ -126,20 +84,11 @@ const filterLinodesWithBackups = (linodes: Linode.LinodeWithBackups[]) => {
export class FromBackupsContent extends React.Component {
state: State = {
linodesWithBackups: [],
- isGettingBackups: false,
userHasBackups: false,
- selectedLinodeID: this.props.selectedLinodeFromQuery || undefined,
- selectedBackupID: this.props.selectedBackupFromQuery || undefined,
- selectedDiskSize: undefined,
- selectedRegionID: this.props.selectedRegionIDFromLinode || null,
- selectedTypeID: null,
- label: '',
backups: false,
- privateIP: false,
selectedBackupInfo: undefined,
- isMakingRequest: false,
backupInfo: undefined,
- tags: []
+ isGettingBackups: false
};
mounted: boolean = false;
@@ -180,117 +129,33 @@ export class FromBackupsContent extends React.Component {
});
};
- handleSelectLinode = (linode: Linode.Linode) => {
- if (linode.id !== this.state.selectedLinodeID) {
- this.setState({
- selectedLinodeID: linode.id,
- selectedTypeID: null,
- selectedRegionID: linode.region,
- selectedDiskSize: linode.specs.disk,
- selectedBackupID: undefined
- });
- }
- };
-
- handleSelectBackupID = (id: number) => {
- this.setState({ selectedBackupID: id });
- };
-
handleSelectBackupInfo = (info: Info) => {
this.setState({ backupInfo: info });
};
- handleSelectPlan = (id: string) => {
- this.setState({ selectedTypeID: id });
- };
-
- handleSelectLabel = (e: any) => {
- this.setState({ label: e.target.value });
- };
-
- handleChangeTags = (selected: Tag[]) => {
- this.setState({ tags: selected });
- };
-
- handleToggleBackups = () => {
- this.setState({ backups: !this.state.backups });
- };
-
- handleTogglePrivateIP = () => {
- this.setState({ privateIP: !this.state.privateIP });
- };
-
- deployLinode = () => {
- if (!this.state.selectedBackupID) {
- /* a backup selection is also required */
- this.setState(
- {
- errors: [{ field: 'backup_id', reason: 'You must select a Backup' }]
- },
- () => {
- scrollErrorIntoView();
- }
- );
- return;
- }
- this.createLinode();
- };
-
createLinode = () => {
const {
- history,
- linodeActions: { createLinode }
- } = this.props;
- const {
- selectedRegionID,
+ backupsEnabled,
+ privateIPEnabled,
selectedTypeID,
- backups,
- privateIP,
+ selectedRegionID,
selectedBackupID,
+ label,
tags
- } = this.state;
-
- this.setState({ isMakingRequest: true });
+ } = this.props;
- const label = this.label();
+ const tagsToAdd = tags ? tags.map(item => item.value) : undefined;
- createLinode({
+ this.props.handleSubmitForm('createFromBackup', {
region: selectedRegionID,
type: selectedTypeID,
+ private_ip: privateIPEnabled,
backup_id: Number(selectedBackupID),
- label: label ? label : null /* optional */,
- backups_enabled: backups /* optional */,
+ label,
+ backups_enabled: backupsEnabled /* optional */,
booted: true,
- tags: tags.map((item: Tag) => item.value)
- })
- .then(linode => {
- if (privateIP) {
- allocatePrivateIP(linode.id);
- }
-
- this.props.enqueueSnackbar(`Your Linode ${label} is being created.`, {
- variant: 'success'
- });
-
- resetEventsPolling();
- history.push('/linodes');
- })
- .catch(error => {
- if (!this.mounted) {
- return;
- }
-
- this.setState(() => ({
- errors: getAPIErrorOrDefault(error)
- }));
- })
- .finally(() => {
- if (!this.mounted) {
- return;
- }
- // regardless of whether request failed or not, change state and enable the submit btn
- this.setState({ isMakingRequest: false });
- });
+ tags: tagsToAdd
+ });
};
componentWillUnmount() {
@@ -299,121 +164,69 @@ export class FromBackupsContent extends React.Component {
componentDidMount() {
this.mounted = true;
- this.getLinodesWithBackups(this.props.linodes);
- const { selectedLinodeID } = this.state;
+ this.getLinodesWithBackups(this.props.linodesData);
// If there is a selected Linode ID (from props), make sure its information
// is set to state as if it had been selected manually.
- if (selectedLinodeID) {
- const selectedLinode = getLinodeInfo(
- selectedLinodeID,
- this.props.linodes
- );
- if (selectedLinode) {
- this.setState({
- selectedLinodeID: selectedLinode.id,
- selectedTypeID: null,
- selectedRegionID: selectedLinode.region,
- selectedDiskSize: selectedLinode.specs.disk
- });
- }
- }
}
- // Generate a default label name with a selected Linode and/or Backup name IF they are selected
- label = () => {
+ render() {
const {
+ backups,
linodesWithBackups,
- selectedBackupID,
- selectedLinodeID
+ isGettingBackups,
+ selectedBackupInfo
} = this.state;
- const { getLabel } = this.props;
-
- const selectedLinode =
- linodesWithBackups &&
- linodesWithBackups.find(l => l.id === selectedLinodeID);
-
- if (!selectedLinode) {
- return getLabel();
- }
-
- const selectedBackup = aggregateBackups(selectedLinode.currentBackups).find(
- b => b.id === selectedBackupID
- );
-
- if (!selectedBackup) {
- return getLabel(selectedLinode.label, 'backup');
- }
-
- const backup =
- selectedBackup.type !== 'auto' ? selectedBackup.label : 'auto'; // automatic backups have a label of 'null', so use a custom string for these
-
- return getLabel(selectedLinode.label, backup, 'backup');
- };
-
- render() {
const {
+ accountBackupsEnabled,
+ classes,
errors,
+ privateIPEnabled,
selectedBackupID,
selectedDiskSize,
selectedLinodeID,
- tags,
selectedTypeID,
- selectedRegionID,
- backups,
- linodesWithBackups,
- privateIP,
- selectedBackupInfo,
- isMakingRequest
- } = this.state;
- const {
- accountBackups,
- extendLinodes,
- getBackupsMonthlyPrice,
- classes,
- notice,
- types,
- getRegionInfo,
- getTypeInfo,
- updateCustomLabel,
- disabled
+ setBackupID,
+ togglePrivateIPEnabled,
+ toggleBackupsEnabled,
+ regionDisplayInfo,
+ typeDisplayInfo,
+ disabled,
+ label,
+ tags,
+ typesData,
+ updateLinodeID,
+ updateTypeID,
+ updateTags,
+ backupsMonthlyPrice,
+ backupsEnabled,
+ updateLabel
} = this.props;
const hasErrorFor = getAPIErrorsFor(errorResources, errors);
- const generalError = hasErrorFor('none');
const imageInfo = selectedBackupInfo;
- const regionInfo = selectedRegionID && getRegionInfo(selectedRegionID);
-
- const typeInfo = getTypeInfo(selectedTypeID);
-
- const hasBackups = backups || accountBackups;
-
- const label = this.label();
+ const hasBackups = backups || accountBackupsEnabled;
return (
-
+
{this.state.isGettingBackups ? (
-
+
+
+
) : !this.userHasBackups() ? (
-
+ title="Create from Backup"
+ />
+
) : (
- {notice && !disabled && (
-
- )}
- {generalError && }
{
filterLinodesWithBackups
)(linodesWithBackups!)}
selectedLinodeID={selectedLinodeID}
- handleSelection={this.handleSelectLinode}
- updateFor={[selectedLinodeID, errors, classes]}
+ handleSelection={updateLinodeID}
+ updateFor={[selectedLinodeID, errors]}
disabled={disabled}
+ notice={{
+ level: 'warning',
+ text: `This newly created Linode will be created with
+ the same password and SSH Keys (if any) as the original Linode.
+ Also note that this Linode will need to be manually booted after it finishes
+ provisioning.`
+ }}
/>
{
)}
selectedLinodeID={selectedLinodeID}
selectedBackupID={selectedBackupID}
- handleChangeBackup={this.handleSelectBackupID}
+ handleChangeBackup={setBackupID}
handleChangeBackupInfo={this.handleSelectBackupInfo}
- updateFor={[
- selectedLinodeID,
- selectedBackupID,
- errors,
- classes
- ]}
+ updateFor={[selectedLinodeID, selectedBackupID, errors]}
/>
@@ -493,38 +308,46 @@ export class FromBackupsContent extends React.Component {
displaySections.push(imageInfo);
}
- if (regionInfo) {
+ if (regionDisplayInfo) {
displaySections.push({
- title: regionInfo.title,
- details: regionInfo.details
+ title: regionDisplayInfo.title,
+ details: regionDisplayInfo.details
});
}
- if (typeInfo) {
- displaySections.push(typeInfo);
+ if (typeDisplayInfo) {
+ displaySections.push(typeDisplayInfo);
}
- if (hasBackups && typeInfo && typeInfo.backupsMonthly) {
+ if (
+ hasBackups &&
+ typeDisplayInfo &&
+ typeDisplayInfo.backupsMonthly
+ ) {
displaySections.push(
renderBackupsDisplaySection(
- accountBackups,
- typeInfo.backupsMonthly
+ accountBackupsEnabled,
+ typeDisplayInfo.backupsMonthly
)
);
}
- let calculatedPrice = pathOr(0, ['monthly'], typeInfo);
- if (hasBackups && typeInfo && typeInfo.backupsMonthly) {
- calculatedPrice += typeInfo.backupsMonthly;
+ let calculatedPrice = pathOr(0, ['monthly'], typeDisplayInfo);
+ if (
+ hasBackups &&
+ typeDisplayInfo &&
+ typeDisplayInfo.backupsMonthly
+ ) {
+ calculatedPrice += typeDisplayInfo.backupsMonthly;
}
return (
@@ -540,11 +363,4 @@ export class FromBackupsContent extends React.Component {
const styled = withStyles(styles);
-const enhanced = compose(
- styled,
- withSnackbar,
- withLabelGenerator,
- withLinodeActions
-);
-
-export default enhanced(FromBackupsContent);
+export default styled(FromBackupsContent);
diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx
index bc5f83f7954..4a3937362ed 100644
--- a/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx
+++ b/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx
@@ -1,135 +1,79 @@
import { shallow } from 'enzyme';
import * as React from 'react';
-import withLinodeActions from 'src/__data__/withLinodeActions';
-import { FromImageContent } from './FromImageContent';
+import { CombinedProps, FromImageContent } from './FromImageContent';
-const mockProps = {
- images: [],
- regions: [],
- types: [],
- getBackupsMonthlyPrice: jest.fn(),
- history: null,
- getTypeInfo: jest.fn(),
- getRegionInfo: jest.fn(),
- userSSHKeys: [],
- accountBackups: false,
- tagObject: {
- accountTags: [],
- selectedTags: [],
- newTags: [],
- errors: [],
- actions: {
- addTag: jest.fn(),
- createTag: jest.fn(),
- getLinodeTagList: jest.fn()
- }
+const mockProps: CombinedProps = {
+ typeDisplayInfo: undefined,
+ classes: {
+ root: '',
+ main: '',
+ sidebarPrivate: '',
+ sidebarPublic: ''
},
- enqueueSnackbar: jest.fn(),
- onPresentSnackbar: jest.fn(),
- updateCustomLabel: jest.fn(),
- getLabel: jest.fn(),
- customLabel: ''
+ updateImageID: jest.fn(),
+ updateLabel: jest.fn(),
+ updatePassword: jest.fn(),
+ updateRegionID: jest.fn(),
+ updateTags: jest.fn(),
+ updateTypeID: jest.fn(),
+ formIsSubmitting: false,
+ label: '',
+ password: '',
+ backupsEnabled: false,
+ accountBackupsEnabled: false,
+ toggleBackupsEnabled: jest.fn(),
+ togglePrivateIPEnabled: jest.fn(),
+ handleSubmitForm: jest.fn(),
+ privateIPEnabled: false,
+ resetCreationState: jest.fn(),
+ resetSSHKeys: jest.fn(),
+ imagesData: [],
+ regionsData: [],
+ typesData: [],
+ userCannotCreateLinode: false,
+ userSSHKeys: []
};
describe('FromImageContent', () => {
- const componentWithNotice = shallow(
-
- );
-
const component = shallow(
-
- );
-
- const componentWithImages = shallow(
-
+
);
- it('should default to Debian 9 as the selected image', () => {
- expect(componentWithImages.state().selectedImageID).toBe('linode/debian9');
- });
-
- it('should set selectedImageID to null when initial state (from history or default) is not in images', () => {
- expect(component.state().selectedImageID).toBe(null);
- });
-
- it('should render a notice when passed a Notice prop', () => {
- expect(componentWithNotice.find('WithStyles(Notice)')).toHaveLength(1);
- });
-
- it('should not render a notice when no notice prop passed', () => {
- expect(component.find('WithStyles(Notice)')).toHaveLength(0);
- });
-
it('should render SelectImage panel', () => {
expect(
- component.find('WithStyles(WithTheme(WithRenderGuard(CreateFromImage)))')
+ component.find('WithTheme(WithRenderGuard(CreateFromImage))')
).toHaveLength(1);
});
it('should render SelectRegion panel', () => {
expect(
component.find(
- 'WithStyles(WithTheme(WithRenderGuard(SelectRegionPanel)))'
+ 'WithTheme(WithRenderGuard(WithStyles(SelectRegionPanel)))'
)
).toHaveLength(1);
});
it('should render SelectPlan panel', () => {
expect(
- component.find('WithStyles(WithTheme(WithRenderGuard(SelectPlanPanel)))')
+ component.find('WithTheme(WithRenderGuard(WithStyles(SelectPlanPanel)))')
).toHaveLength(1);
});
it('should render SelectLabel panel', () => {
expect(
- component.find('WithStyles(WithTheme(WithRenderGuard(InfoPanel)))')
+ component.find('WithTheme(WithRenderGuard(WithStyles(InfoPanel)))')
).toHaveLength(1);
});
it('should render SelectPassword panel', () => {
expect(
- component.find('WithStyles(WithTheme(WithRenderGuard(AccessPanel)))')
+ component.find('WithTheme(WithRenderGuard(WithStyles(AccessPanel)))')
).toHaveLength(1);
});
it('should render SelectAddOns panel', () => {
expect(
- component.find(
- 'WithStyles(withRouter(WithTheme(WithRenderGuard(AddonsPanel))))'
- )
+ component.find('WithTheme(WithRenderGuard(WithStyles(AddonsPanel)))')
).toHaveLength(1);
});
});
diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx
index e09337dcec6..c3442ca5f2a 100644
--- a/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx
+++ b/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx
@@ -1,51 +1,48 @@
-import { InjectedNotistackProps, withSnackbar } from 'notistack';
-import { find, pathOr } from 'ramda';
+import { pathOr } from 'ramda';
import * as React from 'react';
+import { Link } from 'react-router-dom';
import { Sticky, StickyProps } from 'react-sticky';
-import { compose } from 'recompose';
-import AccessPanel, { Disabled } from 'src/components/AccessPanel';
+import AccessPanel from 'src/components/AccessPanel';
import CheckoutBar from 'src/components/CheckoutBar';
+import Paper from 'src/components/core/Paper';
import {
StyleRulesCallback,
withStyles,
WithStyles
} from 'src/components/core/styles';
+import Typography from 'src/components/core/Typography';
import CreateLinodeDisabled from 'src/components/CreateLinodeDisabled';
import Grid from 'src/components/Grid';
import LabelAndTagsPanel from 'src/components/LabelAndTagsPanel';
import Notice from 'src/components/Notice';
-import SelectRegionPanel, {
- ExtendedRegion
-} from 'src/components/SelectRegionPanel';
-import { Tag } from 'src/components/TagsInput';
-import { resetEventsPolling } from 'src/events';
-import userSSHKeyHoc, {
- State as UserSSHKeyProps
-} from 'src/features/linodes/userSSHKeyHoc';
-import {
- LinodeActionsProps,
- withLinodeActions
-} from 'src/store/linodes/linode.containers';
-import { allocatePrivateIP } from 'src/utilities/allocateIPAddress';
-import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
+import Placeholder from 'src/components/Placeholder';
+import SelectRegionPanel from 'src/components/SelectRegionPanel';
import getAPIErrorsFor from 'src/utilities/getAPIErrorFor';
-import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView';
import AddonsPanel from '../AddonsPanel';
import SelectImagePanel from '../SelectImagePanel';
-import SelectPlanPanel, { ExtendedType } from '../SelectPlanPanel';
-import { Info } from '../util';
-import withLabelGenerator, { LabelProps } from '../withLabelGenerator';
+import SelectPlanPanel from '../SelectPlanPanel';
import { renderBackupsDisplaySection } from './utils';
-const DEFAULT_IMAGE = 'linode/debian9';
-type ClassNames = 'root' | 'main' | 'sidebar';
+import {
+ BaseFormStateAndHandlers,
+ ReduxStatePropsAndSSHKeys,
+ WithDisplayData,
+ WithTypesRegionsAndImages
+} from '../types';
+
+type ClassNames = 'root' | 'main' | 'sidebarPrivate' | 'sidebarPublic';
const styles: StyleRulesCallback = theme => ({
root: {},
main: {},
- sidebar: {
+ sidebarPrivate: {
+ [theme.breakpoints.up('lg')]: {
+ marginTop: '-130px !important'
+ }
+ },
+ sidebarPublic: {
[theme.breakpoints.up('lg')]: {
- marginTop: -130
+ marginTop: '0 !important'
}
}
});
@@ -55,44 +52,11 @@ interface Notice {
level: 'warning' | 'error'; // most likely only going to need these two
}
-interface Props {
- errors?: Linode.ApiFieldError[];
- notice?: Notice;
- images: Linode.Image[];
- regions: ExtendedRegion[];
- types: ExtendedType[];
- getBackupsMonthlyPrice: (selectedTypeID: string | null) => number | null;
- getTypeInfo: (selectedTypeID: string | null) => TypeInfo;
- getRegionInfo: (selectedRegionID: string | null) => Info;
- history: any;
- accountBackups: boolean;
- handleDisablePasswordField: (imageSelected: boolean) => Disabled | undefined;
- disabled?: boolean;
+interface Props extends BaseFormStateAndHandlers {
+ variant?: 'public' | 'private' | 'all';
+ imagePanelTitle?: string;
}
-interface State {
- selectedImageID: string | null;
- selectedRegionID: string | null;
- selectedTypeID: string | null;
- label: string;
- errors?: Linode.ApiFieldError[];
- backups: boolean;
- privateIP: boolean;
- password: string | null;
- isMakingRequest: boolean;
- initTab?: number;
- tags: Tag[];
-}
-
-export type TypeInfo =
- | {
- title: string;
- details: string;
- monthly: number;
- backupsMonthly: number | null;
- }
- | undefined;
-
const errorResources = {
type: 'A plan selection',
region: 'region',
@@ -102,342 +66,222 @@ const errorResources = {
tags: 'Tags'
};
-type CombinedProps = Props &
- LinodeActionsProps &
- UserSSHKeyProps &
- InjectedNotistackProps &
- LabelProps &
- WithStyles;
-
-export class FromImageContent extends React.Component {
- state: State = {
- selectedImageID: pathOr(
- DEFAULT_IMAGE,
- ['history', 'location', 'state', 'selectedImageId'],
- this.props
- ),
- selectedTypeID: null,
- selectedRegionID: null,
- password: '',
- label: '',
- backups: false,
- privateIP: false,
- isMakingRequest: false,
- initTab: pathOr(
- null,
- ['history', 'location', 'state', 'initTab'],
- this.props
- ),
- tags: []
- };
-
- mounted: boolean = false;
-
- handleSelectImage = (id: string) => {
- // Allow for deselecting an image
- id === this.state.selectedImageID
- ? this.setState({ selectedImageID: null })
- : this.setState({ selectedImageID: id });
- };
-
- handleSelectRegion = (id: string) => {
- this.setState({ selectedRegionID: id });
- };
-
- handleSelectPlan = (id: string) => {
- this.setState({ selectedTypeID: id });
- };
-
- handleChangeTags = (selected: Tag[]) => {
- this.setState({ tags: selected });
- };
-
- handleTypePassword = (value: string) => {
- this.setState({ password: value });
- };
-
- handleToggleBackups = () => {
- this.setState({ backups: !this.state.backups });
- };
-
- handleTogglePrivateIP = () => {
- this.setState({ privateIP: !this.state.privateIP });
- };
-
- getImageInfo = (image: Linode.Image | undefined): Info => {
- return (
- image && {
- title: `${image.vendor || image.label}`,
- details: `${image.vendor ? image.label : ''}`
- }
- );
- };
-
- label = () => {
- const { selectedImageID, selectedRegionID } = this.state;
- const { getLabel, images } = this.props;
-
- const selectedImage = images.find(img => img.id === selectedImageID);
-
- // Use 'vendor' if it's a public image, otherwise use label (because 'vendor' will be null)
- const image =
- selectedImage &&
- (selectedImage.is_public ? selectedImage.vendor : selectedImage.label);
-
- return getLabel(image, selectedRegionID);
- };
-
- createNewLinode = () => {
- const {
- history,
- userSSHKeys,
- linodeActions: { createLinode }
- } = this.props;
- const {
- selectedImageID,
- selectedRegionID,
- selectedTypeID,
- password,
- backups,
- privateIP,
- tags
- } = this.state;
-
- this.setState({ isMakingRequest: true });
-
- const label = this.label();
-
- createLinode({
- region: selectedRegionID,
- type: selectedTypeID,
- /* label is optional, pass null instead of empty string to bypass Yup validation. */
- label: label ? label : null,
- root_pass: password /* required if image ID is provided */,
- image: selectedImageID /* optional */,
- backups_enabled: backups /* optional */,
+export type CombinedProps = Props &
+ WithStyles &
+ WithDisplayData &
+ WithTypesRegionsAndImages &
+ ReduxStatePropsAndSSHKeys &
+ BaseFormStateAndHandlers;
+
+export class FromImageContent extends React.PureComponent {
+ /** create the Linode */
+ createLinode = () => {
+ this.props.handleSubmitForm('create', {
+ type: this.props.selectedTypeID,
+ region: this.props.selectedRegionID,
+ image: this.props.selectedImageID,
+ root_pass: this.props.password,
+ tags: this.props.tags
+ ? this.props.tags.map(eachTag => eachTag.label)
+ : [],
+ backups_enabled: this.props.backupsEnabled,
booted: true,
- authorized_users: userSSHKeys
+ label: this.props.label,
+ private_ip: this.props.privateIPEnabled,
+ authorized_users: this.props.userSSHKeys
.filter(u => u.selected)
- .map(u => u.username),
- tags: tags.map((item: Tag) => item.value)
- })
- .then((linode: Linode.Linode) => {
- if (privateIP) {
- allocatePrivateIP(linode.id);
- }
-
- this.props.enqueueSnackbar(`Your Linode ${label} is being created.`, {
- variant: 'success'
- });
-
- resetEventsPolling();
- history.push('/linodes');
- })
- .catch((error: any) => {
- if (!this.mounted) {
- return;
- }
-
- this.setState(
- () => ({
- errors: getAPIErrorOrDefault(error)
- }),
- () => {
- scrollErrorIntoView();
- }
- );
- })
- .finally(() => {
- if (!this.mounted) {
- return;
- }
- // regardless of whether request failed or not, change state and enable the submit btn
- this.setState({ isMakingRequest: false });
- });
+ .map(u => u.username)
+ });
};
- componentWillUnmount() {
- this.mounted = false;
- }
-
- componentDidMount() {
- this.mounted = true;
-
- if (
- !find(image => image.id === this.state.selectedImageID, this.props.images)
- ) {
- this.setState({ selectedImageID: null });
- }
- }
-
render() {
const {
- errors,
- backups,
- privateIP,
- selectedImageID,
- tags,
- selectedRegionID,
- selectedTypeID,
- password,
- isMakingRequest,
- initTab
- } = this.state;
-
- const {
- accountBackups,
+ accountBackupsEnabled,
classes,
- notice,
- types,
- regions,
- images,
- getBackupsMonthlyPrice,
- getRegionInfo,
- getTypeInfo,
- updateCustomLabel,
+ typesData: types,
+ regionsData: regions,
+ imagesData: images,
+ imageDisplayInfo,
+ regionDisplayInfo,
+ typeDisplayInfo,
+ backupsMonthlyPrice,
userSSHKeys,
- disabled
+ userCannotCreateLinode,
+ errors,
+ imagePanelTitle,
+ variant
} = this.props;
const hasErrorFor = getAPIErrorsFor(errorResources, errors);
const generalError = hasErrorFor('none');
- const imageInfo = this.getImageInfo(
- this.props.images.find(image => image.id === selectedImageID)
- );
-
- const regionInfo = getRegionInfo(selectedRegionID);
-
- const typeInfo = getTypeInfo(selectedTypeID);
-
- const hasBackups = backups || accountBackups;
-
- const label = this.label();
+ const hasBackups = this.props.backupsEnabled || accountBackupsEnabled;
+ const privateImages = images.filter(image => !image.is_public);
+
+ if (variant === 'private' && privateImages.length === 0) {
+ return (
+
+
+
+ You don't have any private Images. Visit the{' '}
+ Images section to create an Image
+ from one of your Linode's disks.
+
+ }
+ />
+
+
+ );
+ }
return (
-
- {notice && (
-
- )}
-
+
+
{generalError && }
0 && selectedImageID ? userSSHKeys : []}
+ users={
+ userSSHKeys.length > 0 && this.props.selectedImageID
+ ? userSSHKeys
+ : []
+ }
/>
-
+
{(props: StickyProps) => {
const displaySections = [];
- if (imageInfo) {
- displaySections.push(imageInfo);
+ if (imageDisplayInfo) {
+ displaySections.push(imageDisplayInfo);
}
- if (regionInfo) {
+ if (regionDisplayInfo) {
displaySections.push({
- title: regionInfo.title,
- details: regionInfo.details
+ title: regionDisplayInfo.title,
+ details: regionDisplayInfo.details
});
}
- if (typeInfo) {
- displaySections.push(typeInfo);
+ if (typeDisplayInfo) {
+ displaySections.push(typeDisplayInfo);
}
- if (hasBackups && typeInfo && typeInfo.backupsMonthly) {
+ if (
+ hasBackups &&
+ typeDisplayInfo &&
+ typeDisplayInfo.backupsMonthly
+ ) {
displaySections.push(
renderBackupsDisplaySection(
- accountBackups,
- typeInfo.backupsMonthly
+ accountBackupsEnabled,
+ typeDisplayInfo.backupsMonthly
)
);
}
- let calculatedPrice = pathOr(0, ['monthly'], typeInfo);
- if (hasBackups && typeInfo && typeInfo.backupsMonthly) {
- calculatedPrice += typeInfo.backupsMonthly;
+ let calculatedPrice = pathOr(0, ['monthly'], typeDisplayInfo);
+ if (
+ hasBackups &&
+ typeDisplayInfo &&
+ typeDisplayInfo.backupsMonthly
+ ) {
+ calculatedPrice += typeDisplayInfo.backupsMonthly;
}
return (
@@ -452,12 +296,4 @@ export class FromImageContent extends React.Component {
const styled = withStyles(styles);
-const enhanced = compose(
- styled,
- withSnackbar,
- userSSHKeyHoc,
- withLabelGenerator,
- withLinodeActions
-);
-
-export default enhanced(FromImageContent);
+export default styled(FromImageContent);
diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx
index 497b4f098b1..c31fee7ece4 100644
--- a/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx
+++ b/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx
@@ -3,102 +3,86 @@ import * as React from 'react';
import { linodes } from 'src/__data__/linodes';
-import { FromLinodeContent } from './FromLinodeContent';
+import { CombinedProps, FromLinodeContent } from './FromLinodeContent';
-const mockProps = {
- regions: [],
- types: [],
- getBackupsMonthlyPrice: jest.fn(),
- extendLinodes: jest.fn(),
- linodes,
- getRegionInfo: jest.fn(),
- getTypeInfo: jest.fn(),
- history: null,
- accountBackups: false,
- enqueueSnackbar: jest.fn(),
- onPresentSnackbar: jest.fn(),
- upsertLinode: jest.fn(),
- updateCustomLabel: jest.fn(),
- getLabel: jest.fn(),
- customLabel: ''
+const mockProps: CombinedProps = {
+ typeDisplayInfo: undefined,
+ classes: {
+ root: '',
+ main: '',
+ sidebar: ''
+ },
+ updateDiskSize: jest.fn(),
+ updateImageID: jest.fn(),
+ updateLabel: jest.fn(),
+ updateLinodeID: jest.fn(),
+ updatePassword: jest.fn(),
+ updateRegionID: jest.fn(),
+ updateTags: jest.fn(),
+ updateTypeID: jest.fn(),
+ formIsSubmitting: false,
+ label: '',
+ password: '',
+ backupsEnabled: false,
+ accountBackupsEnabled: false,
+ toggleBackupsEnabled: jest.fn(),
+ togglePrivateIPEnabled: jest.fn(),
+ handleSubmitForm: jest.fn(),
+ privateIPEnabled: false,
+ resetCreationState: jest.fn(),
+ resetSSHKeys: jest.fn(),
+ imagesData: [],
+ linodesData: linodes,
+ regionsData: [],
+ typesData: [],
+ userSSHKeys: [],
+ userCannotCreateLinode: false
};
describe('FromImageContent', () => {
- const componentWithNotice = shallow(
-
- );
-
- const component = shallow(
-
- );
+ const component = shallow();
- const componentWithLinodes = shallow(
-
+ const componentWithoutLinodes = shallow(
+
);
- it('should render a notice when passed a Notice prop', () => {
- expect(componentWithNotice.find('WithStyles(Notice)')).toHaveLength(1);
- });
-
it('should render a Placeholder when linodes prop has no length', () => {
- expect(component.find('WithStyles(Placeholder)')).toHaveLength(1);
- });
-
- it('should not render a notice when no notice prop passed', () => {
- expect(componentWithLinodes.find('WithStyles(Notice)')).toHaveLength(0);
+ expect(
+ componentWithoutLinodes.find('WithStyles(Placeholder)')
+ ).toHaveLength(1);
});
it('should render SelectLinode panel', () => {
expect(
- componentWithLinodes.find(
- 'WithStyles(WithTheme(WithRenderGuard(SelectLinodePanel)))'
+ component.find(
+ 'WithTheme(WithRenderGuard(WithStyles(SelectLinodePanel)))'
)
).toHaveLength(1);
});
it('should render SelectRegion panel', () => {
expect(
- componentWithLinodes.find(
- 'WithStyles(WithTheme(WithRenderGuard(SelectRegionPanel)))'
+ component.find(
+ 'WithTheme(WithRenderGuard(WithStyles(SelectRegionPanel)))'
)
).toHaveLength(1);
});
it('should render SelectPlan panel', () => {
expect(
- componentWithLinodes.find(
- 'WithStyles(WithTheme(WithRenderGuard(SelectPlanPanel)))'
- )
+ component.find('WithTheme(WithRenderGuard(WithStyles(SelectPlanPanel)))')
).toHaveLength(1);
});
it('should render SelectLabel panel', () => {
expect(
- componentWithLinodes.find(
- 'WithStyles(WithTheme(WithRenderGuard(InfoPanel)))'
- )
+ component.find('WithTheme(WithRenderGuard(WithStyles(InfoPanel)))')
).toHaveLength(1);
});
it('should render SelectAddOns panel', () => {
expect(
- componentWithLinodes.find(
- 'WithStyles(withRouter(WithTheme(WithRenderGuard(AddonsPanel))))'
- )
+ component.find('WithTheme(WithRenderGuard(WithStyles(AddonsPanel)))')
).toHaveLength(1);
});
});
diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx
index 26528de1f31..1dd621ec792 100644
--- a/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx
+++ b/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx
@@ -1,11 +1,9 @@
-import { InjectedNotistackProps, withSnackbar } from 'notistack';
import { pathOr } from 'ramda';
import * as React from 'react';
-import { connect } from 'react-redux';
import { Sticky, StickyProps } from 'react-sticky';
-import { compose } from 'recompose';
import VolumeIcon from 'src/assets/addnewmenu/volume.svg';
import CheckoutBar from 'src/components/CheckoutBar';
+import Paper from 'src/components/core/Paper';
import {
StyleRulesCallback,
withStyles,
@@ -14,26 +12,24 @@ import {
import CreateLinodeDisabled from 'src/components/CreateLinodeDisabled';
import Grid from 'src/components/Grid';
import LabelAndTagsPanel from 'src/components/LabelAndTagsPanel';
-import Notice from 'src/components/Notice';
import Placeholder from 'src/components/Placeholder';
-import SelectRegionPanel, {
- ExtendedRegion
-} from 'src/components/SelectRegionPanel';
-import { Tag } from 'src/components/TagsInput';
-import { resetEventsPolling } from 'src/events';
-import { cloneLinode } from 'src/services/linodes';
-import { upsertLinode } from 'src/store/linodes/linodes.actions';
-import { allocatePrivateIP } from 'src/utilities/allocateIPAddress';
-import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
+import SelectRegionPanel from 'src/components/SelectRegionPanel';
+
import getAPIErrorsFor from 'src/utilities/getAPIErrorFor';
-import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView';
import AddonsPanel from '../AddonsPanel';
-import SelectLinodePanel, { ExtendedLinode } from '../SelectLinodePanel';
-import SelectPlanPanel, { ExtendedType } from '../SelectPlanPanel';
-import { Info } from '../util';
-import withLabelGenerator, { LabelProps } from '../withLabelGenerator';
+import SelectLinodePanel from '../SelectLinodePanel';
+import SelectPlanPanel from '../SelectPlanPanel';
import { renderBackupsDisplaySection } from './utils';
+import { extendLinodes } from '../utilities';
+
+import {
+ CloneFormStateHandlers,
+ ReduxStatePropsAndSSHKeys,
+ WithDisplayData,
+ WithLinodesTypesRegionsAndImages
+} from '../types';
+
type ClassNames = 'root' | 'main' | 'sidebar';
const styles: StyleRulesCallback = theme => ({
@@ -41,54 +37,11 @@ const styles: StyleRulesCallback = theme => ({
main: {},
sidebar: {
[theme.breakpoints.up('lg')]: {
- marginTop: -130
+ marginTop: '-130px !important'
}
}
});
-interface Notice {
- text: string;
- level: 'warning' | 'error'; // most likely only going to need these two
-}
-
-export type TypeInfo =
- | {
- title: string;
- details: string;
- monthly: number;
- backupsMonthly: number | null;
- }
- | undefined;
-
-interface State {
- selectedImageID: string | null;
- selectedRegionID: string | null;
- selectedTypeID: string | null;
- selectedLinodeID: number | undefined;
- selectedDiskSize?: number;
- label: string;
- errors?: Linode.ApiFieldError[];
- backups: boolean;
- privateIP: boolean;
- password: string | null;
- isMakingRequest: boolean;
- tags: Tag[];
-}
-
-interface Props {
- notice?: Notice;
- regions: ExtendedRegion[];
- types: ExtendedType[];
- getBackupsMonthlyPrice: (selectedTypeID: string | null) => number | null;
- extendLinodes: (linodes: Linode.Linode[]) => ExtendedLinode[];
- linodes: Linode.Linode[];
- getTypeInfo: (selectedTypeID: string | null) => TypeInfo;
- getRegionInfo: (selectedRegionID: string | null) => Info;
- history: any;
- accountBackups: boolean;
- disabled?: boolean;
-}
-
const errorResources = {
type: 'A plan selection',
region: 'region',
@@ -96,244 +49,132 @@ const errorResources = {
root_pass: 'A root password'
};
-type CombinedProps = Props &
- WithUpsertLinode &
- InjectedNotistackProps &
- LabelProps &
- WithStyles;
-
-export class FromLinodeContent extends React.Component {
- state: State = {
- selectedImageID: null,
- selectedTypeID: null,
- selectedRegionID: null,
- password: '',
- label: '',
- backups: false,
- privateIP: false,
- isMakingRequest: false,
- selectedLinodeID: undefined,
- tags: []
- };
-
- mounted: boolean = false;
-
- handleSelectLinode = (linode: Linode.Linode) => {
- if (linode.id !== this.state.selectedLinodeID) {
- this.setState({
- selectedLinodeID: linode.id,
- selectedTypeID: null,
- selectedDiskSize: linode.specs.disk
- });
+export type CombinedProps = WithStyles &
+ WithDisplayData &
+ CloneFormStateHandlers &
+ WithLinodesTypesRegionsAndImages &
+ ReduxStatePropsAndSSHKeys;
+
+export class FromLinodeContent extends React.PureComponent {
+ /** set the Linode ID and the disk size and reset the plan selection */
+ handleSelectLinode = (linodeID: number) => {
+ const linode = this.props.linodesData.find(
+ eachLinode => eachLinode.id === linodeID
+ );
+ if (linode) {
+ this.props.updateLinodeID(linode.id, linode.specs.disk);
}
};
- handleSelectRegion = (id: string) => {
- this.setState({ selectedRegionID: id });
- };
-
- handleSelectPlan = (id: string) => {
- this.setState({ selectedTypeID: id });
- };
-
- handleChangeTags = (selected: Tag[]) => {
- this.setState({ tags: selected });
- };
-
- handleTypePassword = (value: string) => {
- this.setState({ password: value });
- };
-
- handleToggleBackups = () => {
- this.setState({ backups: !this.state.backups });
- };
-
- handleTogglePrivateIP = () => {
- this.setState({ privateIP: !this.state.privateIP });
- };
-
cloneLinode = () => {
- const { history } = this.props;
- const {
- selectedRegionID,
- selectedTypeID,
- selectedLinodeID,
- backups, // optional
- privateIP,
- tags
- } = this.state;
-
- this.setState({ isMakingRequest: true });
-
- const label = this.label();
-
- cloneLinode(selectedLinodeID!, {
- region: selectedRegionID,
- type: selectedTypeID,
- label: label ? label : null,
- backups_enabled: backups,
- tags: tags.map((item: Tag) => item.value)
- })
- .then(linode => {
- if (privateIP) {
- allocatePrivateIP(linode.id);
- }
- this.props.upsertLinode(linode);
- this.props.enqueueSnackbar(`Your Linode is being cloned.`, {
- variant: 'success'
- });
-
- resetEventsPolling();
- history.push('/linodes');
- })
- .catch(error => {
- if (!this.mounted) {
- return;
- }
-
- this.setState(
- () => ({
- errors: getAPIErrorOrDefault(error)
- }),
- () => {
- scrollErrorIntoView();
- }
- );
- })
- .finally(() => {
- if (!this.mounted) {
- return;
- }
- // regardless of whether request failed or not, change state and enable the submit btn
- this.setState({ isMakingRequest: false });
- });
- };
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- componentDidMount() {
- this.mounted = true;
- }
-
- label = () => {
- const { selectedLinodeID, selectedRegionID } = this.state;
- const { getLabel, linodes } = this.props;
-
- const selectedLinode = linodes.find(l => l.id === selectedLinodeID);
- const linodeLabel = selectedLinode && selectedLinode.label;
-
- return getLabel(linodeLabel, 'clone', selectedRegionID);
+ return this.props.handleSubmitForm(
+ 'clone',
+ {
+ region: this.props.selectedRegionID,
+ type: this.props.selectedTypeID,
+ label: this.props.label,
+ private_ip: this.props.privateIPEnabled,
+ backups_enabled: this.props.backupsEnabled,
+ tags: this.props.tags ? this.props.tags.map(item => item.value) : []
+ },
+ this.props.selectedLinodeID
+ );
};
render() {
const {
+ classes,
errors,
- backups,
- privateIP,
- selectedLinodeID,
- selectedRegionID,
+ accountBackupsEnabled,
+ backupsEnabled,
+ backupsMonthlyPrice,
+ userCannotCreateLinode,
+ linodesData: linodes,
+ imagesData: images,
+ typesData: types,
+ regionsData: regions,
+ regionDisplayInfo: regionInfo,
+ typeDisplayInfo: typeInfo,
selectedTypeID,
+ privateIPEnabled,
+ selectedRegionID,
+ selectedLinodeID,
selectedDiskSize,
- isMakingRequest
- } = this.state;
-
- const {
- accountBackups,
- notice,
- types,
- linodes,
- regions,
- extendLinodes,
- getBackupsMonthlyPrice,
- getTypeInfo,
- getRegionInfo,
- classes,
- updateCustomLabel,
- disabled
+ label
} = this.props;
const hasErrorFor = getAPIErrorsFor(errorResources, errors);
- const generalError = hasErrorFor('none');
-
- const regionInfo = getRegionInfo(selectedRegionID);
- const typeInfo = getTypeInfo(selectedTypeID);
-
- const hasBackups = backups || accountBackups;
-
- const label = this.label();
+ const hasBackups = backupsEnabled || accountBackupsEnabled;
return (
{linodes && linodes.length === 0 ? (
-
-
+
+
+
+
) : (
-
-
- {notice && !disabled && (
-
- )}
- {generalError && }
+
+
@@ -354,7 +195,7 @@ export class FromLinodeContent extends React.Component {
if (hasBackups && typeInfo && typeInfo.backupsMonthly) {
displaySections.push(
renderBackupsDisplaySection(
- accountBackups,
+ accountBackupsEnabled,
typeInfo.backupsMonthly
)
);
@@ -369,8 +210,10 @@ export class FromLinodeContent extends React.Component {
{
);
}
}
-interface WithUpsertLinode {
- upsertLinode: (l: Linode.Linode) => void;
-}
-
-const WithUpsertLinode = connect(
- undefined,
- { upsertLinode }
-);
const styled = withStyles(styles);
-const enhanced = compose(
- WithUpsertLinode,
- styled,
- withSnackbar,
- withLabelGenerator
-);
-
-export default enhanced(FromLinodeContent);
+export default styled(FromLinodeContent);
diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx
index ad196670c7e..44db9f9b49a 100644
--- a/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx
+++ b/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx
@@ -1,83 +1,59 @@
import { shallow } from 'enzyme';
import * as React from 'react';
+import { images as mockImages } from 'src/__data__/images';
import { UserDefinedFields as mockUserDefinedFields } from 'src/__data__/UserDefinedFields';
-import withLinodeActions from 'src/__data__/withLinodeActions';
-import { FromStackScriptContent } from './FromStackScriptContent';
+import {
+ CombinedProps,
+ FromStackScriptContent
+} from './FromStackScriptContent';
-const mockProps = {
- images: [],
- regions: [],
- types: [],
- getBackupsMonthlyPrice: jest.fn(),
- getRegionInfo: jest.fn(),
- getTypeInfo: jest.fn(),
- history: null,
- userSSHKeys: [],
- accountBackups: true,
- tagObject: {
- accountTags: [],
- selectedTags: [],
- newTags: [],
- errors: [],
- actions: {
- addTag: jest.fn(),
- createTag: jest.fn(),
- getLinodeTagList: jest.fn()
- }
+const mockProps: CombinedProps = {
+ typeDisplayInfo: undefined,
+ classes: {
+ main: '',
+ sidebar: '',
+ emptyImagePanel: '',
+ emptyImagePanelText: ''
},
- updateCustomLabel: jest.fn(),
- getLabel: jest.fn(),
- linodes: [],
- customLabel: ''
+ updateImageID: jest.fn(),
+ updateLabel: jest.fn(),
+ updatePassword: jest.fn(),
+ updateRegionID: jest.fn(),
+ updateTags: jest.fn(),
+ updateTypeID: jest.fn(),
+ formIsSubmitting: false,
+ label: '',
+ password: '',
+ backupsEnabled: false,
+ accountBackupsEnabled: false,
+ toggleBackupsEnabled: jest.fn(),
+ togglePrivateIPEnabled: jest.fn(),
+ handleSubmitForm: jest.fn(),
+ privateIPEnabled: false,
+ resetCreationState: jest.fn(),
+ resetSSHKeys: jest.fn(),
+ imagesData: [],
+ regionsData: [],
+ typesData: [],
+ userCannotCreateLinode: false,
+ userSSHKeys: [],
+ request: jest.fn(),
+ header: '',
+ updateStackScript: jest.fn(),
+ handleSelectUDFs: jest.fn()
};
describe('FromImageContent', () => {
- const componentWithNotice = shallow(
-
- );
+ const component = shallow();
- const component = shallow(
+ const componentWithUDFs = shallow(
);
- it('should render a notice when passed a Notice prop', () => {
- expect(componentWithNotice.find('WithStyles(Notice)')).toHaveLength(1);
- });
-
- it('should not render a notice when no notice prop passed', () => {
- expect(component.find('WithStyles(Notice)')).toHaveLength(0);
- });
-
it('should render SelectStackScript panel', () => {
expect(
component.find(
@@ -87,69 +63,62 @@ describe('FromImageContent', () => {
});
it('should render UserDefinedFields panel', () => {
- component.setState({ userDefinedFields: mockUserDefinedFields }); // give us some dummy fields
expect(
- component.find(
- 'WithStyles(WithTheme(WithRenderGuard(UserDefinedFieldsPanel)))'
+ componentWithUDFs.find(
+ 'WithTheme(WithRenderGuard(WithStyles(UserDefinedFieldsPanel)))'
)
).toHaveLength(1);
});
it('should not render UserDefinedFields panel if no UDFs', () => {
- component.setState({ userDefinedFields: [] }); // give us some dummy fields
expect(
component.find(
- 'WithStyles(WithTheme(WithRenderGuard(UserDefinedFieldsPanel)))'
+ 'WithTheme(WithRenderGuard(WithStyles(UserDefinedFieldsPanel)))'
)
).toHaveLength(0);
});
- it('should render SelectImage panel if no compatibleImages', () => {
+ it('should not render SelectImage panel if no compatibleImages', () => {
expect(
component.find('WithTheme(WithRenderGuard(WithStyles(CreateFromImage)))')
).toHaveLength(0);
});
- it('should render SelectImage panel if no compatibleImages', () => {
- component.setState({
- compatibleImages: [{ label: 'linode/centos7', is_public: true }]
- });
+ it('should render SelectImage panel there are compatibleImages', () => {
expect(
- component.find('WithStyles(WithTheme(WithRenderGuard(CreateFromImage)))')
+ componentWithUDFs.find('WithTheme(WithRenderGuard(CreateFromImage))')
).toHaveLength(1);
});
it('should render SelectRegion panel', () => {
expect(
component.find(
- 'WithStyles(WithTheme(WithRenderGuard(SelectRegionPanel)))'
+ 'WithTheme(WithRenderGuard(WithStyles(SelectRegionPanel)))'
)
).toHaveLength(1);
});
it('should render SelectPlan panel', () => {
expect(
- component.find('WithStyles(WithTheme(WithRenderGuard(SelectPlanPanel)))')
+ component.find('WithTheme(WithRenderGuard(WithStyles(SelectPlanPanel)))')
).toHaveLength(1);
});
it('should render SelectLabel panel', () => {
expect(
- component.find('WithStyles(WithTheme(WithRenderGuard(InfoPanel)))')
+ component.find('WithTheme(WithRenderGuard(WithStyles(InfoPanel)))')
).toHaveLength(1);
});
it('should render SelectPassword panel', () => {
expect(
- component.find('WithStyles(WithTheme(WithRenderGuard(AccessPanel)))')
+ component.find('WithTheme(WithRenderGuard(WithStyles(AccessPanel)))')
).toHaveLength(1);
});
it('should render SelectAddOns panel', () => {
expect(
- component.find(
- 'WithStyles(withRouter(WithTheme(WithRenderGuard(AddonsPanel))))'
- )
+ component.find('WithTheme(WithRenderGuard(WithStyles(AddonsPanel)))')
).toHaveLength(1);
});
});
diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx
index 45a52cda7b9..05beaefe2a9 100644
--- a/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx
+++ b/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx
@@ -1,9 +1,7 @@
-import { InjectedNotistackProps, withSnackbar } from 'notistack';
import { assocPath, pathOr } from 'ramda';
import * as React from 'react';
import { Sticky, StickyProps } from 'react-sticky';
-import { compose } from 'recompose';
-import AccessPanel, { Disabled } from 'src/components/AccessPanel';
+import AccessPanel from 'src/components/AccessPanel';
import CheckoutBar from 'src/components/CheckoutBar';
import Paper from 'src/components/core/Paper';
import {
@@ -16,34 +14,27 @@ import CreateLinodeDisabled from 'src/components/CreateLinodeDisabled';
import Grid from 'src/components/Grid';
import LabelAndTagsPanel from 'src/components/LabelAndTagsPanel';
import Notice from 'src/components/Notice';
-import SelectRegionPanel, {
- ExtendedRegion
-} from 'src/components/SelectRegionPanel';
+import SelectRegionPanel from 'src/components/SelectRegionPanel';
import { Tag } from 'src/components/TagsInput';
-import { resetEventsPolling } from 'src/events';
-import userSSHKeyHoc, {
- UserSSHKeyProps
-} from 'src/features/linodes/userSSHKeyHoc';
-import SelectStackScriptPanel from 'src/features/StackScripts/SelectStackScriptPanel';
+import SelectStackScriptPanel from 'src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptPanel';
import StackScriptDrawer from 'src/features/StackScripts/StackScriptDrawer';
import UserDefinedFieldsPanel from 'src/features/StackScripts/UserDefinedFieldsPanel';
-import {
- LinodeActionsProps,
- withLinodeActions
-} from 'src/store/linodes/linode.containers';
-import { allocatePrivateIP } from 'src/utilities/allocateIPAddress';
-import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
import getAPIErrorsFor from 'src/utilities/getAPIErrorFor';
-import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView';
import AddonsPanel from '../AddonsPanel';
import SelectImagePanel from '../SelectImagePanel';
-import SelectPlanPanel, { ExtendedType } from '../SelectPlanPanel';
-import { Info } from '../util';
-import withLabelGenerator, { LabelProps } from '../withLabelGenerator';
+import SelectPlanPanel from '../SelectPlanPanel';
+
+import { filterPublicImages, filterUDFErrors } from './formUtilities';
import { renderBackupsDisplaySection } from './utils';
+import {
+ ReduxStatePropsAndSSHKeys,
+ StackScriptFormStateHandlers,
+ WithDisplayData,
+ WithTypesRegionsAndImages
+} from '../types';
+
type ClassNames =
- | 'root'
| 'main'
| 'sidebar'
| 'emptyImagePanel'
@@ -54,7 +45,7 @@ const styles: StyleRulesCallback = theme => ({
main: {},
sidebar: {
[theme.breakpoints.up('lg')]: {
- marginTop: -130
+ marginTop: '-130px !important'
}
},
emptyImagePanel: {
@@ -66,55 +57,14 @@ const styles: StyleRulesCallback = theme => ({
}
});
-interface Notice {
- text: string;
- level: 'warning' | 'error'; // most likely only going to need these two
-}
-
-export type TypeInfo =
- | {
- title: string;
- details: string;
- monthly: number;
- backupsMonthly: number | null;
- }
- | undefined;
-
interface Props {
- notice?: Notice;
- images: Linode.Image[];
- regions: ExtendedRegion[];
- types: ExtendedType[];
- getBackupsMonthlyPrice: (selectedTypeID: string | null) => number | null;
- getTypeInfo: (selectedTypeID: string | null) => TypeInfo;
- getRegionInfo: (selectedRegionID: string | null) => Info;
- history: any;
- selectedTabFromQuery?: string;
- selectedStackScriptFromQuery?: number;
- accountBackups: boolean;
- disabled?: boolean;
-
- /** Comes from HOC */
- handleDisablePasswordField: (imageSelected: boolean) => Disabled | undefined;
-}
-
-interface State {
- userDefinedFields: Linode.StackScript.UserDefinedField[];
- udf_data: any;
- errors?: Linode.ApiFieldError[];
- selectedStackScriptID: number | undefined;
- selectedStackScriptLabel: string;
- selectedStackScriptUsername: string;
- selectedImageID: string | null;
- selectedRegionID: string | null;
- selectedTypeID: string | null;
- backups: boolean;
- privateIP: boolean;
- label: string | null;
- password: string | null;
- isMakingRequest: boolean;
- compatibleImages: Linode.Image[];
- tags: Tag[];
+ request: (
+ username: string,
+ params?: any,
+ filter?: any,
+ stackScriptGrants?: Linode.Grant[]
+ ) => Promise>;
+ header: string;
}
const errorResources = {
@@ -123,40 +73,18 @@ const errorResources = {
label: 'A label',
root_pass: 'A root password',
image: 'image',
- tags: 'Tags'
+ tags: 'Tags',
+ stackscript_id: 'A StackScript'
};
-type CombinedProps = Props &
- LinodeActionsProps &
- InjectedNotistackProps &
- LabelProps &
- UserSSHKeyProps &
+export type CombinedProps = Props &
+ StackScriptFormStateHandlers &
+ ReduxStatePropsAndSSHKeys &
+ WithTypesRegionsAndImages &
+ WithDisplayData &
WithStyles;
-export class FromStackScriptContent extends React.Component<
- CombinedProps,
- State
-> {
- state: State = {
- userDefinedFields: [],
- udf_data: null,
- selectedStackScriptID: this.props.selectedStackScriptFromQuery || undefined,
- selectedStackScriptLabel: '',
- selectedStackScriptUsername: this.props.selectedTabFromQuery || '',
- selectedImageID: null,
- selectedRegionID: null,
- selectedTypeID: null,
- backups: false,
- privateIP: false,
- label: '',
- password: '',
- isMakingRequest: false,
- compatibleImages: [],
- tags: []
- };
-
- mounted: boolean = false;
-
+export class FromStackScriptContent extends React.PureComponent {
handleSelectStackScript = (
id: number,
label: string,
@@ -164,320 +92,159 @@ export class FromStackScriptContent extends React.Component<
stackScriptImages: string[],
userDefinedFields: Linode.StackScript.UserDefinedField[]
) => {
- const { images } = this.props;
- const filteredImages = images.filter(image => {
- for (const stackScriptImage of stackScriptImages) {
- if (image.id === stackScriptImage) {
- return true;
- }
- }
- return false;
+ /**
+ * based on the list of images we get back from the API, compare those
+ * to our list of master images supported by Linode and filter out the ones
+ * that aren't compatible with our selected StackScript
+ */
+ const compatibleImages = this.props.imagesData.filter(eachImage => {
+ return stackScriptImages.some(
+ eachSSImage => eachSSImage === eachImage.id
+ );
});
- const defaultUDFData = {};
- userDefinedFields.forEach(eachField => {
- if (!!eachField.default) {
- defaultUDFData[eachField.name] = eachField.default;
+ /**
+ * if a UDF field comes back from the API with a "default"
+ * value, it means we need to pre-populate the field and form state
+ */
+ const defaultUDFData = userDefinedFields.reduce((accum, eachField) => {
+ if (eachField.default) {
+ accum[eachField.name] = eachField.default;
}
- });
- // first need to make a request to get the stackscript
- // then update userDefinedFields to the fields returned
- this.setState({
- selectedStackScriptID: id,
- selectedStackScriptUsername: username,
- selectedStackScriptLabel: label,
- compatibleImages: filteredImages,
- userDefinedFields,
- udf_data: defaultUDFData
- // prob gonna need to update UDF here too
- });
- };
+ return accum;
+ }, {});
- resetStackScriptSelection = () => {
- // reset stackscript selection to unselected
- if (!this.mounted) {
- return;
- }
- this.setState({
- selectedStackScriptID: undefined,
- selectedStackScriptLabel: '',
- selectedStackScriptUsername: '',
- udf_data: null,
- userDefinedFields: [],
- compatibleImages: [],
- selectedImageID: null // stackscripts don't support all images, so we need to reset it
- });
+ this.props.updateStackScript(
+ id,
+ label,
+ username,
+ userDefinedFields,
+ compatibleImages,
+ defaultUDFData
+ );
};
handleChangeUDF = (key: string, value: string) => {
// either overwrite or create new selection
- const newUDFData = assocPath([key], value, this.state.udf_data);
-
- this.setState({
- udf_data: { ...this.state.udf_data, ...newUDFData }
- });
- };
-
- handleSelectImage = (id: string) => {
- this.setState({ selectedImageID: id });
- };
-
- handleSelectRegion = (id: string) => {
- this.setState({ selectedRegionID: id });
- };
-
- handleSelectPlan = (id: string) => {
- this.setState({ selectedTypeID: id });
- };
-
- handleChangeTags = (selected: Tag[]) => {
- this.setState({ tags: selected });
- };
-
- handleTypePassword = (value: string) => {
- this.setState({ password: value });
- };
-
- handleToggleBackups = () => {
- this.setState({ backups: !this.state.backups });
- };
-
- handleTogglePrivateIP = () => {
- this.setState({ privateIP: !this.state.privateIP });
- };
+ const newUDFData = assocPath([key], value, this.props.selectedUDFs);
- getImageInfo = (image: Linode.Image | undefined): Info => {
- return (
- image && {
- title: `${image.vendor || image.label}`,
- details: `${image.vendor ? image.label : ''}`
- }
- );
+ this.props.handleSelectUDFs({ ...this.props.selectedUDFs, ...newUDFData });
};
- createFromStackScript = () => {
- if (!this.state.selectedStackScriptID) {
- this.setState(
- {
- errors: [
- { field: 'stackscript_id', reason: 'You must select a StackScript' }
- ]
- },
- () => {
- scrollErrorIntoView();
- }
- );
- return;
- }
- this.createLinode();
- };
-
- createLinode = () => {
+ handleCreateLinode = () => {
const {
- history,
+ backupsEnabled,
+ password,
+ privateIPEnabled,
userSSHKeys,
- linodeActions: { createLinode }
- } = this.props;
- const {
+ handleSubmitForm,
selectedImageID,
selectedRegionID,
- selectedTypeID,
selectedStackScriptID,
- udf_data,
- password,
- backups,
- privateIP,
+ selectedTypeID,
+ selectedUDFs,
tags
- } = this.state;
-
- this.setState({ isMakingRequest: true });
-
- const label = this.label();
+ } = this.props;
- createLinode({
+ handleSubmitForm('createFromStackScript', {
region: selectedRegionID,
type: selectedTypeID,
stackscript_id: selectedStackScriptID,
- stackscript_data: udf_data,
- label: label ? label : null /* optional */,
+ stackscript_data: selectedUDFs,
+ label: this.props.label /* optional */,
root_pass: password /* required if image ID is provided */,
image: selectedImageID /* optional */,
- backups_enabled: backups /* optional */,
+ backups_enabled: backupsEnabled /* optional */,
booted: true,
+ private_ip: privateIPEnabled,
authorized_users: userSSHKeys
.filter(u => u.selected)
.map(u => u.username),
- tags: tags.map((item: Tag) => item.value)
- })
- .then(linode => {
- if (privateIP) {
- allocatePrivateIP(linode.id);
- }
-
- this.props.enqueueSnackbar(`Your Linode ${label} is being created.`, {
- variant: 'success'
- });
-
- resetEventsPolling();
- history.push('/linodes');
- })
- .catch(error => {
- if (!this.mounted) {
- return;
- }
- this.setState(
- () => ({
- errors: getAPIErrorOrDefault(error)
- }),
- () => {
- scrollErrorIntoView();
- }
- );
- })
- .finally(() => {
- if (!this.mounted) {
- return;
- }
- // regardless of whether request failed or not, change state and enable the submit btn
- this.setState({ isMakingRequest: false });
- });
- };
-
- componentDidMount() {
- this.mounted = true;
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- filterPublicImages = (images: Linode.Image[]) => {
- return images.filter((image: Linode.Image) => image.is_public);
- };
-
- label = () => {
- const {
- selectedStackScriptLabel,
- selectedImageID,
- selectedRegionID
- } = this.state;
- const { getLabel, images } = this.props;
-
- const selectedImage = images.find(img => img.id === selectedImageID);
-
- const image = selectedImage && selectedImage.vendor;
-
- return getLabel(selectedStackScriptLabel, image, selectedRegionID);
+ tags: tags ? tags.map((item: Tag) => item.value) : []
+ });
};
render() {
const {
+ accountBackupsEnabled,
errors,
- userDefinedFields,
- udf_data,
+ backupsMonthlyPrice,
+ regionsData,
+ typesData,
+ classes,
+ imageDisplayInfo,
+ regionDisplayInfo,
selectedImageID,
selectedRegionID,
selectedStackScriptID,
selectedTypeID,
- backups,
- privateIP,
+ typeDisplayInfo,
+ privateIPEnabled,
tags,
+ backupsEnabled,
password,
- isMakingRequest,
- compatibleImages,
- selectedStackScriptLabel,
- selectedStackScriptUsername
- } = this.state;
-
- const {
- accountBackups,
- notice,
- getBackupsMonthlyPrice,
- regions,
- types,
- classes,
- getRegionInfo,
- getTypeInfo,
- images,
+ imagesData,
userSSHKeys,
- updateCustomLabel,
- disabled
+ userCannotCreateLinode: disabled,
+ selectedStackScriptUsername,
+ selectedStackScriptLabel,
+ label,
+ request,
+ header,
+ toggleBackupsEnabled,
+ togglePrivateIPEnabled,
+ updateImageID,
+ updatePassword,
+ updateRegionID,
+ updateTags,
+ updateTypeID,
+ availableUserDefinedFields: userDefinedFields,
+ availableStackScriptImages: compatibleImages,
+ selectedUDFs: udf_data
} = this.props;
const hasErrorFor = getAPIErrorsFor(errorResources, errors);
const generalError = hasErrorFor('none');
- const hasBackups = Boolean(backups || accountBackups);
-
- const label = this.label();
-
- /*
- * errors with UDFs have dynamic keys
- * for example, if there are UDFs that aren't filled out, you can can
- * errors that look something like this
- * { field: 'wordpress_pass', reason: 'you must fill out a WP password' }
- * Because of this, we need to both make each error doesn't match any
- * that are in our errorResources map and that it has a 'field' key in the first
- * place. Then, we can confirm we are indeed looking at a UDF error
- */
- const udfErrors = errors
- ? errors.filter(error => {
- // ensure the error isn't a root_pass, image, region, type, label
- const isNotUDFError = Object.keys(errorResources).some(errorKey => {
- return errorKey === error.field;
- });
- // if the 'field' prop exists and isn't any other error
- return !!error.field && !isNotUDFError;
- })
- : undefined;
-
- const regionInfo = getRegionInfo(selectedRegionID);
- const typeInfo = getTypeInfo(selectedTypeID);
- const imageInfo = this.getImageInfo(
- images.find(image => image.id === selectedImageID)
- );
+ const hasBackups = Boolean(backupsEnabled || accountBackupsEnabled);
return (
-
+
- {!disabled && notice && (
-
- )}
{generalError && }
null}
disabled={disabled}
+ request={request}
+ category={'apps'}
/>
{!disabled && userDefinedFields && userDefinedFields.length > 0 && (
)}
{!disabled && compatibleImages && compatibleImages.length > 0 ? (
) : (
@@ -499,18 +266,18 @@ export class FromStackScriptContent extends React.Component<
)}
@@ -518,45 +285,40 @@ export class FromStackScriptContent extends React.Component<
labelFieldProps={{
label: 'Linode Label',
value: label || '',
- onChange: updateCustomLabel,
+ onChange: this.props.updateLabel,
errorText: hasErrorFor('label'),
disabled
}}
tagsInputProps={{
- value: tags,
- onChange: this.handleChangeTags,
+ value: tags || [],
+ onChange: updateTags,
tagError: hasErrorFor('tags'),
disabled
}}
- updateFor={[tags, label, errors, classes]}
+ updateFor={[tags, label, errors]}
/>
0 && selectedImageID ? userSSHKeys : []}
/>
@@ -574,42 +336,42 @@ export class FromStackScriptContent extends React.Component<
});
}
- if (imageInfo) {
- displaySections.push(imageInfo);
+ if (imageDisplayInfo) {
+ displaySections.push(imageDisplayInfo);
}
- if (regionInfo) {
+ if (regionDisplayInfo) {
displaySections.push({
- title: regionInfo.title,
- details: regionInfo.details
+ title: regionDisplayInfo.title,
+ details: regionDisplayInfo.details
});
}
- if (typeInfo) {
- displaySections.push(typeInfo);
+ if (typeDisplayInfo) {
+ displaySections.push(typeDisplayInfo);
}
- if (hasBackups && typeInfo && typeInfo.backupsMonthly) {
+ if (hasBackups && typeDisplayInfo && backupsMonthlyPrice) {
displaySections.push(
renderBackupsDisplaySection(
- accountBackups,
- typeInfo.backupsMonthly
+ accountBackupsEnabled,
+ backupsMonthlyPrice
)
);
}
- let calculatedPrice = pathOr(0, ['monthly'], typeInfo);
- if (hasBackups && typeInfo && typeInfo.backupsMonthly) {
- calculatedPrice += typeInfo.backupsMonthly;
+ let calculatedPrice = pathOr(0, ['monthly'], typeDisplayInfo);
+ if (hasBackups && typeDisplayInfo && backupsMonthlyPrice) {
+ calculatedPrice += backupsMonthlyPrice;
}
return (
@@ -625,12 +387,4 @@ export class FromStackScriptContent extends React.Component<
const styled = withStyles(styles);
-const enhanced = compose(
- styled,
- withSnackbar,
- userSSHKeyHoc,
- withLabelGenerator,
- withLinodeActions
-);
-
-export default enhanced(FromStackScriptContent) as any;
+export default styled(FromStackScriptContent);
diff --git a/src/features/linodes/LinodesCreate/TabbedContent/formUtilities.test.ts b/src/features/linodes/LinodesCreate/TabbedContent/formUtilities.test.ts
new file mode 100644
index 00000000000..135fadec491
--- /dev/null
+++ b/src/features/linodes/LinodesCreate/TabbedContent/formUtilities.test.ts
@@ -0,0 +1,37 @@
+import { images, privateImages } from 'src/__data__/images';
+import { filterPublicImages, filterUDFErrors } from './formUtilities';
+
+describe('Linode Create Utilities', () => {
+ it('should filter out public Images', () => {
+ const filteredImages = filterPublicImages([...images, ...privateImages]);
+ expect(
+ filteredImages.every(eachImage => !!eachImage.is_public)
+ ).toBeTruthy();
+ });
+
+ it('should filter out all errors except UDF errors', () => {
+ const mockErrors: Linode.ApiFieldError[] = [
+ {
+ field: 'label',
+ reason: 'label is required'
+ },
+ {
+ field: 'ssh_keys',
+ reason: 'ssh_keys are required'
+ },
+ {
+ field: 'wp_password',
+ reason: 'a value for the UDF is required'
+ }
+ ];
+
+ const errorResources = {
+ label: 'A label',
+ ssh_keys: 'ssh_keys'
+ };
+
+ const filteredErrors = filterUDFErrors(errorResources, mockErrors);
+ expect(filteredErrors[0].field).toBe('wp_password');
+ expect(filteredErrors).toHaveLength(1);
+ });
+});
diff --git a/src/features/linodes/LinodesCreate/TabbedContent/formUtilities.ts b/src/features/linodes/LinodesCreate/TabbedContent/formUtilities.ts
new file mode 100644
index 00000000000..61578f47cd3
--- /dev/null
+++ b/src/features/linodes/LinodesCreate/TabbedContent/formUtilities.ts
@@ -0,0 +1,29 @@
+/**
+ * @returns { Linode.Image[] } - a list of public images AKA
+ * images that are officially supported by Linode
+ *
+ * @todo test this
+ */
+export const filterPublicImages = (images: Linode.Image[]) => {
+ return images.filter((image: Linode.Image) => image.is_public);
+};
+
+/**
+ * filter out all the API errors that aren't UDF errors from our error state.
+ * To do this, we compare the keys from the error state to our "errorResources"
+ * map and return all the errors that don't match the keys in that object
+ *
+ * @todo test this function
+ */
+export const filterUDFErrors = (
+ errorResources: any,
+ errors?: Linode.ApiFieldError[]
+) => {
+ return !errors
+ ? []
+ : errors.filter(eachError => {
+ return !Object.keys(errorResources).some(
+ eachKey => eachKey === eachError.field
+ );
+ });
+};
diff --git a/src/features/linodes/LinodesCreate/index.ts b/src/features/linodes/LinodesCreate/index.ts
index 7c0a70b5b45..876be63f53d 100644
--- a/src/features/linodes/LinodesCreate/index.ts
+++ b/src/features/linodes/LinodesCreate/index.ts
@@ -1,2 +1,2 @@
-import LinodesCreate from './LinodesCreate';
+import LinodesCreate from './LinodeCreateContainer';
export default LinodesCreate;
diff --git a/src/features/linodes/LinodesCreate/types.ts b/src/features/linodes/LinodesCreate/types.ts
new file mode 100644
index 00000000000..23cb8a25fba
--- /dev/null
+++ b/src/features/linodes/LinodesCreate/types.ts
@@ -0,0 +1,180 @@
+import { ExtendedRegion } from 'src/components/SelectRegionPanel';
+import { Tag } from 'src/components/TagsInput';
+import { State as userSSHKeysProps } from 'src/features/linodes/userSSHKeyHoc';
+import { CloudApp } from 'src/services/cloud_apps';
+import { CreateLinodeRequest } from 'src/services/linodes';
+import { ExtendedType } from './SelectPlanPanel';
+
+export interface ExtendedLinode extends Linode.Linode {
+ heading: string;
+ subHeadings: string[];
+}
+
+export type TypeInfo =
+ | {
+ title: string;
+ details: string;
+ monthly: number;
+ backupsMonthly: number | null;
+ }
+ | undefined;
+
+export type Info = { title: string; details?: string } | undefined;
+
+/**
+ * These props are meant purely for what is displayed in the
+ * Checkout bar
+ */
+export interface WithDisplayData {
+ typeDisplayInfo?: TypeInfo;
+ regionDisplayInfo?: Info;
+ imageDisplayInfo?: Info;
+ backupsMonthlyPrice?: number | null;
+}
+
+/**
+ * Redux Props including the error and loading states.
+ * These props are meant for the parent component that is doing
+ * the null, loading and error checking
+ */
+export interface WithImagesProps {
+ imagesData?: Linode.Image[];
+ imagesLoading: boolean;
+ imagesError?: string;
+}
+
+export interface WithLinodesProps {
+ linodesData?: Linode.Linode[];
+ linodesLoading: boolean;
+ linodesError?: Linode.ApiFieldError[];
+}
+
+export interface WithRegionsProps {
+ regionsData?: ExtendedRegion[];
+ regionsLoading: boolean;
+ regionsError?: Linode.ApiFieldError[];
+}
+
+export interface WithTypesProps {
+ typesData?: ExtendedType[];
+ typesLoading: boolean;
+ typesError?: Linode.ApiFieldError[];
+}
+
+/**
+ * Pure Data without the loading and error
+ * keys. Component with these props have already been
+ * safe-guarded with null, loading, and error checking
+ */
+export interface WithTypesRegionsAndImages {
+ regionsData: ExtendedRegion[];
+ typesData: ExtendedType[];
+ imagesData: Linode.Image[];
+}
+
+export interface WithLinodesTypesRegionsAndImages
+ extends WithTypesRegionsAndImages {
+ linodesData: Linode.Linode[];
+}
+
+export interface ReduxStateProps {
+ accountBackupsEnabled: boolean;
+ userCannotCreateLinode: boolean;
+}
+
+export type HandleSubmit = (
+ type: 'create' | 'clone' | 'createFromStackScript' | 'createFromBackup',
+ payload: CreateLinodeRequest,
+ linodeID?: number
+) => void;
+
+/**
+ * minimum number of state and handlers needed for
+ * the _create from image_ flow to function
+ */
+export interface BaseFormStateAndHandlers {
+ errors?: Linode.ApiFieldError[];
+ formIsSubmitting: boolean;
+ handleSubmitForm: HandleSubmit;
+ selectedImageID?: string;
+ updateImageID: (id: string) => void;
+ selectedRegionID?: string;
+ updateRegionID: (id: string) => void;
+ selectedTypeID?: string;
+ updateTypeID: (id: string) => void;
+ label: string;
+ updateLabel: (
+ event: React.ChangeEvent<
+ HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
+ >
+ ) => void;
+ password: string;
+ updatePassword: (password: string) => void;
+ backupsEnabled: boolean;
+ toggleBackupsEnabled: () => void;
+ privateIPEnabled: boolean;
+ togglePrivateIPEnabled: () => void;
+ tags?: Tag[];
+ updateTags: (tags: Tag[]) => void;
+ resetCreationState: () => void;
+ resetSSHKeys: () => void;
+}
+
+/**
+ * additional form fields needed when creating a Linode from a Linode
+ * AKA cloning a Linode
+ */
+export interface CloneFormStateHandlers extends BaseFormStateAndHandlers {
+ selectedDiskSize?: number;
+ updateDiskSize: (id: number) => void;
+ selectedLinodeID?: number;
+ updateLinodeID: (id: number, diskSize?: number) => void;
+}
+
+/**
+ * additional form fields needed when creating a Linode from a StackScript
+ */
+export interface StackScriptFormStateHandlers extends BaseFormStateAndHandlers {
+ selectedStackScriptID?: number;
+ selectedStackScriptUsername?: string;
+ selectedStackScriptLabel?: string;
+ availableUserDefinedFields?: Linode.StackScript.UserDefinedField[];
+ availableStackScriptImages?: Linode.Image[];
+ updateStackScript: (
+ id: number,
+ label: string,
+ username: string,
+ userDefinedFields: Linode.StackScript.UserDefinedField[],
+ availableImages: Linode.Image[],
+ defaultData?: any
+ ) => void;
+ selectedUDFs?: any;
+ handleSelectUDFs: (stackScripts: any[]) => void;
+}
+
+/**
+ * additional form fields needed when create a Linode from a backup
+ * Note that it extends the _Clone_ props because creating from a backup
+ * requires the Linodes data
+ */
+export interface BackupFormStateHandlers extends CloneFormStateHandlers {
+ selectedBackupID?: number;
+ setBackupID: (id: number) => void;
+}
+
+export interface AppsData {
+ appInstances?: CloudApp[];
+ appInstancesLoading: boolean;
+ appInstancesError?: string;
+}
+
+export type AllFormStateAndHandlers = BaseFormStateAndHandlers &
+ CloneFormStateHandlers &
+ StackScriptFormStateHandlers &
+ BackupFormStateHandlers;
+
+/**
+ * Additional props that don't have a logic place to live under but still
+ * need to be passed down to the children
+ */
+export type ReduxStatePropsAndSSHKeys = ReduxStateProps & userSSHKeysProps;
diff --git a/src/features/linodes/LinodesCreate/util.ts b/src/features/linodes/LinodesCreate/util.ts
deleted file mode 100644
index c2bfb86c84e..00000000000
--- a/src/features/linodes/LinodesCreate/util.ts
+++ /dev/null
@@ -1 +0,0 @@
-export type Info = { title: string; details?: string } | undefined;
diff --git a/src/features/linodes/LinodesCreate/utilities.ts b/src/features/linodes/LinodesCreate/utilities.ts
new file mode 100644
index 00000000000..9f7fa75ac89
--- /dev/null
+++ b/src/features/linodes/LinodesCreate/utilities.ts
@@ -0,0 +1,34 @@
+import { compose, find, lensPath, prop, propEq, set } from 'ramda';
+import { displayType } from 'src/features/linodes/presentation';
+import { ExtendedType } from './SelectPlanPanel';
+import { ExtendedLinode } from './types';
+
+export const extendLinodes = (
+ linodes: Linode.Linode[],
+ imagesData?: Linode.Image[],
+ typesData?: ExtendedType[]
+): ExtendedLinode[] => {
+ const images = imagesData || [];
+ const types = typesData || [];
+ return linodes.map(
+ linode =>
+ compose, Partial>(
+ set(lensPath(['heading']), linode.label),
+ set(
+ lensPath(['subHeadings']),
+ formatLinodeSubheading(
+ displayType(linode.type, types),
+ compose(
+ prop('label'),
+ find(propEq('id', linode.image))
+ )(images)
+ )
+ )
+ )(linode) as ExtendedLinode
+ );
+};
+
+const formatLinodeSubheading = (typeInfo: string, imageInfo: string) => {
+ const subheading = imageInfo ? `${typeInfo}, ${imageInfo}` : `${typeInfo}`;
+ return [subheading];
+};
diff --git a/src/features/linodes/LinodesCreate/withLabelGenerator.test.tsx b/src/features/linodes/LinodesCreate/withLabelGenerator.test.tsx
index 7b204cdd33c..816e4750d01 100644
--- a/src/features/linodes/LinodesCreate/withLabelGenerator.test.tsx
+++ b/src/features/linodes/LinodesCreate/withLabelGenerator.test.tsx
@@ -22,19 +22,23 @@ describe('withLabelGenerator HOC', () => {
});
it('updates custom label', () => {
- nestedComponent.props().updateCustomLabel({ target: { value: '' } });
- expect(nestedComponent.props().customLabel).toBe('');
nestedComponent
.props()
- .updateCustomLabel({ target: { value: 'hello world' } });
+ .updateCustomLabel({ target: { value: '' } } as React.ChangeEvent<
+ HTMLInputElement
+ >);
+ expect(nestedComponent.props().customLabel).toBe('');
+ nestedComponent.props().updateCustomLabel({
+ target: { value: 'hello world' }
+ } as React.ChangeEvent);
expect(nestedComponent.props().customLabel).toBe('hello world');
});
it('returns custom label after custom label has been altered', () => {
expect(nestedComponent.props().getLabel('ubuntu')).toBe('ubuntu');
- nestedComponent
- .props()
- .updateCustomLabel({ target: { value: 'hello world' } });
+ nestedComponent.props().updateCustomLabel({
+ target: { value: 'hello world' }
+ } as React.ChangeEvent);
expect(nestedComponent.props().getLabel('ubuntu')).toBe('hello world');
});
diff --git a/src/features/linodes/LinodesCreate/withLabelGenerator.tsx b/src/features/linodes/LinodesCreate/withLabelGenerator.tsx
index f9beb4993a7..f0146b0e8a9 100644
--- a/src/features/linodes/LinodesCreate/withLabelGenerator.tsx
+++ b/src/features/linodes/LinodesCreate/withLabelGenerator.tsx
@@ -5,7 +5,11 @@ import { deriveDefaultLabel, LabelArgTypes } from './deriveDefaultLabel';
export interface LabelProps {
customLabel: string;
- updateCustomLabel: (e: any) => void;
+ updateCustomLabel: (
+ e: React.ChangeEvent<
+ HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
+ >
+ ) => void;
getLabel: (...args: any[]) => string;
}
@@ -84,7 +88,7 @@ export default withLabelGenerator;
// Utilities
-// Searches 'existingLabels' and appends a zero-padded incrementer to the original label
+// Searches 'existingLabels' and appends a zero-padded increment-er to the original label
export const dedupeLabel = (
label: string,
existingLabels: string[]
diff --git a/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackup.tsx b/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackup.tsx
index 620b2d7ad18..b4766bfae0f 100644
--- a/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackup.tsx
+++ b/src/features/linodes/LinodesDetail/LinodeBackup/LinodeBackup.tsx
@@ -419,7 +419,9 @@ class LinodeBackup extends React.Component {
const { history, linodeID } = this.props;
history.push(
'/linodes/create' +
- `?type=fromBackup&backupID=${backup.id}&linodeID=${linodeID}`
+ `?type=My%20Images&subtype=Backups&backupID=${
+ backup.id
+ }&linodeID=${linodeID}`
);
};
diff --git a/src/features/linodes/LinodesDetail/LinodeRebuild/LinodeRebuild.tsx b/src/features/linodes/LinodesDetail/LinodeRebuild/LinodeRebuild.tsx
index af2964ac7d9..eb69467b9ab 100644
--- a/src/features/linodes/LinodesDetail/LinodeRebuild/LinodeRebuild.tsx
+++ b/src/features/linodes/LinodesDetail/LinodeRebuild/LinodeRebuild.tsx
@@ -29,10 +29,14 @@ interface ContextProps {
}
type CombinedProps = WithStyles & ContextProps;
-type MODES = 'fromImage' | 'fromStackScript';
+type MODES =
+ | 'fromImage'
+ | 'fromCommunityStackScript'
+ | 'fromAccountStackScript';
const options = [
{ value: 'fromImage', label: 'From Image' },
- { value: 'fromStackScript', label: 'From StackScript' }
+ { value: 'fromCommunityStackScript', label: 'From Community StackScript' },
+ { value: 'fromAccountStackScript', label: 'From Account StackScript' }
];
const LinodeRebuild: React.StatelessComponent = props => {
@@ -66,7 +70,12 @@ const LinodeRebuild: React.StatelessComponent = props => {
/>
{mode === 'fromImage' && }
- {mode === 'fromStackScript' && }
+ {mode === 'fromCommunityStackScript' && (
+
+ )}
+ {mode === 'fromAccountStackScript' && (
+
+ )}
);
};
diff --git a/src/features/linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx b/src/features/linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx
index 55094c02cc0..0a16db69bb9 100644
--- a/src/features/linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx
+++ b/src/features/linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx
@@ -32,6 +32,11 @@ import { filterPublicImages } from 'src/utilities/images';
import { withLinodeDetailContext } from '../linodeDetailContext';
import { RebuildDialog } from './RebuildDialog';
+import {
+ getCommunityStackscripts,
+ getMineAndAccountStackScripts
+} from 'src/features/StackScripts/stackScriptUtils';
+
type ClassNames = 'root' | 'error' | 'emptyImagePanel' | 'emptyImagePanelText';
const styles: StyleRulesCallback = theme => ({
@@ -50,6 +55,10 @@ const styles: StyleRulesCallback = theme => ({
}
});
+interface Props {
+ type: 'community' | 'account';
+}
+
interface ContextProps {
linodeId: number;
}
@@ -64,7 +73,8 @@ interface RebuildFromStackScriptForm {
password: string;
}
-export type CombinedProps = WithStyles &
+export type CombinedProps = Props &
+ WithStyles &
WithImagesProps &
ContextProps &
UserSSHKeyProps &
@@ -198,6 +208,13 @@ export const RebuildFromStackScript: React.StatelessComponent<
publicImages={filterPublicImages(imagesData)}
resetSelectedStackScript={resetStackScript}
data-qa-select-stackscript
+ category={props.type}
+ header="Select StackScript"
+ request={
+ props.type === 'account'
+ ? getMineAndAccountStackScripts
+ : getCommunityStackscripts
+ }
/>
{ss.user_defined_fields && ss.user_defined_fields.length > 0 && (
0 ? (
setField('imageID', selected)}
updateFor={[classes, form.imageID, ss.images, errors]}
selectedImageID={form.imageID}
error={hasErrorFor.image}
- hideMyImages={true}
/>
) : (
@@ -272,7 +289,7 @@ const linodeContext = withLinodeDetailContext(({ linode }) => ({
linodeId: linode.id
}));
-const enhanced = compose(
+const enhanced = compose(
linodeContext,
userSSHKeyHoc,
styled,
diff --git a/src/features/linodes/index.tsx b/src/features/linodes/index.tsx
index 4e257585135..9c6a440d683 100644
--- a/src/features/linodes/index.tsx
+++ b/src/features/linodes/index.tsx
@@ -13,7 +13,7 @@ const LinodesLanding = DefaultLoader({
});
const LinodesCreate = DefaultLoader({
- loader: () => import('./LinodesCreate')
+ loader: () => import('./LinodesCreate/LinodeCreateContainer')
});
const LinodesDetail = DefaultLoader({
diff --git a/src/features/linodes/userSSHKeyHoc.ts b/src/features/linodes/userSSHKeyHoc.ts
index 90fdced69c4..9b92e4f4e74 100644
--- a/src/features/linodes/userSSHKeyHoc.ts
+++ b/src/features/linodes/userSSHKeyHoc.ts
@@ -1,4 +1,4 @@
-import { path } from 'ramda';
+import { assoc, map, path } from 'ramda';
import * as React from 'react';
import { connect } from 'react-redux';
import { UserSSHKeyObject } from 'src/components/AccessPanel';
@@ -13,12 +13,24 @@ export interface UserSSHKeyProps {
export interface State {
userSSHKeys: UserSSHKeyObject[];
+ resetSSHKeys: () => void;
}
+const resetKeys = (key: UserSSHKeyObject) => {
+ return assoc('selected', false, key);
+};
+
export default (Component: React.ComponentType) => {
class WrappedComponent extends React.PureComponent {
+ resetSSHKeys = () => {
+ const { userSSHKeys } = this.state;
+ const newKeys = map(resetKeys, userSSHKeys);
+ this.setState({ userSSHKeys: newKeys });
+ };
+
state = {
- userSSHKeys: []
+ userSSHKeys: [],
+ resetSSHKeys: this.resetSSHKeys
};
mounted: boolean = false;
diff --git a/src/index.css b/src/index.css
index fdff5a029b0..066e157f9fe 100644
--- a/src/index.css
+++ b/src/index.css
@@ -250,9 +250,12 @@ a.black:not(.nu):active {
flex-basis: 78.8%;
}
.mlSidebar {
+ position: sticky;
+ top: 0;
+ align-self: flex-start;
max-width: 21.2%;
max-width: 21.2%;
- padding-left: 16px !important;
+ padding: 8px 24px !important;
margin-top: 0 !important;
}
}
diff --git a/src/services/cloud_apps/index.ts b/src/services/cloud_apps/index.ts
new file mode 100644
index 00000000000..69fa9bbb964
--- /dev/null
+++ b/src/services/cloud_apps/index.ts
@@ -0,0 +1,26 @@
+import { API_ROOT } from 'src/constants';
+import Request, { setMethod, setParams, setURL, setXFilter } from '../index';
+
+export interface CloudApp {
+ sequence: number;
+ /**
+ * will be something like /assets/minecraft.svg
+ * front-end needs to provide the location
+ */
+ logo_url: string | null;
+ label: string;
+ stackscript_id: number;
+ images: string[];
+ user_defined_fields: any[];
+ created: string;
+ id: number;
+}
+
+export const getCloudApps = (params?: any, filter?: any) => {
+ return Request>(
+ setURL(`${API_ROOT}beta/linode/one-click-apps`),
+ setMethod('GET'),
+ setParams(params),
+ setXFilter(filter)
+ ).then(response => response.data);
+};
diff --git a/src/services/linodes/linodes.ts b/src/services/linodes/linodes.ts
index 1c0d37ed422..1b53dcbf501 100644
--- a/src/services/linodes/linodes.ts
+++ b/src/services/linodes/linodes.ts
@@ -12,18 +12,18 @@ type Page = Linode.ResourcePage;
type Linode = Linode.Linode;
export interface CreateLinodeRequest {
- type: string | null;
- region: string | null;
+ type?: string;
+ region?: string;
stackscript_id?: number;
backup_id?: number;
swap_size?: number;
- image?: string | null;
- root_pass?: string | null;
+ image?: string;
+ root_pass?: string;
authorized_keys?: string[];
backups_enabled?: boolean;
stackscript_data?: any;
booted?: boolean;
- label: string | null;
+ label?: string;
tags?: string[];
private_ip?: boolean;
authorized_users?: string[];
diff --git a/src/store/index.ts b/src/store/index.ts
index 804d3555cae..5dba40ab5e1 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -49,6 +49,10 @@ import images, {
defaultState as defaultImagesState,
State as ImagesStata
} from 'src/store/image/image.reducer';
+import linodeCreateReducer, {
+ defaultState as linodeCreateDefaultState,
+ State as LinodeCreateState
+} from 'src/store/linodeCreate/linodeCreate.reducer';
import linodeConfigs, {
defaultState as defaultLinodeConfigsState,
State as LinodeConfigsState
@@ -165,6 +169,7 @@ export interface ApplicationState {
tagImportDrawer: TagImportDrawerState;
volumeDrawer: VolumeDrawerState;
bucketDrawer: BucketDrawerState;
+ createLinode: LinodeCreateState;
}
const defaultState: ApplicationState = {
@@ -177,7 +182,8 @@ const defaultState: ApplicationState = {
stackScriptDrawer: stackScriptDrawerDefaultState,
tagImportDrawer: tagDrawerDefaultState,
volumeDrawer: volumeDrawerDefaultState,
- bucketDrawer: bucketDrawerDefaultState
+ bucketDrawer: bucketDrawerDefaultState,
+ createLinode: linodeCreateDefaultState
};
/**
@@ -212,7 +218,8 @@ const reducers = combineReducers({
tagImportDrawer,
volumeDrawer,
bucketDrawer,
- events
+ events,
+ createLinode: linodeCreateReducer
});
const enhancers = compose(
diff --git a/src/store/linodeCreate/linodeCreate.actions.ts b/src/store/linodeCreate/linodeCreate.actions.ts
new file mode 100644
index 00000000000..774a24beecf
--- /dev/null
+++ b/src/store/linodeCreate/linodeCreate.actions.ts
@@ -0,0 +1,12 @@
+import actionCreatorFactory from 'typescript-fsa';
+
+const actionBase = actionCreatorFactory('@@manager/create-linode');
+
+export type CreateTypes =
+ | 'fromApp'
+ | 'fromStackScript'
+ | 'fromImage'
+ | 'fromBackup'
+ | 'fromLinode';
+
+export const handleChangeCreateType = actionBase('change-type');
diff --git a/src/store/linodeCreate/linodeCreate.reducer.ts b/src/store/linodeCreate/linodeCreate.reducer.ts
new file mode 100644
index 00000000000..a7bb1990a55
--- /dev/null
+++ b/src/store/linodeCreate/linodeCreate.reducer.ts
@@ -0,0 +1,63 @@
+import { parse } from 'querystring';
+import { Reducer } from 'redux';
+import { reducerWithInitialState } from 'typescript-fsa-reducers';
+import { CreateTypes, handleChangeCreateType } from './linodeCreate.actions';
+
+export interface State {
+ type: CreateTypes;
+}
+
+const getInitialType = (): CreateTypes => {
+ const queryParams = parse(location.search.replace('?', '').toLowerCase());
+
+ if (queryParams.type) {
+ if (queryParams.subtype) {
+ /**
+ * we have a subtype in the query string so now we need to deduce what
+ * endpoint we should be POSTing to based on what is in the query params
+ */
+ if (queryParams.subtype.includes('stackscript')) {
+ return 'fromStackScript';
+ } else if (queryParams.subtype.includes('clone')) {
+ return 'fromLinode';
+ } else if (queryParams.subtype.includes('backup')) {
+ return 'fromBackup';
+ } else {
+ return 'fromApp';
+ }
+ } else {
+ /**
+ * here we know we don't have a subtype in the query string
+ * but we do have a type (AKA a parent tab is selected). In this case,
+ * we can assume the first child tab is selected within the parent tabs
+ */
+ if (queryParams.type.includes('one-click')) {
+ return 'fromApp';
+ } else if (queryParams.type.includes('images')) {
+ return 'fromImage';
+ } else {
+ return 'fromImage';
+ }
+ }
+ }
+
+ /** always backup to 'fromImage' */
+ return 'fromImage';
+};
+
+export const defaultState: State = {
+ type: getInitialType()
+};
+
+const reducer: Reducer = reducerWithInitialState(
+ defaultState
+).caseWithAction(handleChangeCreateType, (state, action) => {
+ const { payload } = action;
+
+ return {
+ ...state,
+ type: payload
+ };
+});
+
+export default reducer;
diff --git a/src/utilities/errorUtils.ts b/src/utilities/errorUtils.ts
index 4d3db65fdbd..6079385a47b 100644
--- a/src/utilities/errorUtils.ts
+++ b/src/utilities/errorUtils.ts
@@ -29,6 +29,42 @@ export const getAPIErrorOrDefault = (
return pathOr(_defaultError, ['response', 'data', 'errors'], errorResponse);
};
+export const handleUnauthorizedErrors = (
+ e: Linode.ApiFieldError[],
+ unauthedMessage: string
+) => {
+ /**
+ * filter out errors that match the following
+ * {
+ * reason: "Unauthorized"
+ * }
+ *
+ * and if any of these errors exist, set the hasUnauthorizedError
+ * flag to true
+ */
+ let hasUnauthorizedError = false;
+ const filteredErrors = e.filter(eachError => {
+ if (eachError.reason.toLowerCase().includes('unauthorized')) {
+ hasUnauthorizedError = true;
+ return false;
+ }
+ return true;
+ });
+
+ /**
+ * if we found an unauthorized error, add on the new message in the API
+ * Error format
+ */
+ return hasUnauthorizedError
+ ? [
+ {
+ reason: unauthedMessage
+ },
+ ...filteredErrors
+ ]
+ : filteredErrors;
+};
+
export const getErrorStringOrDefault = (
errors: Linode.ApiFieldError[] | string,
defaultError: string = 'An unexpected error occurred.'