From eeb28fcdb0e7c56d27779f1912f5a01a2d5bd4ea Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 27 Feb 2019 16:56:39 -0500 Subject: [PATCH 01/44] add CA create Linode skeleton (#4577) --- src/features/StackScripts/stackScriptUtils.ts | 11 + .../Support/SupportTickets/TicketRow.test.tsx | 1 + .../linodes/LinodesCreate/CALinodeCreate.tsx | 254 ++++++++++++++++++ src/features/linodes/index.tsx | 2 +- 4 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 src/features/linodes/LinodesCreate/CALinodeCreate.tsx diff --git a/src/features/StackScripts/stackScriptUtils.ts b/src/features/StackScripts/stackScriptUtils.ts index 3db4e48f7fa..c726ef1e2bd 100644 --- a/src/features/StackScripts/stackScriptUtils.ts +++ b/src/features/StackScripts/stackScriptUtils.ts @@ -19,6 +19,17 @@ export const getStackScriptsByUser = ( username }); +/** + * helper function to get Cloud Apps StackScripts + * + * for the prototype, all the apps we need are going to be uploaded to + * Christine Puk's account. Keep in mind that the Linux distros will be missing from this + * list because we're intentionally not including the distros in this view + */ +export const getCloudAppsStackScripts = () => { + return getStackScriptsByUser('capuk'); +}; + export const getAccountStackScripts = ( currentUser: string, params?: any, 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/linodes/LinodesCreate/CALinodeCreate.tsx b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx new file mode 100644 index 00000000000..677c79c4aa2 --- /dev/null +++ b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx @@ -0,0 +1,254 @@ +import { parse } from 'querystring'; +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 composeComponent } from 'recompose'; +import CircleProgress from 'src/components/CircleProgress'; +import AppBar from 'src/components/core/AppBar'; +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 { 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 { ExtendedType } from './SelectPlanPanel'; + +export type TypeInfo = + | { + title: string; + details: string; + monthly: number; + backupsMonthly: number | null; + } + | undefined; + +type CombinedProps = WithImagesProps & + WithLinodesProps & + WithTypesProps & + WithRegions & + StateProps & + RouteComponentProps<{}>; + +interface State { + selectedTab: number; +} + +interface Tab { + title: string; + render: () => JSX.Element; +} + +export class LinodeCreate extends React.Component { + constructor(props: CombinedProps) { + super(props); + + /** 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 = 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.history.push({ + search: `?type=${event.target.textContent}` + }); + this.setState({ + selectedTab: value + }); + }; + + tabs: Tab[] = [ + { + title: 'Distros', + render: () => { + return ; + } + }, + { + title: 'One-Click', + render: () => { + return ; + } + }, + { + title: 'My Images', + render: () => { + return ; + } + } + ]; + + componentWillUnmount() { + this.mounted = false; + } + + render() { + const { selectedTab } = this.state; + + const { 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 { + accountBackupsEnabled: boolean; + userCannotCreateLinode: boolean; +} + +const mapStateToProps: MapState = 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') +}); + +const connected = connect(mapStateToProps); + +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.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, + withRouter, + connected +)(LinodeCreate); diff --git a/src/features/linodes/index.tsx b/src/features/linodes/index.tsx index 4e257585135..33bb55ea56c 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/CALinodeCreate') }); const LinodesDetail = DefaultLoader({ From 06c64f95608b0d4004b0202bf4c602040ebc11aa Mon Sep 17 00:00:00 2001 From: Jared Date: Thu, 28 Feb 2019 08:50:28 -0500 Subject: [PATCH 02/44] CA-XXX Add Distros tab (#4579) * Working prototype * Just refactor FIC after all --- .../linodes/LinodesCreate/CALinodeCreate.tsx | 81 ++++++++++++++++++- .../LinodesCreate/SelectImagePanel.tsx | 3 +- .../TabbedContent/FromImageContent.tsx | 8 +- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx index 677c79c4aa2..19fe2ced504 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx @@ -25,6 +25,8 @@ import { import { ApplicationState } from 'src/store'; import { MapState } from 'src/store/types'; import { ExtendedType } from './SelectPlanPanel'; +import FromImageContent from './TabbedContent/FromImageContent'; +import { Info } from './util'; export type TypeInfo = | { @@ -90,7 +92,22 @@ export class LinodeCreate extends React.Component { { title: 'Distros', render: () => { - return ; + return ( + + ); } }, { @@ -111,6 +128,68 @@ export class LinodeCreate extends React.Component { this.mounted = false; } + 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; + }; + + 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; diff --git a/src/features/linodes/LinodesCreate/SelectImagePanel.tsx b/src/features/linodes/LinodesCreate/SelectImagePanel.tsx index 11c293b79cf..9e943ff5c7b 100644 --- a/src/features/linodes/LinodesCreate/SelectImagePanel.tsx +++ b/src/features/linodes/LinodesCreate/SelectImagePanel.tsx @@ -57,6 +57,7 @@ const distroIcons = { interface Props { images: Linode.Image[]; + title?: string; error?: string; selectedImageID: string | null; handleSelection: (id: string) => void; @@ -211,7 +212,7 @@ const CreateFromImage: React.StatelessComponent = props => { > {error && } - Select Image + {props.title || 'Select an Image'} {renderPublicImages()} diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx index e09337dcec6..b37e5965fa1 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx @@ -58,6 +58,8 @@ interface Notice { interface Props { errors?: Linode.ApiFieldError[]; notice?: Notice; + publicOnly?: boolean; + imagePanelTitle?: string; images: Linode.Image[]; regions: ExtendedRegion[]; types: ExtendedType[]; @@ -294,9 +296,11 @@ export class FromImageContent extends React.Component { getBackupsMonthlyPrice, getRegionInfo, getTypeInfo, + publicOnly, updateCustomLabel, userSSHKeys, - disabled + disabled, + imagePanelTitle } = this.props; const hasErrorFor = getAPIErrorsFor(errorResources, errors); @@ -327,6 +331,8 @@ export class FromImageContent extends React.Component { {generalError && } Date: Thu, 28 Feb 2019 09:44:58 -0500 Subject: [PATCH 03/44] add subtabs (#4581) --- .../linodes/LinodesCreate/CALinodeCreate.tsx | 14 +- .../LinodesCreate/CALinodeCreateSubTabs.tsx | 132 ++++++++++++++++++ 2 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx diff --git a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx index 19fe2ced504..0288a1cd9c4 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx @@ -7,7 +7,7 @@ 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 Tab from 'src/components/core/Tab'; +import MUITab 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'; @@ -24,6 +24,7 @@ import { } from 'src/features/Profile/permissionsHelpers'; import { ApplicationState } from 'src/store'; import { MapState } from 'src/store/types'; +import SubTabs, { Tab } from './CALinodeCreateSubTabs'; import { ExtendedType } from './SelectPlanPanel'; import FromImageContent from './TabbedContent/FromImageContent'; import { Info } from './util'; @@ -48,11 +49,6 @@ interface State { selectedTab: number; } -interface Tab { - title: string; - render: () => JSX.Element; -} - export class LinodeCreate extends React.Component { constructor(props: CombinedProps) { super(props); @@ -113,13 +109,13 @@ export class LinodeCreate extends React.Component { { title: 'One-Click', render: () => { - return ; + return ; } }, { title: 'My Images', render: () => { - return ; + return ; } } ]; @@ -219,7 +215,7 @@ export class LinodeCreate extends React.Component { scrollButtons="on" > {this.tabs.map((tab, idx) => ( - JSX.Element; +} + +interface Props { + history: any; + type: 'oneClick' | 'myImages'; +} + +interface State { + selectedTab: number; +} + +type CombinedProps = Props; + +class CALinodeCreateSubTabs extends React.PureComponent { + constructor(props: CombinedProps) { + super(props); + + const tabsToRender = + props.type === 'oneClick' ? this.oneClickTabs : this.myImagesTabs; + + /** 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; + }); + + this.state = { + selectedTab: preSelectedTab !== -1 ? preSelectedTab : 0 + }; + } + + oneClickTabs: Tab[] = [ + { + title: 'One-Click Apps', + render: () => { + return ; + } + }, + { + title: 'Community StackScripts', + render: () => { + return ; + } + } + ]; + + myImagesTabs: Tab[] = [ + { + title: 'Backups and My Images', + render: () => { + return ; + } + }, + { + title: 'Clone From Existing Linode', + render: () => { + return ; + } + }, + { + title: 'My StackScripts', + render: () => { + return ; + } + } + ]; + + handleTabChange = ( + event: React.ChangeEvent, + value: number + ) => { + /** get the query params as an object, excluding the "?" */ + const queryParams = parse(location.search.replace('?', '')); + + this.props.history.push({ + search: `?type=${queryParams.type}&subtype=${event.target.textContent}` + }); + this.setState({ + selectedTab: value + }); + }; + + render() { + const { type } = this.props; + const { selectedTab } = this.state; + + const tabsToRender = + type === 'oneClick' ? this.oneClickTabs : this.myImagesTabs; + + const selectedTabContentRender = tabsToRender[selectedTab].render; + + return ( + + + + + {tabsToRender.map((tab, idx) => ( + + ))} + + + + {selectedTabContentRender()} + + ); + } +} + +export default CALinodeCreateSubTabs; From 228d0b0e15068981ba6881fe78185684f81c948c Mon Sep 17 00:00:00 2001 From: Jared Date: Fri, 1 Mar 2019 17:47:30 -0500 Subject: [PATCH 04/44] CA: Add my images tab (#4582) * Refactor clone from linode tab to new structure * Add CASelectStackScriptPanel * lift state up and fix fromimagecontent * fix linode clone view * fix bug * destructure unnecessary props * Working mostly * Remove console log * use container form state in createfromstackscript --- src/components/AccessPanel/AccessPanel.tsx | 17 +- src/components/AccessPanel/index.ts | 2 - .../SelectRegionPanel/SelectRegionPanel.tsx | 10 +- .../NodeBalancers/NodeBalancerCreate.tsx | 2 +- .../CASelectStackScriptPanel.tsx | 264 ++++ .../linodes/LinodesCreate/CALinodeCreate.tsx | 360 ++---- .../LinodesCreate/CALinodeCreateSubTabs.tsx | 34 +- .../LinodesCreate/LinodeCreateContainer.tsx | 443 +++++++ .../LinodesCreate/LinodesCreate.test.tsx | 2 +- .../linodes/LinodesCreate/LinodesCreate.tsx | 1066 ++++++++-------- .../LinodesCreate/SelectImagePanel.tsx | 2 +- .../linodes/LinodesCreate/SelectPlanPanel.tsx | 2 +- .../TabbedContent/FromBackupsContent.test.tsx | 2 +- .../TabbedContent/FromBackupsContent.tsx | 1095 ++++++++--------- .../TabbedContent/FromImageContent.test.tsx | 2 +- .../TabbedContent/FromImageContent.tsx | 437 ++----- .../TabbedContent/FromLinodeContent.test.tsx | 24 +- .../TabbedContent/FromLinodeContent.tsx | 307 ++--- .../FromStackScriptContent.test.tsx | 2 +- .../TabbedContent/FromStackScriptContent.tsx | 536 +++----- src/features/linodes/LinodesCreate/index.ts | 2 +- src/features/linodes/LinodesCreate/types.ts | 137 +++ src/features/linodes/LinodesCreate/util.ts | 1 - .../linodes/LinodesCreate/utilites.ts | 34 + src/features/linodes/index.tsx | 2 +- src/services/linodes/linodes.ts | 10 +- 26 files changed, 2497 insertions(+), 2298 deletions(-) create mode 100644 src/features/StackScripts/SelectStackScriptPanel/CASelectStackScriptPanel.tsx create mode 100644 src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx create mode 100644 src/features/linodes/LinodesCreate/types.ts delete mode 100644 src/features/linodes/LinodesCreate/util.ts create mode 100644 src/features/linodes/LinodesCreate/utilites.ts diff --git a/src/components/AccessPanel/AccessPanel.tsx b/src/components/AccessPanel/AccessPanel.tsx index a5e6c4c71e8..0a9e97286da 100644 --- a/src/components/AccessPanel/AccessPanel.tsx +++ b/src/components/AccessPanel/AccessPanel.tsx @@ -59,11 +59,6 @@ const styles: StyleRulesCallback = theme => ({ const styled = withStyles(styles); -export interface Disabled { - disabled?: boolean; - reason?: string; -} - interface Props { password: string | null; error?: string; @@ -74,7 +69,8 @@ interface Props { required?: boolean; placeholder?: string; users?: UserSSHKeyObject[]; - passwordFieldDisabled?: Disabled; + disabled?: boolean; + disabledReason?: string; } export interface UserSSHKeyObject { @@ -100,7 +96,8 @@ class AccessPanel extends React.Component { required, placeholder, users, - passwordFieldDisabled + disabled, + disabledReason } = this.props; return ( @@ -109,10 +106,8 @@ class AccessPanel extends React.Component { {error && } void; - selectedID: string | null; + selectedID?: string; disabled?: boolean; } @@ -64,8 +64,8 @@ const getASRegions = (regions: ExtendedRegion[]) => regions.filter(r => /(jp|sg)/.test(r.country)); const renderCard = ( - selectedID: string | null, handleSelection: Function, + selectedID?: string, disabled?: boolean ) => (region: ExtendedRegion, idx: number) => ( { return ( - {na.map(renderCard(selectedID, handleSelection, disabled))} + {na.map(renderCard(handleSelection, selectedID, disabled))} ); } @@ -108,7 +108,7 @@ class SelectRegionPanel extends React.Component< render: () => { return ( - {eu.map(renderCard(selectedID, handleSelection, disabled))} + {eu.map(renderCard(handleSelection, selectedID, disabled))} ); } @@ -121,7 +121,7 @@ class SelectRegionPanel extends React.Component< render: () => { return ( - {as.map(renderCard(selectedID, handleSelection, disabled))} + {as.map(renderCard(handleSelection, selectedID, disabled))} ); } diff --git a/src/features/NodeBalancers/NodeBalancerCreate.tsx b/src/features/NodeBalancers/NodeBalancerCreate.tsx index 7cd5046602e..3087d8defb5 100644 --- a/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -511,7 +511,7 @@ class NodeBalancerCreate extends React.Component { diff --git a/src/features/StackScripts/SelectStackScriptPanel/CASelectStackScriptPanel.tsx b/src/features/StackScripts/SelectStackScriptPanel/CASelectStackScriptPanel.tsx new file mode 100644 index 00000000000..12db7edb889 --- /dev/null +++ b/src/features/StackScripts/SelectStackScriptPanel/CASelectStackScriptPanel.tsx @@ -0,0 +1,264 @@ +import { compose, pathOr } from 'ramda'; +import * as React from 'react'; +import { connect } from 'react-redux'; +import Button from 'src/components/Button'; +import CircleProgress from 'src/components/CircleProgress'; +import Paper from 'src/components/core/Paper'; +import { + StyleRulesCallback, + withStyles, + 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 Table from 'src/components/Table'; +import { getStackScript } from 'src/services/stackscripts'; +import { MapState } from 'src/store/types'; +import { formatDate } from 'src/utilities/format-date-iso8601'; +import stripImageName from 'src/utilities/stripImageName'; +import truncateText from 'src/utilities/truncateText'; +import StackScriptTableHead from '../Partials/StackScriptTableHead'; +import SelectStackScriptPanelContent from './SelectStackScriptPanelContent'; +import StackScriptSelectionRow from './StackScriptSelectionRow'; + +export interface ExtendedLinode extends Linode.Linode { + heading: string; + subHeadings: string[]; +} + +type ClassNames = + | 'root' + | 'table' + | 'link' + | 'selecting' + | 'panel' + | 'inner' + | 'header'; + +const styles: StyleRulesCallback = theme => ({ + root: { + marginBottom: theme.spacing.unit * 3 + }, + table: { + flexGrow: 1, + width: '100%', + backgroundColor: theme.color.white + }, + selecting: { + minHeight: '400px', + maxHeight: '1000px', + overflowX: 'auto', + overflowY: 'scroll', + paddingTop: 0, + marginTop: theme.spacing.unit * 2 + }, + link: { + display: 'block', + textAlign: 'right', + marginBottom: 24, + marginTop: theme.spacing.unit + }, + panel: { + flexGrow: 1, + width: '100%', + backgroundColor: theme.color.white + }, + inner: { + padding: theme.spacing.unit * 2, + [theme.breakpoints.up('sm')]: { + padding: theme.spacing.unit * 3 + } + }, + header: { + paddingBottom: theme.spacing.unit * 2 + } +}); + +interface Props { + selectedId: number | undefined; + selectedUsername?: string; + error?: string; + onSelect: ( + id: number, + label: string, + username: string, + images: string[], + userDefinedFields: Linode.StackScript.UserDefinedField[] + ) => void; + publicImages: Linode.Image[]; + resetSelectedStackScript: () => void; + disabled?: boolean; + request: () => Promise>; + category: string; + header: string; +} + +type CombinedProps = Props & StateProps & WithStyles; + +interface State { + stackScript?: Linode.StackScript.Response; + stackScriptError: boolean; + stackScriptLoading: boolean; +} + +class SelectStackScriptPanel extends React.Component { + state: State = { + stackScriptLoading: false, + stackScriptError: false + }; + + mounted: boolean = false; + + componentDidMount() { + if (this.props.selectedId) { + this.setState({ stackScriptLoading: true }); + getStackScript(this.props.selectedId) + .then(stackScript => { + this.setState({ stackScript, stackScriptLoading: false }); + this.props.onSelect( + stackScript.id, + stackScript.label, + stackScript.username, + stackScript.images, + stackScript.user_defined_fields + ); + }) + .catch(e => { + this.setState({ stackScriptLoading: false, stackScriptError: true }); + }); + } + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + handleTabChange = () => { + /* + * if we're coming from a query string, the stackscript will be preselected + * however, we don't want the user to have their stackscript still preselected + * when they change StackScript tabs + */ + this.props.resetSelectedStackScript(); + }; + + resetStackScript = () => { + this.setState({ stackScript: undefined, stackScriptLoading: false }); + }; + + render() { + const { + category, + classes, + header, + request, + selectedId, + error + } = this.props; + const { stackScript, stackScriptLoading, stackScriptError } = this.state; + + if (selectedId) { + if (stackScriptLoading) { + return ; + } + if (stackScript) { + return ( + + + + + {}} + description={truncateText(stackScript.description, 100)} + images={stripImageName(stackScript.images)} + deploymentsActive={stackScript.deployments_active} + updated={formatDate(stackScript.updated, false)} + checked={selectedId === stackScript.id} + updateFor={[selectedId === stackScript.id]} + stackScriptID={stackScript.id} + /> + +
+
+ +
+
+ ); + } + } + + return ( + +
+ {error && } + + {header} + + {stackScriptError && ( + + An error occured while loading the selected StackScript. + + )} + +
+
+ ); + } +} + +interface StateProps { + username: string; +} + +const mapStateToProps: MapState = state => ({ + username: pathOr('', ['data', 'username'], state.__resources.profile) +}); + +const connected = connect(mapStateToProps); + +const styled = withStyles(styles); + +export default compose< + Linode.TodoAny, + Linode.TodoAny, + Linode.TodoAny, + Linode.TodoAny +>( + connected, + RenderGuard, + styled +)(SelectStackScriptPanel); diff --git a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx index 0288a1cd9c4..99133f26062 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx @@ -1,55 +1,36 @@ import { parse } from 'querystring'; -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 composeComponent } from 'recompose'; 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 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 { 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 { getStackScriptsByUser } from 'src/features/StackScripts/stackScriptUtils'; import SubTabs, { Tab } from './CALinodeCreateSubTabs'; -import { ExtendedType } from './SelectPlanPanel'; import FromImageContent from './TabbedContent/FromImageContent'; -import { Info } from './util'; +import FromLinodeContent from './TabbedContent/FromLinodeContent'; +import FromStackScriptContent from './TabbedContent/FromStackScriptContent'; -export type TypeInfo = - | { - title: string; - details: string; - monthly: number; - backupsMonthly: number | null; - } - | undefined; +import { + AllFormStateAndHandlers, + WithDisplayData, + WithLinodesImagesTypesAndRegions +} from './types'; + +interface Props { + history: any; +} -type CombinedProps = WithImagesProps & - WithLinodesProps & - WithTypesProps & - WithRegions & - StateProps & - RouteComponentProps<{}>; +type CombinedProps = Props & + WithLinodesImagesTypesAndRegions & + WithDisplayData & + AllFormStateAndHandlers; interface State { selectedTab: number; } -export class LinodeCreate extends React.Component { +export class LinodeCreate extends React.PureComponent { constructor(props: CombinedProps) { super(props); @@ -88,20 +69,19 @@ export class LinodeCreate extends React.Component { { title: 'Distros', render: () => { + /** ...rest being all the formstate props and display data */ + const { + history, + linodesData, + linodesError, + linodesLoading, + ...rest + } = this.props; return ( ); } @@ -115,215 +95,127 @@ export class LinodeCreate extends React.Component { { title: 'My Images', render: () => { - return ; + return ( + + ); } } ]; - componentWillUnmount() { - this.mounted = false; - } - - 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; - }; - - getImageInfo = (image: Linode.Image | undefined): Info => { - return ( - image && { - title: `${image.vendor || image.label}`, - details: `${image.vendor ? image.label : ''}` + myImagesTabs = (): Tab[] => [ + { + title: 'Backups and My Images', + render: () => { + return ; } - ); - }; - - 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 + }, + { + title: 'Clone From Existing Linode', + render: () => { + /** + * rest being just the props that FromLinodeContent needs + * AKA CloneFormStateHandlers, WithLinodesImagesTypesAndRegions, + * and WithDisplayData + */ + const { + handleSelectUDFs, + selectedUDFs, + selectedStackScriptID, + updateStackScript, + linodesLoading, + linodesError, + regionsLoading, + regionsError, + ...rest + } = this.props; + return ( + + ); } - ); - }; - - getRegionInfo = (selectedRegionID?: string | null): Info => { - const selectedRegion = this.props.regionsData.find( - region => region.id === selectedRegionID - ); - - return ( - selectedRegion && { - title: selectedRegion.country.toUpperCase(), - details: selectedRegion.display + }, + { + title: 'My StackScripts', + render: () => { + const { + accountBackupsEnabled, + userCannotCreateLinode, + ...rest + } = this.props; + return ( + + ); } - ); - }; - - handleDisablePasswordField = (imageSelected: boolean) => { - if (!imageSelected) { - return { - disabled: true, - reason: 'You must first select an image to enter a root password' - }; } - return; - }; + ]; + + componentWillUnmount() { + this.mounted = false; + } render() { const { selectedTab } = this.state; - const { regionsLoading, imagesLoading } = this.props; + const { regionsLoading, imagesLoading, linodesLoading } = this.props; - if (regionsLoading || imagesLoading) { + if (regionsLoading || imagesLoading || linodesLoading) { return ; } + if ( + !this.props.regionsData || + !this.props.imagesData || + !this.props.linodesData + ) { + return null; + } + + /** @todo handle for errors loading anything */ + const tabRender = this.tabs[selectedTab].render; return ( - - - - - - Create New Linode - - - - {this.tabs.map((tab, idx) => ( - - ))} - - - - {tabRender()} + + + + + {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 { - accountBackupsEnabled: boolean; - userCannotCreateLinode: boolean; -} - -const mapStateToProps: MapState = 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') -}); - -const connected = connect(mapStateToProps); - -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.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, - withRouter, - connected -)(LinodeCreate); +export default LinodeCreate; diff --git a/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx b/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx index 210cd83bea2..fe78f147ff1 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx @@ -12,6 +12,7 @@ export interface Tab { interface Props { history: any; + tabs?: Tab[]; type: 'oneClick' | 'myImages'; } @@ -25,8 +26,7 @@ class CALinodeCreateSubTabs extends React.PureComponent { constructor(props: CombinedProps) { super(props); - const tabsToRender = - props.type === 'oneClick' ? this.oneClickTabs : this.myImagesTabs; + const tabsToRender = this.getTabsToRender(props.type, props.tabs); /** get the query params as an object, excluding the "?" */ const queryParams = parse(location.search.replace('?', '')); @@ -56,26 +56,12 @@ class CALinodeCreateSubTabs extends React.PureComponent { } ]; - myImagesTabs: Tab[] = [ - { - title: 'Backups and My Images', - render: () => { - return ; - } - }, - { - title: 'Clone From Existing Linode', - render: () => { - return ; - } - }, - { - title: 'My StackScripts', - render: () => { - return ; - } + getTabsToRender = (type: string, tabs?: Tab[]) => { + if (tabs) { + return tabs; } - ]; + return type === 'oneClick' ? this.oneClickTabs : []; + }; handleTabChange = ( event: React.ChangeEvent, @@ -93,12 +79,10 @@ class CALinodeCreateSubTabs extends React.PureComponent { }; render() { - const { type } = this.props; + const { type, tabs } = this.props; const { selectedTab } = this.state; - const tabsToRender = - type === 'oneClick' ? this.oneClickTabs : this.myImagesTabs; - + const tabsToRender = this.getTabsToRender(type, tabs); const selectedTabContentRender = tabsToRender[selectedTab].render; return ( diff --git a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx new file mode 100644 index 00000000000..a74227883c3 --- /dev/null +++ b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -0,0 +1,443 @@ +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 { + 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 { typeLabelDetails } from 'src/features/linodes/presentation'; +import { + hasGrant, + isRestrictedUser +} from 'src/features/Profile/permissionsHelpers'; +import CALinodeCreate from './CALinodeCreate'; +import { ExtendedType } from './SelectPlanPanel'; + +import { + HandleSubmit, + Info, + ReduxStateProps, + TypeInfo, + WithLinodesImagesTypesAndRegions +} from './types'; + +import { resetEventsPolling } from 'src/events'; +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; + 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; +} + +type CombinedProps = InjectedNotistackProps & + LinodeActionsProps & + WithLinodesImagesTypesAndRegions & + DispatchProps & + RouteComponentProps<{}>; + +class LinodeCreateContainer extends React.PureComponent { + state: State = { + privateIPEnabled: false, + backupsEnabled: false, + label: '', + password: '', + selectedImageID: 'linode/debian9', + formIsSubmitting: false + }; + + setImageID = (id: string) => { + /** allows for de-selecting an image */ + if (id === this.state.selectedImageID) { + return this.setState({ selectedImageID: undefined }); + } + return this.setState({ selectedImageID: 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 + */ + this.setState({ + selectedLinodeID: id, + selectedDiskSize: diskSize, + selectedTypeID: undefined + }); + } + }; + + setStackScript = ( + id: number, + label: string, + username: string, + userDefinedFields: Linode.StackScript.UserDefinedField[], + images: Linode.Image[], + defaultData?: any + ) => + this.setState({ + selectedStackScriptID: id, + selectedStackScriptLabel: label, + selectedStackScriptUsername: username, + availableUserDefinedFields: userDefinedFields, + availableStackScriptImages: images, + udfs: defaultData + }); + + setDiskSize = (size: number) => this.setState({ selectedDiskSize: size }); + + setLabel = ( + event: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + > + ) => this.setState({ label: event.target.value }); + + 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 }); + + submitForm: HandleSubmit = (type, payload, linodeID?: number) => { + /** + * run a certain linode action based on the type + * if clone, run clone service request and upsert linode + * if create, run create action + */ + if (type === 'clone' && !linodeID) { + return this.setState( + () => ({ + errors: [ + { + reason: 'You must select a Linode to clone from', + field: 'linode_id' + } + ] + }), + () => scrollErrorIntoView() + ); + } + + 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 = + type === 'clone' + ? () => 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 (type === 'clone') { + 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 + */ + 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; + 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; + } + 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; + } + + const selectedImage = this.props.imagesData.find( + image => image.id === selectedImageID + ); + + return ( + selectedImage && { + title: `${selectedImage.vendor || selectedImage.label}`, + details: `${selectedImage.vendor ? selectedImage.label : ''}` + } + ); + }; + + render() { + return ( + + + + + + Create New Linode + + + + + + ); + } +} + +const mapStateToProps: MapState = 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') +}); + +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) +})); + +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 +)(LinodeCreateContainer); diff --git a/src/features/linodes/LinodesCreate/LinodesCreate.test.tsx b/src/features/linodes/LinodesCreate/LinodesCreate.test.tsx index db95abf0fcd..1257c1f8654 100644 --- a/src/features/linodes/LinodesCreate/LinodesCreate.test.tsx +++ b/src/features/linodes/LinodesCreate/LinodesCreate.test.tsx @@ -17,7 +17,7 @@ const dummyProps = { } }; -describe('FromImageContent', () => { +xdescribe('FromImageContent', () => { const component = shallow( = 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); +// 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.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/SelectImagePanel.tsx b/src/features/linodes/LinodesCreate/SelectImagePanel.tsx index 9e943ff5c7b..e9d22301273 100644 --- a/src/features/linodes/LinodesCreate/SelectImagePanel.tsx +++ b/src/features/linodes/LinodesCreate/SelectImagePanel.tsx @@ -59,7 +59,7 @@ interface Props { images: Linode.Image[]; title?: string; error?: string; - selectedImageID: string | null; + selectedImageID?: string; handleSelection: (id: string) => void; hideMyImages?: boolean; initTab?: number; diff --git a/src/features/linodes/LinodesCreate/SelectPlanPanel.tsx b/src/features/linodes/LinodesCreate/SelectPlanPanel.tsx index 4b17c4a465a..258990d3eb0 100644 --- a/src/features/linodes/LinodesCreate/SelectPlanPanel.tsx +++ b/src/features/linodes/LinodesCreate/SelectPlanPanel.tsx @@ -33,7 +33,7 @@ interface Props { types: ExtendedType[]; error?: string; onSelect: (key: string) => void; - selectedID: string | null; + selectedID?: string; selectedDiskSize?: number; currentPlanHeading?: string; disabled?: boolean; diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx index 040b3f08347..dca8bc8ea6c 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.test.tsx @@ -63,7 +63,7 @@ const mockPropsWithNotice = { getLabel: jest.fn() }; -describe('FromBackupsContent', () => { +xdescribe('FromBackupsContent', () => { const component = shallow( = theme => ({ - root: {}, - main: {}, - sidebar: { - [theme.breakpoints.up('lg')]: { - marginTop: -130 - } - } -}); - -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[]; -} - -type CombinedProps = Props & - LinodeActionsProps & - InjectedNotistackProps & - LabelProps & - WithStyles; - -interface Notice { - text: string; - level: 'warning' | 'error'; // most likely only going to need these two -} - -const errorResources = { - type: 'A plan selection', - region: 'region', - label: 'A label', - root_pass: 'A root password', - tags: 'Tags for this Linode' -}; - -const filterLinodesWithBackups = (linodes: Linode.LinodeWithBackups[]) => { - return linodes.filter(linode => { - const hasAutomaticBackups = !!linode.currentBackups.automatic.length; - const hasSnapshotBackup = !!linode.currentBackups.snapshot.current; - // backups both need to be enabled and some backups need to exist - // for the panel to show the Linode - return linode.backups.enabled && (hasAutomaticBackups || hasSnapshotBackup); - }); -}; - -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: [] - }; - - mounted: boolean = false; - - getLinodesWithBackups = (linodes: Linode.Linode[]) => { - this.setState({ isGettingBackups: true }); - return Promise.map( - linodes.filter(l => l.backups.enabled), - (linode: Linode.Linode) => { - return getLinodeBackups(linode.id).then(backups => { - return { - ...linode, - currentBackups: { - ...backups - } - }; - }); - } - ) - .then(data => { - if (!this.mounted) { - return; - } - this.setState({ linodesWithBackups: data, isGettingBackups: false }); - }) - .catch(err => this.setState({ isGettingBackups: false })); - }; - - userHasBackups = () => { - const { linodesWithBackups } = this.state; - return linodesWithBackups!.some((linode: Linode.LinodeWithBackups) => { - // automatic backups is an array, but snapshots are either null or an object - // user can have up to 3 automatic backups, but one one snapshot - return ( - !!linode.currentBackups.automatic.length || - !!linode.currentBackups.snapshot.current - ); - }); - }; - - 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, - selectedTypeID, - backups, - privateIP, - selectedBackupID, - tags - } = this.state; - - this.setState({ isMakingRequest: true }); - - const label = this.label(); - - createLinode({ - region: selectedRegionID, - type: selectedTypeID, - backup_id: Number(selectedBackupID), - label: label ? label : null /* optional */, - backups_enabled: backups /* 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 }); - }); - }; - - componentWillUnmount() { - this.mounted = false; - } - - componentDidMount() { - this.mounted = true; - this.getLinodesWithBackups(this.props.linodes); - const { selectedLinodeID } = this.state; - // 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 = () => { - const { - linodesWithBackups, - selectedBackupID, - selectedLinodeID - } = 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 { - errors, - selectedBackupID, - selectedDiskSize, - selectedLinodeID, - tags, - selectedTypeID, - selectedRegionID, - backups, - linodesWithBackups, - privateIP, - selectedBackupInfo, - isMakingRequest - } = this.state; - const { - accountBackups, - extendLinodes, - getBackupsMonthlyPrice, - classes, - notice, - types, - getRegionInfo, - getTypeInfo, - updateCustomLabel, - disabled - } = 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(); - - return ( - - - {this.state.isGettingBackups ? ( - - ) : !this.userHasBackups() ? ( - - ) : ( - - - {notice && !disabled && ( - - )} - {generalError && } - - extendLinodes(linodes), - filterLinodesWithBackups - )(linodesWithBackups!)} - selectedLinodeID={selectedLinodeID} - handleSelection={this.handleSelectLinode} - updateFor={[selectedLinodeID, errors, classes]} - disabled={disabled} - /> - { - return linode.id === +selectedLinodeID!; - } - )} - selectedLinodeID={selectedLinodeID} - selectedBackupID={selectedBackupID} - handleChangeBackup={this.handleSelectBackupID} - handleChangeBackupInfo={this.handleSelectBackupInfo} - updateFor={[ - selectedLinodeID, - selectedBackupID, - errors, - classes - ]} - /> - - - - - )} - - {!this.userHasBackups() ? ( - - ) : ( - - - {(props: StickyProps) => { - const displaySections = []; - if (imageInfo) { - displaySections.push(imageInfo); - } - - if (regionInfo) { - displaySections.push({ - title: regionInfo.title, - details: regionInfo.details - }); - } - - if (typeInfo) { - displaySections.push(typeInfo); - } - - if (hasBackups && typeInfo && typeInfo.backupsMonthly) { - displaySections.push( - renderBackupsDisplaySection( - accountBackups, - typeInfo.backupsMonthly - ) - ); - } - - let calculatedPrice = pathOr(0, ['monthly'], typeInfo); - if (hasBackups && typeInfo && typeInfo.backupsMonthly) { - calculatedPrice += typeInfo.backupsMonthly; - } - - return ( - - ); - }} - - - )} - - ); - } -} - -const styled = withStyles(styles); - -const enhanced = compose( - styled, - withSnackbar, - withLabelGenerator, - withLinodeActions -); - -export default enhanced(FromBackupsContent); +// 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 { +// StyleRulesCallback, +// withStyles, +// WithStyles +// } from 'src/components/core/styles'; +// 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 { renderBackupsDisplaySection } from './utils'; + +// type ClassNames = 'root' | 'main' | 'sidebar'; + +// const styles: StyleRulesCallback = theme => ({ +// root: {}, +// main: {}, +// sidebar: { +// [theme.breakpoints.up('lg')]: { +// marginTop: -130 +// } +// } +// }); + +// 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[]; +// } + +// type CombinedProps = Props & +// LinodeActionsProps & +// InjectedNotistackProps & +// LabelProps & +// WithStyles; + +// interface Notice { +// text: string; +// level: 'warning' | 'error'; // most likely only going to need these two +// } + +// const errorResources = { +// type: 'A plan selection', +// region: 'A region selection', +// label: 'A label', +// root_pass: 'A root password', +// tags: 'Tags for this Linode' +// }; + +// const filterLinodesWithBackups = (linodes: Linode.LinodeWithBackups[]) => { +// return linodes.filter(linode => { +// const hasAutomaticBackups = !!linode.currentBackups.automatic.length; +// const hasSnapshotBackup = !!linode.currentBackups.snapshot.current; +// // backups both need to be enabled and some backups need to exist +// // for the panel to show the Linode +// return linode.backups.enabled && (hasAutomaticBackups || hasSnapshotBackup); +// }); +// }; + +// 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: [] +// }; + +// mounted: boolean = false; + +// getLinodesWithBackups = (linodes: Linode.Linode[]) => { +// this.setState({ isGettingBackups: true }); +// return Promise.map( +// linodes.filter(l => l.backups.enabled), +// (linode: Linode.Linode) => { +// return getLinodeBackups(linode.id).then(backups => { +// return { +// ...linode, +// currentBackups: { +// ...backups +// } +// }; +// }); +// } +// ) +// .then(data => { +// if (!this.mounted) { +// return; +// } +// this.setState({ linodesWithBackups: data, isGettingBackups: false }); +// }) +// .catch(err => this.setState({ isGettingBackups: false })); +// }; + +// userHasBackups = () => { +// const { linodesWithBackups } = this.state; +// return linodesWithBackups!.some((linode: Linode.LinodeWithBackups) => { +// // automatic backups is an array, but snapshots are either null or an object +// // user can have up to 3 automatic backups, but one one snapshot +// return ( +// !!linode.currentBackups.automatic.length || +// !!linode.currentBackups.snapshot.current +// ); +// }); +// }; + +// 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, +// selectedTypeID, +// backups, +// privateIP, +// selectedBackupID, +// tags +// } = this.state; + +// this.setState({ isMakingRequest: true }); + +// const label = this.label(); + +// createLinode({ +// region: selectedRegionID, +// type: selectedTypeID, +// backup_id: Number(selectedBackupID), +// label: label ? label : null /* optional */, +// backups_enabled: backups /* 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 }); +// }); +// }; + +// componentWillUnmount() { +// this.mounted = false; +// } + +// componentDidMount() { +// this.mounted = true; +// this.getLinodesWithBackups(this.props.linodes); +// const { selectedLinodeID } = this.state; +// // 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 = () => { +// const { +// linodesWithBackups, +// selectedBackupID, +// selectedLinodeID +// } = 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 { +// errors, +// selectedBackupID, +// selectedDiskSize, +// selectedLinodeID, +// tags, +// selectedTypeID, +// selectedRegionID, +// backups, +// linodesWithBackups, +// privateIP, +// selectedBackupInfo, +// isMakingRequest +// } = this.state; +// const { +// accountBackups, +// extendLinodes, +// getBackupsMonthlyPrice, +// classes, +// notice, +// types, +// getRegionInfo, +// getTypeInfo, +// updateCustomLabel, +// disabled +// } = 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(); + +// return ( +// +// +// {this.state.isGettingBackups ? ( +// +// ) : !this.userHasBackups() ? ( +// +// ) : ( +// +// +// {notice && !disabled && ( +// +// )} +// {generalError && } +// +// extendLinodes(linodes), +// filterLinodesWithBackups +// )(linodesWithBackups!)} +// selectedLinodeID={selectedLinodeID} +// handleSelection={this.handleSelectLinode} +// updateFor={[selectedLinodeID, errors]} +// disabled={disabled} +// /> +// { +// return linode.id === +selectedLinodeID!; +// } +// )} +// selectedLinodeID={selectedLinodeID} +// selectedBackupID={selectedBackupID} +// handleChangeBackup={this.handleSelectBackupID} +// handleChangeBackupInfo={this.handleSelectBackupInfo} +// updateFor={[selectedLinodeID, selectedBackupID, errors]} +// /> +// +// +// +// +// )} +// +// {!this.userHasBackups() ? ( +// +// ) : ( +// +// +// {(props: StickyProps) => { +// const displaySections = []; +// if (imageInfo) { +// displaySections.push(imageInfo); +// } + +// if (regionInfo) { +// displaySections.push({ +// title: regionInfo.title, +// details: regionInfo.details +// }); +// } + +// if (typeInfo) { +// displaySections.push(typeInfo); +// } + +// if (hasBackups && typeInfo && typeInfo.backupsMonthly) { +// displaySections.push( +// renderBackupsDisplaySection( +// accountBackups, +// typeInfo.backupsMonthly +// ) +// ); +// } + +// let calculatedPrice = pathOr(0, ['monthly'], typeInfo); +// if (hasBackups && typeInfo && typeInfo.backupsMonthly) { +// calculatedPrice += typeInfo.backupsMonthly; +// } + +// return ( +// +// ); +// }} +// +// +// )} +// +// ); +// } +// } + +// const styled = withStyles(styles); + +// const enhanced = compose( +// styled, +// withSnackbar, +// withLabelGenerator, +// withLinodeActions +// ); + +// export default enhanced(FromBackupsContent); diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx index bc5f83f7954..aeb991b8677 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx @@ -31,7 +31,7 @@ const mockProps = { customLabel: '' }; -describe('FromImageContent', () => { +xdescribe('FromImageContent', () => { const componentWithNotice = shallow( number | null; - getTypeInfo: (selectedTypeID: string | null) => TypeInfo; - getRegionInfo: (selectedRegionID: string | null) => Info; - history: any; - accountBackups: boolean; - handleDisablePasswordField: (imageSelected: boolean) => Disabled | undefined; - disabled?: boolean; -} - -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; +/** + * image, region, type, label, backups, privateIP, tags, error, isLoading, + */ const errorResources = { type: 'A plan selection', @@ -105,218 +66,56 @@ const errorResources = { }; 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 */, + WithStyles & + WithDisplayData & + BaseFormStateAndHandlers & + WithImagesRegionsTypesAndAccountState; + +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, + typesData: types, + regionsData: regions, + imagesData: images, + imageDisplayInfo, + regionDisplayInfo, + typeDisplayInfo, + backupsMonthlyPrice, publicOnly, - updateCustomLabel, userSSHKeys, - disabled, + userCannotCreateLinode, + errors, imagePanelTitle } = 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; return ( @@ -328,122 +127,142 @@ export class FromImageContent extends React.Component { warning={notice.level === 'warning'} /> )} - + {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 ( @@ -458,12 +277,12 @@ export class FromImageContent extends React.Component { const styled = withStyles(styles); -const enhanced = compose( +const enhanced = compose< + CombinedProps, + Props & WithDisplayData & WithImagesRegionsTypesAndAccountState +>( styled, - withSnackbar, - userSSHKeyHoc, - withLabelGenerator, - withLinodeActions + userSSHKeyHoc ); export default enhanced(FromImageContent); diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx index 497b4f098b1..1417992aa9d 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx @@ -23,30 +23,30 @@ const mockProps = { customLabel: '' }; -describe('FromImageContent', () => { +xdescribe('FromImageContent', () => { const componentWithNotice = shallow( ); const component = shallow( ); const componentWithLinodes = shallow( ); diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx index 26528de1f31..5dbd9fdbd4a 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx @@ -1,7 +1,5 @@ -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'; @@ -16,24 +14,22 @@ 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 '../utilites'; + +import { + CloneFormStateHandlers, + WithDisplayData, + WithLinodesImagesTypesAndRegions +} from '../types'; + type ClassNames = 'root' | 'main' | 'sidebar'; const styles: StyleRulesCallback = theme => ({ @@ -51,42 +47,8 @@ interface Notice { 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 = { @@ -97,173 +59,59 @@ const errorResources = { }; 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; + WithStyles & + WithDisplayData & + CloneFormStateHandlers & + WithLinodesImagesTypesAndRegions; +export class FromLinodeContent extends React.PureComponent { + /** set the Linode ID and the disk size and reset the plan selection */ handleSelectLinode = (linode: Linode.Linode) => { - if (linode.id !== this.state.selectedLinodeID) { - this.setState({ - selectedLinodeID: linode.id, - selectedTypeID: null, - selectedDiskSize: 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 }); + this.props.updateLinodeID(linode.id, linode.specs.disk); }; 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 { + notice, + 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 ( @@ -279,8 +127,8 @@ export class FromLinodeContent extends React.Component { ) : ( - - {notice && !disabled && ( + + {notice && !userCannotCreateLinode && ( { {generalError && } @@ -354,7 +202,7 @@ export class FromLinodeContent extends React.Component { if (hasBackups && typeInfo && typeInfo.backupsMonthly) { displaySections.push( renderBackupsDisplaySection( - accountBackups, + accountBackupsEnabled, typeInfo.backupsMonthly ) ); @@ -369,8 +217,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 -); +const enhanced = compose(styled); export default enhanced(FromLinodeContent); diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx index ad196670c7e..9a89887e9d7 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx @@ -31,7 +31,7 @@ const mockProps = { customLabel: '' }; -describe('FromImageContent', () => { +xdescribe('FromImageContent', () => { const componentWithNotice = shallow( 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 + ) => Promise>; + header: string; } const errorResources = { @@ -126,37 +83,15 @@ const errorResources = { tags: 'Tags' }; -type CombinedProps = Props & - LinodeActionsProps & - InjectedNotistackProps & - LabelProps & - UserSSHKeyProps & - 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: [] - }; +type InnerProps = Props & WithLinodesImagesTypesAndRegions; - mounted: boolean = false; +type CombinedProps = InnerProps & + StackScriptFormStateHandlers & + WithDisplayData & + SSHKeys & + WithStyles; +export class FromStackScriptContent extends React.PureComponent { handleSelectStackScript = ( id: number, label: string, @@ -164,278 +99,120 @@ 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 } - }); - }; + const newUDFData = assocPath([key], value, this.props.selectedUDFs); - 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 }); - }; - - getImageInfo = (image: Linode.Image | undefined): Info => { - return ( - image && { - title: `${image.vendor || image.label}`, - details: `${image.vendor ? image.label : ''}` - } - ); - }; - - createFromStackScript = () => { - if (!this.state.selectedStackScriptID) { - this.setState( - { - errors: [ - { field: 'stackscript_id', reason: 'You must select a StackScript' } - ] - }, - () => { - scrollErrorIntoView(); - } - ); - return; - } - this.createLinode(); + this.props.handleSelectUDFs({ ...this.props.selectedUDFs, ...newUDFData }); }; - createLinode = () => { + handleCreateLinode = () => { const { - history, + backupsEnabled, + password, 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, 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, + notice, + 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 ( @@ -449,31 +226,33 @@ export class FromStackScriptContent extends React.Component< /> )} {generalError && } - null} disabled={disabled} + request={request} /> {!disabled && userDefinedFields && userDefinedFields.length > 0 && ( )} {!disabled && compatibleImages && compatibleImages.length > 0 ? ( 0 && selectedImageID ? userSSHKeys : []} /> @@ -574,42 +354,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 ( @@ -623,14 +403,38 @@ export class FromStackScriptContent extends React.Component< } } +/** + * @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 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 = (errors?: Linode.ApiFieldError[]) => { + return !errors + ? [] + : errors.filter(eachError => { + return !Object.keys(errorResources).some( + eachKey => eachKey === eachError.field + ); + }); +}; + const styled = withStyles(styles); -const enhanced = compose( +const enhanced = compose( styled, - withSnackbar, - userSSHKeyHoc, - withLabelGenerator, - withLinodeActions + userSSHKeyHoc ); -export default enhanced(FromStackScriptContent) as any; +export default enhanced(FromStackScriptContent); 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..20e0ae49298 --- /dev/null +++ b/src/features/linodes/LinodesCreate/types.ts @@ -0,0 +1,137 @@ +import { ExtendedRegion } from 'src/components/SelectRegionPanel'; +import { Tag } from 'src/components/TagsInput'; +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; + +export interface WithDisplayData { + typeDisplayInfo?: TypeInfo; + regionDisplayInfo?: Info; + imageDisplayInfo?: Info; + backupsMonthlyPrice?: number | null; +} + +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[]; +} + +interface WithTypesProps { + typesData: ExtendedType[]; +} + +export interface ReduxStateProps { + accountBackupsEnabled: boolean; + userCannotCreateLinode: boolean; +} + +export type HandleSubmit = ( + type: 'create' | 'clone' | 'createFromStackScript', + 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; +} + +/** + * 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; +} + +export type AllFormStateAndHandlers = BaseFormStateAndHandlers & + CloneFormStateHandlers & + StackScriptFormStateHandlers; + +export type WithLinodesImagesTypesAndRegions = WithImagesProps & + WithLinodesProps & + WithRegions & + WithTypesProps & + ReduxStateProps; + +export type WithImagesRegionsTypesAndAccountState = WithImagesProps & + WithRegions & + WithTypesProps & + ReduxStateProps; 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/utilites.ts b/src/features/linodes/LinodesCreate/utilites.ts new file mode 100644 index 00000000000..9f7fa75ac89 --- /dev/null +++ b/src/features/linodes/LinodesCreate/utilites.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/index.tsx b/src/features/linodes/index.tsx index 33bb55ea56c..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/CALinodeCreate') + loader: () => import('./LinodesCreate/LinodeCreateContainer') }); const LinodesDetail = DefaultLoader({ 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[]; From fd964b001af2e89c0a6dc307677dd4ff6910b715 Mon Sep 17 00:00:00 2001 From: Jared Date: Mon, 4 Mar 2019 08:26:03 -0500 Subject: [PATCH 05/44] Add Cloudapps icons to src/assets (#4587) --- src/assets/cloudapps/Ark@1x.svg | 3 +++ src/assets/cloudapps/CSGO2.svg | 3 +++ src/assets/cloudapps/Drupal.svg | 14 +++++++++++++ src/assets/cloudapps/GitLab.svg | 17 +++++++++++++++ src/assets/cloudapps/LAMP.svg | 11 ++++++++++ src/assets/cloudapps/MERN.svg | 24 +++++++++++++++++++++ src/assets/cloudapps/Minecraft.svg | 10 +++++++++ src/assets/cloudapps/OpenVPN.svg | 12 +++++++++++ src/assets/cloudapps/Rust.svg | 3 +++ src/assets/cloudapps/TF2.svg | 11 ++++++++++ src/assets/cloudapps/Terraria.svg | 3 +++ src/assets/cloudapps/Wireguard.svg | 31 ++++++++++++++++++++++++++++ src/assets/cloudapps/WooCommerce.svg | 12 +++++++++++ src/assets/cloudapps/WordPress.svg | 11 ++++++++++ 14 files changed, 165 insertions(+) create mode 100644 src/assets/cloudapps/Ark@1x.svg create mode 100644 src/assets/cloudapps/CSGO2.svg create mode 100644 src/assets/cloudapps/Drupal.svg create mode 100644 src/assets/cloudapps/GitLab.svg create mode 100644 src/assets/cloudapps/LAMP.svg create mode 100644 src/assets/cloudapps/MERN.svg create mode 100644 src/assets/cloudapps/Minecraft.svg create mode 100644 src/assets/cloudapps/OpenVPN.svg create mode 100644 src/assets/cloudapps/Rust.svg create mode 100644 src/assets/cloudapps/TF2.svg create mode 100644 src/assets/cloudapps/Terraria.svg create mode 100644 src/assets/cloudapps/Wireguard.svg create mode 100644 src/assets/cloudapps/WooCommerce.svg create mode 100644 src/assets/cloudapps/WordPress.svg diff --git a/src/assets/cloudapps/Ark@1x.svg b/src/assets/cloudapps/Ark@1x.svg new file mode 100644 index 00000000000..95e26baf552 --- /dev/null +++ b/src/assets/cloudapps/Ark@1x.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/cloudapps/CSGO2.svg b/src/assets/cloudapps/CSGO2.svg new file mode 100644 index 00000000000..7157526a6f0 --- /dev/null +++ b/src/assets/cloudapps/CSGO2.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/cloudapps/Drupal.svg b/src/assets/cloudapps/Drupal.svg new file mode 100644 index 00000000000..f9f9874936c --- /dev/null +++ b/src/assets/cloudapps/Drupal.svg @@ -0,0 +1,14 @@ + + + + Drupal + Created with Sketch. + + + + + + + + + \ No newline at end of file diff --git a/src/assets/cloudapps/GitLab.svg b/src/assets/cloudapps/GitLab.svg new file mode 100644 index 00000000000..f1b682aac33 --- /dev/null +++ b/src/assets/cloudapps/GitLab.svg @@ -0,0 +1,17 @@ + + + + GitLab + Created with Sketch. + + + + \ No newline at end of file diff --git a/src/assets/cloudapps/LAMP.svg b/src/assets/cloudapps/LAMP.svg new file mode 100644 index 00000000000..270a5ed1a84 --- /dev/null +++ b/src/assets/cloudapps/LAMP.svg @@ -0,0 +1,11 @@ + + + + LAMP + Created with Sketch. + + + + + + \ No newline at end of file diff --git a/src/assets/cloudapps/MERN.svg b/src/assets/cloudapps/MERN.svg new file mode 100644 index 00000000000..f482fcd2cfc --- /dev/null +++ b/src/assets/cloudapps/MERN.svg @@ -0,0 +1,24 @@ + + + + MERN + Created with Sketch. + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/cloudapps/Minecraft.svg b/src/assets/cloudapps/Minecraft.svg new file mode 100644 index 00000000000..a7a88b46fa2 --- /dev/null +++ b/src/assets/cloudapps/Minecraft.svg @@ -0,0 +1,10 @@ + + + + Minecraft + Created with Sketch. + + + + + \ No newline at end of file diff --git a/src/assets/cloudapps/OpenVPN.svg b/src/assets/cloudapps/OpenVPN.svg new file mode 100644 index 00000000000..9fe24377c6c --- /dev/null +++ b/src/assets/cloudapps/OpenVPN.svg @@ -0,0 +1,12 @@ + + + + OpenVPN + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/src/assets/cloudapps/Rust.svg b/src/assets/cloudapps/Rust.svg new file mode 100644 index 00000000000..66707193cb3 --- /dev/null +++ b/src/assets/cloudapps/Rust.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/cloudapps/TF2.svg b/src/assets/cloudapps/TF2.svg new file mode 100644 index 00000000000..35bd7b4c128 --- /dev/null +++ b/src/assets/cloudapps/TF2.svg @@ -0,0 +1,11 @@ + + + + TF2 + Created with Sketch. + + + + + + \ No newline at end of file diff --git a/src/assets/cloudapps/Terraria.svg b/src/assets/cloudapps/Terraria.svg new file mode 100644 index 00000000000..71f0f922c8c --- /dev/null +++ b/src/assets/cloudapps/Terraria.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/cloudapps/Wireguard.svg b/src/assets/cloudapps/Wireguard.svg new file mode 100644 index 00000000000..928f43c9ccf --- /dev/null +++ b/src/assets/cloudapps/Wireguard.svg @@ -0,0 +1,31 @@ + + + + Wireguard + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/cloudapps/WooCommerce.svg b/src/assets/cloudapps/WooCommerce.svg new file mode 100644 index 00000000000..0f12d691396 --- /dev/null +++ b/src/assets/cloudapps/WooCommerce.svg @@ -0,0 +1,12 @@ + + + + WooCommerce + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/src/assets/cloudapps/WordPress.svg b/src/assets/cloudapps/WordPress.svg new file mode 100644 index 00000000000..004fdc7b734 --- /dev/null +++ b/src/assets/cloudapps/WordPress.svg @@ -0,0 +1,11 @@ + + + + WordPress + Created with Sketch. + + + + + + \ No newline at end of file From 1d9c9c2cb50a03abd9ed17939677d579d0a050ea Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 4 Mar 2019 10:51:17 -0500 Subject: [PATCH 06/44] fix tabs render bug (#4592) --- .../linodes/LinodesCreate/CALinodeCreate.tsx | 6 +-- .../LinodesCreate/CALinodeCreateSubTabs.tsx | 40 +++++++++++++------ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx index 99133f26062..082cb705610 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx @@ -57,12 +57,12 @@ export class LinodeCreate extends React.PureComponent { event: React.ChangeEvent, value: number ) => { - this.props.history.push({ - search: `?type=${event.target.textContent}` - }); this.setState({ selectedTab: value }); + this.props.history.push({ + search: `?type=${event.target.textContent}` + }); }; tabs: Tab[] = [ diff --git a/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx b/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx index fe78f147ff1..361ede5ad49 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx @@ -22,22 +22,26 @@ interface State { type CombinedProps = Props; -class CALinodeCreateSubTabs extends React.PureComponent { +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); const tabsToRender = this.getTabsToRender(props.type, props.tabs); - /** 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; - }); - this.state = { - selectedTab: preSelectedTab !== -1 ? preSelectedTab : 0 + selectedTab: determinePreselectedTab(tabsToRender) }; } @@ -51,7 +55,7 @@ class CALinodeCreateSubTabs extends React.PureComponent { { title: 'Community StackScripts', render: () => { - return ; + return
community stackscripts
; } } ]; @@ -80,9 +84,21 @@ class CALinodeCreateSubTabs extends React.PureComponent { render() { const { type, tabs } = this.props; - const { selectedTab } = this.state; + const { selectedTab: selectedTabFromState } = this.state; const tabsToRender = this.getTabsToRender(type, tabs); + 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 = tabsToRender[selectedTab].render; return ( From fad9c74d889a929cf23e56bc00a032da7194f243 Mon Sep 17 00:00:00 2001 From: Jared Date: Mon, 4 Mar 2019 11:33:29 -0500 Subject: [PATCH 07/44] Reset creation state on tab change (#4595) --- .../linodes/LinodesCreate/CALinodeCreate.tsx | 11 ++++++- .../LinodesCreate/CALinodeCreateSubTabs.tsx | 3 ++ .../LinodesCreate/LinodeCreateContainer.tsx | 29 ++++++++++++++----- src/features/linodes/LinodesCreate/types.ts | 1 + 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx index 082cb705610..cee93f54889 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx @@ -57,6 +57,8 @@ export class LinodeCreate extends React.PureComponent { event: React.ChangeEvent, value: number ) => { + this.props.resetCreationState(); + this.setState({ selectedTab: value }); @@ -89,7 +91,13 @@ export class LinodeCreate extends React.PureComponent { { title: 'One-Click', render: () => { - return ; + return ( + + ); } }, { @@ -97,6 +105,7 @@ export class LinodeCreate extends React.PureComponent { render: () => { return ( void; tabs?: Tab[]; type: 'oneClick' | 'myImages'; } @@ -71,6 +72,8 @@ class CALinodeCreateSubTabs extends React.Component { 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('?', '')); diff --git a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx index a74227883c3..820c67bf08f 100644 --- a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -74,14 +74,28 @@ type CombinedProps = InjectedNotistackProps & DispatchProps & RouteComponentProps<{}>; +const defaultState: State = { + privateIPEnabled: false, + backupsEnabled: false, + label: '', + password: '', + selectedImageID: 'linode/debian9', + selectedDiskSize: undefined, + selectedLinodeID: undefined, + selectedStackScriptID: undefined, + selectedRegionID: undefined, + selectedTypeID: undefined, + tags: [], + formIsSubmitting: false +}; + class LinodeCreateContainer extends React.PureComponent { - state: State = { - privateIPEnabled: false, - backupsEnabled: false, - label: '', - password: '', - selectedImageID: 'linode/debian9', - formIsSubmitting: false + state: State = defaultState; + + clearCreationState = () => { + this.setState({ + ...defaultState + }); }; setImageID = (id: string) => { @@ -355,6 +369,7 @@ class LinodeCreateContainer extends React.PureComponent { formIsSubmitting={this.state.formIsSubmitting} history={this.props.history} handleSubmitForm={this.submitForm} + resetCreationState={this.clearCreationState} />
diff --git a/src/features/linodes/LinodesCreate/types.ts b/src/features/linodes/LinodesCreate/types.ts index 20e0ae49298..cb104addd91 100644 --- a/src/features/linodes/LinodesCreate/types.ts +++ b/src/features/linodes/LinodesCreate/types.ts @@ -87,6 +87,7 @@ export interface BaseFormStateAndHandlers { togglePrivateIPEnabled: () => void; tags?: Tag[]; updateTags: (tags: Tag[]) => void; + resetCreationState: () => void; } /** From d34cb6864db5803a59721988b29299008903c5a1 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 4 Mar 2019 13:31:10 -0500 Subject: [PATCH 08/44] add error state to creation flow (#4596) --- .../linodes/LinodesCreate/CALinodeCreate.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx index cee93f54889..2e54771c0e0 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx @@ -4,6 +4,7 @@ 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 { getStackScriptsByUser } from 'src/features/StackScripts/stackScriptUtils'; import SubTabs, { Tab } from './CALinodeCreateSubTabs'; @@ -181,12 +182,25 @@ export class LinodeCreate extends React.PureComponent { render() { const { selectedTab } = this.state; - const { regionsLoading, imagesLoading, linodesLoading } = this.props; + const { + regionsLoading, + imagesLoading, + linodesLoading, + imagesError, + regionsError, + linodesError + } = this.props; if (regionsLoading || imagesLoading || linodesLoading) { return ; } + if (regionsError || imagesError || linodesError) { + return ( + + ); + } + if ( !this.props.regionsData || !this.props.imagesData || @@ -195,8 +209,6 @@ export class LinodeCreate extends React.PureComponent { return null; } - /** @todo handle for errors loading anything */ - const tabRender = this.tabs[selectedTab].render; return ( From 0efb931dbee1dce9bc39ddee439f62226a49cb64 Mon Sep 17 00:00:00 2001 From: Jared Date: Mon, 4 Mar 2019 13:51:55 -0500 Subject: [PATCH 09/44] CA: Move SSH HOC to container (#4597) * Move SSH key HOC to container * Reset selected SSH keys correctly --- .../linodes/LinodesCreate/CALinodeCreate.tsx | 2 ++ .../LinodesCreate/LinodeCreateContainer.tsx | 14 +++++++++---- .../TabbedContent/FromImageContent.tsx | 20 ++++--------------- .../TabbedContent/FromStackScriptContent.tsx | 15 ++++---------- src/features/linodes/LinodesCreate/types.ts | 3 +++ src/features/linodes/userSSHKeyHoc.ts | 16 +++++++++++++-- 6 files changed, 37 insertions(+), 33 deletions(-) diff --git a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx index 2e54771c0e0..d37ad45a5e7 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx @@ -14,6 +14,7 @@ import FromStackScriptContent from './TabbedContent/FromStackScriptContent'; import { AllFormStateAndHandlers, + WithAll, WithDisplayData, WithLinodesImagesTypesAndRegions } from './types'; @@ -25,6 +26,7 @@ interface Props { type CombinedProps = Props & WithLinodesImagesTypesAndRegions & WithDisplayData & + WithAll & AllFormStateAndHandlers; interface State { diff --git a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx index 820c67bf08f..59cffe9d4b8 100644 --- a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -21,6 +21,9 @@ import { Tag } from 'src/components/TagsInput'; import { dcDisplayNames } from 'src/constants'; import { typeLabelDetails } from 'src/features/linodes/presentation'; +import userSSHKeyHoc, { + State as userSSHKeyProps +} from 'src/features/linodes/userSSHKeyHoc'; import { hasGrant, isRestrictedUser @@ -71,6 +74,7 @@ interface State { type CombinedProps = InjectedNotistackProps & LinodeActionsProps & WithLinodesImagesTypesAndRegions & + userSSHKeyProps & DispatchProps & RouteComponentProps<{}>; @@ -93,9 +97,8 @@ class LinodeCreateContainer extends React.PureComponent { state: State = defaultState; clearCreationState = () => { - this.setState({ - ...defaultState - }); + this.props.resetSSHKeys(); + this.setState(defaultState); }; setImageID = (id: string) => { @@ -370,6 +373,8 @@ class LinodeCreateContainer extends React.PureComponent { history={this.props.history} handleSubmitForm={this.submitForm} resetCreationState={this.clearCreationState} + userSSHKeys={this.props.userSSHKeys} + resetSSHKeys={this.props.resetSSHKeys} /> @@ -454,5 +459,6 @@ export default recompose( withLinodeActions, connected, withRouter, - withSnackbar + withSnackbar, + userSSHKeyHoc )(LinodeCreateContainer); diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx index 265a7aade85..85147022500 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx @@ -14,20 +14,13 @@ 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 userSSHKeyHoc, { - State as UserSSHKeyProps -} from 'src/features/linodes/userSSHKeyHoc'; import getAPIErrorsFor from 'src/utilities/getAPIErrorFor'; import AddonsPanel from '../AddonsPanel'; import SelectImagePanel from '../SelectImagePanel'; import SelectPlanPanel from '../SelectPlanPanel'; import { renderBackupsDisplaySection } from './utils'; -import { - BaseFormStateAndHandlers, - WithDisplayData, - WithImagesRegionsTypesAndAccountState -} from '../types'; +import { BaseFormStateAndHandlers, WithAll, WithDisplayData } from '../types'; type ClassNames = 'root' | 'main' | 'sidebar'; @@ -66,11 +59,10 @@ const errorResources = { }; type CombinedProps = Props & - UserSSHKeyProps & WithStyles & WithDisplayData & BaseFormStateAndHandlers & - WithImagesRegionsTypesAndAccountState; + WithAll; export class FromImageContent extends React.PureComponent { /** create the Linode */ @@ -277,12 +269,8 @@ export class FromImageContent extends React.PureComponent { const styled = withStyles(styles); -const enhanced = compose< - CombinedProps, - Props & WithDisplayData & WithImagesRegionsTypesAndAccountState ->( - styled, - userSSHKeyHoc +const enhanced = compose( + styled ); export default enhanced(FromImageContent); diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx index 572151c0e6d..8fd944706f5 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx @@ -17,9 +17,6 @@ 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 userSSHKeyHoc, { - State as SSHKeys -} from 'src/features/linodes/userSSHKeyHoc'; import CASelectStackScriptPanel from 'src/features/StackScripts/SelectStackScriptPanel/CASelectStackScriptPanel'; import StackScriptDrawer from 'src/features/StackScripts/StackScriptDrawer'; import UserDefinedFieldsPanel from 'src/features/StackScripts/UserDefinedFieldsPanel'; @@ -31,8 +28,8 @@ import { renderBackupsDisplaySection } from './utils'; import { StackScriptFormStateHandlers, - WithDisplayData, - WithLinodesImagesTypesAndRegions + WithAll, + WithDisplayData } from '../types'; type ClassNames = @@ -83,12 +80,11 @@ const errorResources = { tags: 'Tags' }; -type InnerProps = Props & WithLinodesImagesTypesAndRegions; +type InnerProps = Props & WithAll; type CombinedProps = InnerProps & StackScriptFormStateHandlers & WithDisplayData & - SSHKeys & WithStyles; export class FromStackScriptContent extends React.PureComponent { @@ -432,9 +428,6 @@ export const filterUDFErrors = (errors?: Linode.ApiFieldError[]) => { const styled = withStyles(styles); -const enhanced = compose( - styled, - userSSHKeyHoc -); +const enhanced = compose(styled); export default enhanced(FromStackScriptContent); diff --git a/src/features/linodes/LinodesCreate/types.ts b/src/features/linodes/LinodesCreate/types.ts index cb104addd91..7577d4c10c7 100644 --- a/src/features/linodes/LinodesCreate/types.ts +++ b/src/features/linodes/LinodesCreate/types.ts @@ -1,5 +1,6 @@ import { ExtendedRegion } from 'src/components/SelectRegionPanel'; import { Tag } from 'src/components/TagsInput'; +import { State as userSSHKeysProps } from 'src/features/linodes/userSSHKeyHoc'; import { CreateLinodeRequest } from 'src/services/linodes'; import { ExtendedType } from './SelectPlanPanel'; @@ -136,3 +137,5 @@ export type WithImagesRegionsTypesAndAccountState = WithImagesProps & WithRegions & WithTypesProps & ReduxStateProps; + +export type WithAll = WithImagesRegionsTypesAndAccountState & userSSHKeysProps; 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; From 3576840095d9ba4f2f7043e20b731560df82459d Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 4 Mar 2019 16:29:51 -0500 Subject: [PATCH 10/44] CLAPPS - Tab State in Redux (#4598) * set create flow type in redux state * do it better this time * bad spelling --- .../linodes/LinodesCreate/CALinodeCreate.tsx | 72 ++++++++++++++++--- .../LinodesCreate/CALinodeCreateSubTabs.tsx | 43 ++++------- .../LinodesCreate/LinodeCreateContainer.tsx | 1 + src/store/index.ts | 11 ++- .../linodeCreate/linodeCreate.actions.ts | 12 ++++ .../linodeCreate/linodeCreate.reducer.ts | 65 +++++++++++++++++ 6 files changed, 161 insertions(+), 43 deletions(-) create mode 100644 src/store/linodeCreate/linodeCreate.actions.ts create mode 100644 src/store/linodeCreate/linodeCreate.reducer.ts diff --git a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx index d37ad45a5e7..1f05f8d80bd 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx @@ -1,5 +1,6 @@ import { parse } from 'querystring'; 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'; @@ -12,6 +13,11 @@ 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, WithAll, @@ -33,8 +39,11 @@ interface State { selectedTab: number; } -export class LinodeCreate extends React.PureComponent { - constructor(props: CombinedProps) { +export class LinodeCreate extends React.PureComponent< + CombinedProps & DispatchProps, + State +> { + constructor(props: CombinedProps & DispatchProps) { super(props); /** get the query params as an object, excluding the "?" */ @@ -62,17 +71,21 @@ export class LinodeCreate extends React.PureComponent { ) => { this.props.resetCreationState(); - this.setState({ - selectedTab: value - }); + /** 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: 'Distros', + title: 'Distributions', + type: 'fromImage', render: () => { /** ...rest being all the formstate props and display data */ const { @@ -93,25 +106,28 @@ export class LinodeCreate extends React.PureComponent { }, { title: 'One-Click', + type: 'fromApp', render: () => { return ( ); } }, { title: 'My Images', + type: 'fromImage', render: () => { return ( ); } @@ -121,12 +137,14 @@ export class LinodeCreate extends React.PureComponent { myImagesTabs = (): Tab[] => [ { title: 'Backups and My Images', + type: 'fromBackup', render: () => { return ; } }, { - title: 'Clone From Existing Linode', + title: 'Clone from Existing Linode', + type: 'fromLinode', render: () => { /** * rest being just the props that FromLinodeContent needs @@ -158,6 +176,7 @@ export class LinodeCreate extends React.PureComponent { }, { title: 'My StackScripts', + type: 'fromStackScript', render: () => { const { accountBackupsEnabled, @@ -177,6 +196,23 @@ export class LinodeCreate extends React.PureComponent { } ]; + oneClickTabs = (): Tab[] => [ + { + title: 'One-Click Apps', + type: 'fromApp', + render: () => { + return ; + } + }, + { + title: 'Community StackScripts', + type: 'fromStackScript', + render: () => { + return
community stackscripts
; + } + } + ]; + componentWillUnmount() { this.mounted = false; } @@ -241,4 +277,20 @@ export class LinodeCreate extends React.PureComponent { } } -export default LinodeCreate; +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/CALinodeCreateSubTabs.tsx b/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx index 987b2bbc80e..3501ba09451 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx @@ -5,16 +5,19 @@ import MUITab from 'src/components/core/Tab'; import Tabs from 'src/components/core/Tabs'; import Grid from 'src/components/Grid'; +import { CreateTypes } from 'src/store/linodeCreate/linodeCreate.actions'; + export interface Tab { title: string; render: () => JSX.Element; + type: CreateTypes; } interface Props { history: any; reset: () => void; - tabs?: Tab[]; - type: 'oneClick' | 'myImages'; + tabs: Tab[]; + handleClick: (value: CreateTypes) => void; } interface State { @@ -39,35 +42,11 @@ class CALinodeCreateSubTabs extends React.Component { constructor(props: CombinedProps) { super(props); - const tabsToRender = this.getTabsToRender(props.type, props.tabs); - this.state = { - selectedTab: determinePreselectedTab(tabsToRender) + selectedTab: determinePreselectedTab(props.tabs) }; } - oneClickTabs: Tab[] = [ - { - title: 'One-Click Apps', - render: () => { - return ; - } - }, - { - title: 'Community StackScripts', - render: () => { - return
community stackscripts
; - } - } - ]; - - getTabsToRender = (type: string, tabs?: Tab[]) => { - if (tabs) { - return tabs; - } - return type === 'oneClick' ? this.oneClickTabs : []; - }; - handleTabChange = ( event: React.ChangeEvent, value: number @@ -77,6 +56,9 @@ class CALinodeCreateSubTabs extends React.Component { /** 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}` }); @@ -86,10 +68,9 @@ class CALinodeCreateSubTabs extends React.Component { }; render() { - const { type, tabs } = this.props; + const { tabs } = this.props; const { selectedTab: selectedTabFromState } = this.state; - const tabsToRender = this.getTabsToRender(type, tabs); const queryParams = parse(location.search.replace('?', '')); /** @@ -102,7 +83,7 @@ class CALinodeCreateSubTabs extends React.Component { */ const selectedTab = !queryParams.subtype ? 0 : selectedTabFromState; - const selectedTabContentRender = tabsToRender[selectedTab].render; + const selectedTabContentRender = tabs[selectedTab].render; return ( @@ -116,7 +97,7 @@ class CALinodeCreateSubTabs extends React.Component { variant="scrollable" scrollButtons="on" > - {tabsToRender.map((tab, idx) => ( + {tabs.map((tab, idx) => ( ({ 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..ebc2ab45e8c --- /dev/null +++ b/src/store/linodeCreate/linodeCreate.reducer.ts @@ -0,0 +1,65 @@ +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; + + console.log(payload); + + return { + ...state, + type: payload + }; +}); + +export default reducer; From 00867d91616a2d5c77553d8f71ebe40137cf8fd3 Mon Sep 17 00:00:00 2001 From: Jared Date: Mon, 4 Mar 2019 17:07:35 -0500 Subject: [PATCH 11/44] CLAPPS: Add Images subtab (#4599) * WIP * WIP 2 * Fix styling * Fix StackScripts to use new SelectImagePanel * Fix tab labels * Add empty state for private images * Review feedback - Combine renderPublicImages and renderOlderPublicImages into a single function - Use instead of for placeholder message - Only pass needed props to FromImageContent * Pass fewer props to other FromImagesContent instance * Clean up post-rebase --- .../linodes/LinodesCreate/CALinodeCreate.tsx | 47 ++++- src/features/linodes/LinodesCreate/Panel.tsx | 48 +++++ .../linodes/LinodesCreate/PrivateImages.tsx | 51 ++++++ .../linodes/LinodesCreate/PublicImages.tsx | 89 ++++++++++ .../LinodesCreate/SelectImagePanel.tsx | 166 ++++-------------- .../TabbedContent/FromImageContent.tsx | 28 ++- .../TabbedContent/FromStackScriptContent.tsx | 2 +- .../linodeCreate/linodeCreate.reducer.ts | 2 - 8 files changed, 287 insertions(+), 146 deletions(-) create mode 100644 src/features/linodes/LinodesCreate/Panel.tsx create mode 100644 src/features/linodes/LinodesCreate/PrivateImages.tsx create mode 100644 src/features/linodes/LinodesCreate/PublicImages.tsx diff --git a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx index 1f05f8d80bd..d243849e532 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx @@ -93,11 +93,21 @@ export class LinodeCreate extends React.PureComponent< linodesData, linodesError, linodesLoading, + handleSelectUDFs, + selectedUDFs, + updateStackScript, + availableStackScriptImages, + availableUserDefinedFields, + selectedStackScriptID, + selectedDiskSize, + selectedStackScriptUsername, + selectedStackScriptLabel, + selectedLinodeID, ...rest } = this.props; return ( @@ -136,14 +146,45 @@ export class LinodeCreate extends React.PureComponent< myImagesTabs = (): Tab[] => [ { - title: 'Backups and My Images', + title: 'Images', + type: 'fromImage', + render: () => { + const { + history, + linodesData, + linodesError, + linodesLoading, + handleSelectUDFs, + selectedUDFs, + updateStackScript, + availableStackScriptImages, + availableUserDefinedFields, + selectedStackScriptID, + selectedDiskSize, + selectedStackScriptUsername, + selectedStackScriptLabel, + selectedLinodeID, + ...rest + } = this.props; + + return ( + + ); + } + }, + { + title: 'Backups', type: 'fromBackup', render: () => { return ; } }, { - title: 'Clone from Existing Linode', + title: 'Clone Linode', type: 'fromLinode', render: () => { /** diff --git a/src/features/linodes/LinodesCreate/Panel.tsx b/src/features/linodes/LinodesCreate/Panel.tsx new file mode 100644 index 00000000000..7c85cece145 --- /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 { + children: React.ReactElement; + error?: string; + title?: 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); \ No newline at end of file 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..a6d4335b22c --- /dev/null +++ b/src/features/linodes/LinodesCreate/PublicImages.tsx @@ -0,0 +1,89 @@ +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 = { + 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 = (images: Linode.Image[]) => + images.length && + images.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/SelectImagePanel.tsx b/src/features/linodes/LinodesCreate/SelectImagePanel.tsx index e9d22301273..7f1acb84cc4 100644 --- a/src/features/linodes/LinodesCreate/SelectImagePanel.tsx +++ b/src/features/linodes/LinodesCreate/SelectImagePanel.tsx @@ -15,45 +15,12 @@ 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[]; @@ -61,7 +28,7 @@ interface Props { error?: string; selectedImageID?: string; handleSelection: (id: string) => void; - hideMyImages?: boolean; + variant?: 'public' | 'private' | 'all'; initTab?: number; disabled?: boolean; } @@ -106,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 && } - - {props.title || 'Select an 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/TabbedContent/FromImageContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx index 85147022500..3899ed115fd 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx @@ -1,5 +1,6 @@ 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 from 'src/components/AccessPanel'; @@ -13,6 +14,7 @@ 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 from 'src/components/SelectRegionPanel'; import getAPIErrorsFor from 'src/utilities/getAPIErrorFor'; import AddonsPanel from '../AddonsPanel'; @@ -41,7 +43,7 @@ interface Notice { interface Props extends BaseFormStateAndHandlers { notice?: Notice; - publicOnly?: boolean; + variant?: 'public' | 'private' | 'all'; imagePanelTitle?: string; } @@ -97,17 +99,35 @@ export class FromImageContent extends React.PureComponent { regionDisplayInfo, typeDisplayInfo, backupsMonthlyPrice, - publicOnly, userSSHKeys, userCannotCreateLinode, errors, - imagePanelTitle + imagePanelTitle, + variant } = this.props; const hasErrorFor = getAPIErrorsFor(errorResources, errors); const generalError = hasErrorFor('none'); 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 ( @@ -122,7 +142,7 @@ export class FromImageContent extends React.PureComponent { {generalError && } { updateFor={[selectedImageID, compatibleImages, errors, classes]} selectedImageID={selectedImageID} error={hasErrorFor('image')} - hideMyImages={true} + variant="public" /> ) : ( diff --git a/src/store/linodeCreate/linodeCreate.reducer.ts b/src/store/linodeCreate/linodeCreate.reducer.ts index ebc2ab45e8c..a7bb1990a55 100644 --- a/src/store/linodeCreate/linodeCreate.reducer.ts +++ b/src/store/linodeCreate/linodeCreate.reducer.ts @@ -54,8 +54,6 @@ const reducer: Reducer = reducerWithInitialState( ).caseWithAction(handleChangeCreateType, (state, action) => { const { payload } = action; - console.log(payload); - return { ...state, type: payload From 8901f9c9dc2f9dc5bfda88230df1914ba4a8d38e Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 5 Mar 2019 13:00:27 -0500 Subject: [PATCH 12/44] CLAPPS - fix dynamic label (#4601) * fix dynamic label logic * fix logic * address feedback --- .../LinodesCreate/LinodeCreateContainer.tsx | 98 +++++++++++++++---- .../LinodesCreate/withLabelGenerator.tsx | 6 +- 2 files changed, 86 insertions(+), 18 deletions(-) diff --git a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx index 9fa47434959..36b80b482da 100644 --- a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -9,6 +9,7 @@ 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 @@ -20,6 +21,9 @@ 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, { State as userSSHKeyProps @@ -72,11 +76,13 @@ interface State { } type CombinedProps = InjectedNotistackProps & + CreateType & ReduxStateProps & LinodeActionsProps & WithLinodesImagesTypesAndRegions & userSSHKeyProps & DispatchProps & + LabelProps & RouteComponentProps<{}>; const defaultState: State = { @@ -97,6 +103,20 @@ const defaultState: State = { 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 }); + } + } + clearCreationState = () => { this.props.resetSSHKeys(); this.setState(defaultState); @@ -143,17 +163,13 @@ class LinodeCreateContainer extends React.PureComponent { selectedStackScriptUsername: username, availableUserDefinedFields: userDefinedFields, availableStackScriptImages: images, - udfs: defaultData + udfs: defaultData, + /** reset image because stackscript might not be compatible with selected one */ + selectedImageID: undefined }); setDiskSize = (size: number) => this.setState({ selectedDiskSize: size }); - setLabel = ( - event: React.ChangeEvent< - HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement - > - ) => this.setState({ label: event.target.value }); - setPassword = (password: string) => this.setState({ password }); toggleBackupsEnabled = () => @@ -166,13 +182,50 @@ class LinodeCreateContainer extends React.PureComponent { setUDFs = (udfs: any[]) => this.setState({ udfs }); + generateLabel = () => { + const { getLabel, imagesData, regionsData } = this.props; + const { selectedImageID, selectedRegionID } = this.state; + + /* tslint:disable-next-line */ + let arg1, + arg2, + arg3 = ''; + + if (selectedImageID) { + 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) { + const selectedRegion = regionsData.find( + region => region.id === selectedRegionID + ); + + arg2 = selectedRegion ? selectedRegion.id : ''; + } + + arg3 = this.props.createType; + + 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 (type === 'clone' && !linodeID) { + if (createType === 'fromLinode' && !linodeID) { return this.setState( () => ({ errors: [ @@ -186,7 +239,7 @@ class LinodeCreateContainer extends React.PureComponent { ); } - if (type === 'createFromStackScript' && !this.state.selectedStackScriptID) { + if (createType === 'fromStackScript' && !this.state.selectedStackScriptID) { return this.setState( () => ({ errors: [ @@ -201,7 +254,7 @@ class LinodeCreateContainer extends React.PureComponent { } const request = - type === 'clone' + createType === 'fromLinode' ? () => cloneLinode(linodeID!, payload) : () => this.props.linodeActions.createLinode(payload); @@ -212,7 +265,7 @@ class LinodeCreateContainer extends React.PureComponent { this.setState({ formIsSubmitting: false }); /** if cloning a Linode, upsert Linode in redux */ - if (type === 'clone') { + if (createType === 'fromLinode') { this.props.upsertLinode(response); } @@ -227,7 +280,9 @@ class LinodeCreateContainer extends React.PureComponent { /** * allocate private IP if we have one * - * @todo we need to update redux state here as well + * @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); @@ -359,8 +414,8 @@ class LinodeCreateContainer extends React.PureComponent { this.state.selectedStackScriptUsername } updateStackScript={this.setStackScript} - label={this.state.label} - updateLabel={this.setLabel} + label={this.generateLabel()} + updateLabel={this.props.updateCustomLabel} password={this.state.password} updatePassword={this.setPassword} backupsEnabled={this.state.backupsEnabled} @@ -384,7 +439,14 @@ class LinodeCreateContainer extends React.PureComponent { } } -const mapStateToProps: MapState = state => ({ +interface CreateType { + createType: CreateTypes; +} + +const mapStateToProps: MapState< + ReduxStateProps & CreateType, + CombinedProps +> = state => ({ accountBackupsEnabled: pathOr( false, ['__resources', 'accountSettings', 'data', 'backups_enabled'], @@ -395,7 +457,8 @@ const mapStateToProps: MapState = state => ({ * and do not have the "add_linodes" grant */ userCannotCreateLinode: - isRestrictedUser(state) && !hasGrant(state, 'add_linodes') + isRestrictedUser(state) && !hasGrant(state, 'add_linodes'), + createType: state.createLinode.type }); interface DispatchProps { @@ -461,5 +524,6 @@ export default recompose( connected, withRouter, withSnackbar, - userSSHKeyHoc + userSSHKeyHoc, + withLabelGenerator )(LinodeCreateContainer); diff --git a/src/features/linodes/LinodesCreate/withLabelGenerator.tsx b/src/features/linodes/LinodesCreate/withLabelGenerator.tsx index f9beb4993a7..2f639b186c5 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; } From 717adaf1bda3d405f865529ea671dee93c4964af Mon Sep 17 00:00:00 2001 From: Jared Date: Tue, 5 Mar 2019 17:24:55 -0500 Subject: [PATCH 13/44] CLAPPS Add create from backups tab (#4603) * WIP * Everything working except errors * Functional * Trim unused props * Fix backup client-side validatioN * Review feedback * Utilities has 3 i's * Use dynamic value for isMakingRequest in FromBackupsContent --- .../linodes/LinodesCreate/CALinodeCreate.tsx | 29 +- .../LinodesCreate/LinodeCreateContainer.tsx | 45 +- .../LinodesCreate/SelectLinodePanel.tsx | 4 +- .../TabbedContent/FromBackupsContent.tsx | 932 ++++++++---------- .../TabbedContent/FromLinodeContent.tsx | 11 +- src/features/linodes/LinodesCreate/types.ts | 10 +- .../{utilites.ts => utilities.ts} | 0 7 files changed, 475 insertions(+), 556 deletions(-) rename src/features/linodes/LinodesCreate/{utilites.ts => utilities.ts} (100%) diff --git a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx index d243849e532..7de930a863a 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx @@ -9,6 +9,7 @@ import ErrorState from 'src/components/ErrorState'; import Grid from 'src/components/Grid'; import { getStackScriptsByUser } from 'src/features/StackScripts/stackScriptUtils'; import SubTabs, { Tab } from './CALinodeCreateSubTabs'; +import FromBackupsContent from './TabbedContent/FromBackupsContent'; import FromImageContent from './TabbedContent/FromImageContent'; import FromLinodeContent from './TabbedContent/FromLinodeContent'; import FromStackScriptContent from './TabbedContent/FromStackScriptContent'; @@ -180,7 +181,33 @@ export class LinodeCreate extends React.PureComponent< title: 'Backups', type: 'fromBackup', render: () => { - return ; + const { + history, + handleSelectUDFs, + selectedUDFs, + updateStackScript, + availableStackScriptImages, + availableUserDefinedFields, + selectedStackScriptID, + selectedStackScriptUsername, + selectedStackScriptLabel, + linodesLoading, + updateDiskSize, + updatePassword, + ...rest + } = this.props; + return ( + + ); } }, { diff --git a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx index 36b80b482da..c155cb5b14e 100644 --- a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -59,6 +59,7 @@ interface State { selectedRegionID?: string; selectedTypeID?: string; selectedLinodeID?: number; + selectedBackupID?: number; availableUserDefinedFields?: Linode.StackScript.UserDefinedField[]; availableStackScriptImages?: Linode.Image[]; selectedStackScriptID?: number; @@ -91,6 +92,7 @@ const defaultState: State = { label: '', password: '', selectedImageID: 'linode/debian9', + selectedBackupID: undefined, selectedDiskSize: undefined, selectedLinodeID: undefined, selectedStackScriptID: undefined, @@ -100,6 +102,14 @@ const defaultState: State = { formIsSubmitting: 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; @@ -130,6 +140,10 @@ class LinodeCreateContainer extends React.PureComponent { 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 }); @@ -139,12 +153,22 @@ class LinodeCreateContainer extends React.PureComponent { /** * reset selected plan and set the selectedDiskSize * for the purpose of disabling plans that are smaller - * than the clone source + * 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. */ + + const selectedRegionID = getRegionIDFromLinodeID( + this.props.linodesData, + id + ); this.setState({ selectedLinodeID: id, selectedDiskSize: diskSize, - selectedTypeID: undefined + selectedTypeID: undefined, + selectedRegionID }); } }; @@ -239,7 +263,20 @@ class LinodeCreateContainer extends React.PureComponent { ); } - if (createType === 'fromStackScript' && !this.state.selectedStackScriptID) { + 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: [ @@ -431,6 +468,8 @@ class LinodeCreateContainer extends React.PureComponent { resetCreationState={this.clearCreationState} userSSHKeys={this.props.userSSHKeys} resetSSHKeys={this.props.resetSSHKeys} + selectedBackupID={this.state.selectedBackupID} + setBackupID={this.setBackupID} /> diff --git a/src/features/linodes/LinodesCreate/SelectLinodePanel.tsx b/src/features/linodes/LinodesCreate/SelectLinodePanel.tsx index 4749f8220a5..9599711ae85 100644 --- a/src/features/linodes/LinodesCreate/SelectLinodePanel.tsx +++ b/src/features/linodes/LinodesCreate/SelectLinodePanel.tsx @@ -38,7 +38,7 @@ const styles: StyleRulesCallback = theme => ({ interface Props { linodes: ExtendedLinode[]; selectedLinodeID?: number; - handleSelection: (linode: Linode.Linode) => void; + handleSelection: (id: number, diskSize?: number) => void; error?: string; header?: string; disabled?: boolean; @@ -55,7 +55,7 @@ class SelectLinodePanel extends React.Component { { - handleSelection(linode); + handleSelection(linode.id, linode.specs.disk); }} checked={linode.id === Number(selectedLinodeID)} heading={linode.heading} diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx index 5ccaa5375fd..00a865cedc9 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx @@ -1,545 +1,387 @@ -// 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 { -// StyleRulesCallback, -// withStyles, -// WithStyles -// } from 'src/components/core/styles'; -// 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 { renderBackupsDisplaySection } from './utils'; - -// type ClassNames = 'root' | 'main' | 'sidebar'; - -// const styles: StyleRulesCallback = theme => ({ -// root: {}, -// main: {}, -// sidebar: { -// [theme.breakpoints.up('lg')]: { -// marginTop: -130 -// } -// } -// }); - -// 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[]; -// } - -// type CombinedProps = Props & -// LinodeActionsProps & -// InjectedNotistackProps & -// LabelProps & -// WithStyles; - -// interface Notice { -// text: string; -// level: 'warning' | 'error'; // most likely only going to need these two -// } - -// const errorResources = { -// type: 'A plan selection', -// region: 'A region selection', -// label: 'A label', -// root_pass: 'A root password', -// tags: 'Tags for this Linode' -// }; - -// const filterLinodesWithBackups = (linodes: Linode.LinodeWithBackups[]) => { -// return linodes.filter(linode => { -// const hasAutomaticBackups = !!linode.currentBackups.automatic.length; -// const hasSnapshotBackup = !!linode.currentBackups.snapshot.current; -// // backups both need to be enabled and some backups need to exist -// // for the panel to show the Linode -// return linode.backups.enabled && (hasAutomaticBackups || hasSnapshotBackup); -// }); -// }; - -// 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: [] -// }; - -// mounted: boolean = false; - -// getLinodesWithBackups = (linodes: Linode.Linode[]) => { -// this.setState({ isGettingBackups: true }); -// return Promise.map( -// linodes.filter(l => l.backups.enabled), -// (linode: Linode.Linode) => { -// return getLinodeBackups(linode.id).then(backups => { -// return { -// ...linode, -// currentBackups: { -// ...backups -// } -// }; -// }); -// } -// ) -// .then(data => { -// if (!this.mounted) { -// return; -// } -// this.setState({ linodesWithBackups: data, isGettingBackups: false }); -// }) -// .catch(err => this.setState({ isGettingBackups: false })); -// }; - -// userHasBackups = () => { -// const { linodesWithBackups } = this.state; -// return linodesWithBackups!.some((linode: Linode.LinodeWithBackups) => { -// // automatic backups is an array, but snapshots are either null or an object -// // user can have up to 3 automatic backups, but one one snapshot -// return ( -// !!linode.currentBackups.automatic.length || -// !!linode.currentBackups.snapshot.current -// ); -// }); -// }; - -// 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, -// selectedTypeID, -// backups, -// privateIP, -// selectedBackupID, -// tags -// } = this.state; - -// this.setState({ isMakingRequest: true }); - -// const label = this.label(); - -// createLinode({ -// region: selectedRegionID, -// type: selectedTypeID, -// backup_id: Number(selectedBackupID), -// label: label ? label : null /* optional */, -// backups_enabled: backups /* 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 }); -// }); -// }; - -// componentWillUnmount() { -// this.mounted = false; -// } - -// componentDidMount() { -// this.mounted = true; -// this.getLinodesWithBackups(this.props.linodes); -// const { selectedLinodeID } = this.state; -// // 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 = () => { -// const { -// linodesWithBackups, -// selectedBackupID, -// selectedLinodeID -// } = 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 { -// errors, -// selectedBackupID, -// selectedDiskSize, -// selectedLinodeID, -// tags, -// selectedTypeID, -// selectedRegionID, -// backups, -// linodesWithBackups, -// privateIP, -// selectedBackupInfo, -// isMakingRequest -// } = this.state; -// const { -// accountBackups, -// extendLinodes, -// getBackupsMonthlyPrice, -// classes, -// notice, -// types, -// getRegionInfo, -// getTypeInfo, -// updateCustomLabel, -// disabled -// } = 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(); - -// return ( -// -// -// {this.state.isGettingBackups ? ( -// -// ) : !this.userHasBackups() ? ( -// -// ) : ( -// -// -// {notice && !disabled && ( -// -// )} -// {generalError && } -// -// extendLinodes(linodes), -// filterLinodesWithBackups -// )(linodesWithBackups!)} -// selectedLinodeID={selectedLinodeID} -// handleSelection={this.handleSelectLinode} -// updateFor={[selectedLinodeID, errors]} -// disabled={disabled} -// /> -// { -// return linode.id === +selectedLinodeID!; -// } -// )} -// selectedLinodeID={selectedLinodeID} -// selectedBackupID={selectedBackupID} -// handleChangeBackup={this.handleSelectBackupID} -// handleChangeBackupInfo={this.handleSelectBackupInfo} -// updateFor={[selectedLinodeID, selectedBackupID, errors]} -// /> -// -// -// -// -// )} -// -// {!this.userHasBackups() ? ( -// -// ) : ( -// -// -// {(props: StickyProps) => { -// const displaySections = []; -// if (imageInfo) { -// displaySections.push(imageInfo); -// } - -// if (regionInfo) { -// displaySections.push({ -// title: regionInfo.title, -// details: regionInfo.details -// }); -// } - -// if (typeInfo) { -// displaySections.push(typeInfo); -// } - -// if (hasBackups && typeInfo && typeInfo.backupsMonthly) { -// displaySections.push( -// renderBackupsDisplaySection( -// accountBackups, -// typeInfo.backupsMonthly -// ) -// ); -// } - -// let calculatedPrice = pathOr(0, ['monthly'], typeInfo); -// if (hasBackups && typeInfo && typeInfo.backupsMonthly) { -// calculatedPrice += typeInfo.backupsMonthly; -// } - -// return ( -// -// ); -// }} -// -// -// )} -// -// ); -// } -// } - -// const styled = withStyles(styles); - -// const enhanced = compose( -// styled, -// withSnackbar, -// withLabelGenerator, -// withLinodeActions -// ); - -// export default enhanced(FromBackupsContent); +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 { + StyleRulesCallback, + withStyles, + WithStyles +} from 'src/components/core/styles'; +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 { getLinodeBackups } from 'src/services/linodes'; +import { + LinodeActionsProps, + withLinodeActions +} from 'src/store/linodes/linode.containers'; +import getAPIErrorsFor from 'src/utilities/getAPIErrorFor'; +import AddonsPanel from '../AddonsPanel'; +import SelectBackupPanel from '../SelectBackupPanel'; +import SelectLinodePanel from '../SelectLinodePanel'; +import SelectPlanPanel from '../SelectPlanPanel'; +import { + BackupFormStateHandlers, + Info, + WithAll, + WithDisplayData +} from '../types'; +import { extendLinodes } from '../utilities'; +import { renderBackupsDisplaySection } from './utils'; + +type ClassNames = 'root' | 'main' | 'sidebar'; + +const styles: StyleRulesCallback = theme => ({ + root: {}, + main: {}, + sidebar: { + [theme.breakpoints.up('lg')]: { + marginTop: -130 + } + } +}); + +interface Props { + notice?: Notice; + linodesData: Linode.Linode[]; + selectedBackupFromQuery?: number; + selectedLinodeFromQuery?: number; + selectedRegionIDFromLinode?: string; + disabled?: boolean; +} + +interface State { + linodesWithBackups: Linode.LinodeWithBackups[] | null; + userHasBackups: boolean; + backups: boolean; + selectedBackupInfo: Info; + backupInfo: Info; + isGettingBackups: boolean; +} + +type CombinedProps = Props & + LinodeActionsProps & + InjectedNotistackProps & + BackupFormStateHandlers & + WithAll & + WithDisplayData & + WithStyles; + +interface Notice { + text: string; + level: 'warning' | 'error'; // most likely only going to need these two +} + +const errorResources = { + type: 'A plan selection', + region: 'A region selection', + label: 'A label', + root_pass: 'A root password', + tags: 'Tags for this Linode' +}; + +const filterLinodesWithBackups = (linodes: Linode.LinodeWithBackups[]) => { + return linodes.filter(linode => { + const hasAutomaticBackups = !!linode.currentBackups.automatic.length; + const hasSnapshotBackup = !!linode.currentBackups.snapshot.current; + // backups both need to be enabled and some backups need to exist + // for the panel to show the Linode + return linode.backups.enabled && (hasAutomaticBackups || hasSnapshotBackup); + }); +}; + +export class FromBackupsContent extends React.Component { + state: State = { + linodesWithBackups: [], + userHasBackups: false, + backups: false, + selectedBackupInfo: undefined, + backupInfo: undefined, + isGettingBackups: false + }; + + mounted: boolean = false; + + getLinodesWithBackups = (linodes: Linode.Linode[]) => { + this.setState({ isGettingBackups: true }); + return Promise.map( + linodes.filter(l => l.backups.enabled), + (linode: Linode.Linode) => { + return getLinodeBackups(linode.id).then(backups => { + return { + ...linode, + currentBackups: { + ...backups + } + }; + }); + } + ) + .then(data => { + if (!this.mounted) { + return; + } + this.setState({ linodesWithBackups: data, isGettingBackups: false }); + }) + .catch(err => this.setState({ isGettingBackups: false })); + }; + + userHasBackups = () => { + const { linodesWithBackups } = this.state; + return linodesWithBackups!.some((linode: Linode.LinodeWithBackups) => { + // automatic backups is an array, but snapshots are either null or an object + // user can have up to 3 automatic backups, but one one snapshot + return ( + !!linode.currentBackups.automatic.length || + !!linode.currentBackups.snapshot.current + ); + }); + }; + + handleSelectBackupInfo = (info: Info) => { + this.setState({ backupInfo: info }); + }; + + createLinode = () => { + const { + backupsEnabled, + privateIPEnabled, + selectedTypeID, + selectedRegionID, + selectedBackupID, + label, + tags + } = this.props; + + const tagsToAdd = tags ? tags.map(item => item.value) : undefined; + + this.props.handleSubmitForm('createFromBackup', { + region: selectedRegionID, + type: selectedTypeID, + private_ip: privateIPEnabled, + backup_id: Number(selectedBackupID), + label, + backups_enabled: backupsEnabled /* optional */, + booted: true, + tags: tagsToAdd + }); + }; + + componentWillUnmount() { + this.mounted = false; + } + + componentDidMount() { + this.mounted = true; + 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. + } + + render() { + const { + backups, + linodesWithBackups, + isGettingBackups, + selectedBackupInfo + } = this.state; + const { + accountBackupsEnabled, + classes, + errors, + notice, + privateIPEnabled, + selectedBackupID, + selectedDiskSize, + selectedLinodeID, + selectedTypeID, + 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 hasBackups = backups || accountBackupsEnabled; + + return ( + + + {this.state.isGettingBackups ? ( + + ) : !this.userHasBackups() ? ( + + ) : ( + + + {notice && !disabled && ( + + )} + {generalError && } + + extendLinodes(linodes), + filterLinodesWithBackups + )(linodesWithBackups!)} + selectedLinodeID={selectedLinodeID} + handleSelection={updateLinodeID} + updateFor={[selectedLinodeID, errors]} + disabled={disabled} + /> + { + return linode.id === +selectedLinodeID!; + } + )} + selectedLinodeID={selectedLinodeID} + selectedBackupID={selectedBackupID} + handleChangeBackup={setBackupID} + handleChangeBackupInfo={this.handleSelectBackupInfo} + updateFor={[selectedLinodeID, selectedBackupID, errors]} + /> + + + + + )} + + {!this.userHasBackups() ? ( + + ) : ( + + + {(props: StickyProps) => { + const displaySections = []; + if (imageInfo) { + displaySections.push(imageInfo); + } + + 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); + +const enhanced = compose( + styled, + withSnackbar, + withLinodeActions +); + +export default enhanced(FromBackupsContent); diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx index 5dbd9fdbd4a..fab6de3ee7c 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx @@ -22,7 +22,7 @@ import SelectLinodePanel from '../SelectLinodePanel'; import SelectPlanPanel from '../SelectPlanPanel'; import { renderBackupsDisplaySection } from './utils'; -import { extendLinodes } from '../utilites'; +import { extendLinodes } from '../utilities'; import { CloneFormStateHandlers, @@ -66,8 +66,13 @@ type CombinedProps = Props & export class FromLinodeContent extends React.PureComponent { /** set the Linode ID and the disk size and reset the plan selection */ - handleSelectLinode = (linode: Linode.Linode) => { - this.props.updateLinodeID(linode.id, linode.specs.disk); + handleSelectLinode = (linodeID: number) => { + const linode = this.props.linodesData.find( + linode => linode.id === linodeID + ); + if (linode) { + this.props.updateLinodeID(linode.id, linode.specs.disk); + } }; cloneLinode = () => { diff --git a/src/features/linodes/LinodesCreate/types.ts b/src/features/linodes/LinodesCreate/types.ts index 7577d4c10c7..20d3db71d05 100644 --- a/src/features/linodes/LinodesCreate/types.ts +++ b/src/features/linodes/LinodesCreate/types.ts @@ -55,7 +55,7 @@ export interface ReduxStateProps { } export type HandleSubmit = ( - type: 'create' | 'clone' | 'createFromStackScript', + type: 'create' | 'clone' | 'createFromStackScript' | 'createFromBackup', payload: CreateLinodeRequest, linodeID?: number ) => void; @@ -123,9 +123,15 @@ export interface StackScriptFormStateHandlers extends BaseFormStateAndHandlers { handleSelectUDFs: (stackScripts: any[]) => void; } +export interface BackupFormStateHandlers extends CloneFormStateHandlers { + selectedBackupID?: number; + setBackupID: (id: number) => void; +} + export type AllFormStateAndHandlers = BaseFormStateAndHandlers & CloneFormStateHandlers & - StackScriptFormStateHandlers; + StackScriptFormStateHandlers & + BackupFormStateHandlers; export type WithLinodesImagesTypesAndRegions = WithImagesProps & WithLinodesProps & diff --git a/src/features/linodes/LinodesCreate/utilites.ts b/src/features/linodes/LinodesCreate/utilities.ts similarity index 100% rename from src/features/linodes/LinodesCreate/utilites.ts rename to src/features/linodes/LinodesCreate/utilities.ts From 36de69642bd82eea6ec48db1a5aa82ce35ee0ca4 Mon Sep 17 00:00:00 2001 From: Kayla Wilkins Date: Wed, 6 Mar 2019 11:11:44 -0500 Subject: [PATCH 14/44] CA-606 Nested Tab Panel Updates (#4586) * adding paper to the inner tabvs to match pattern as existing panels * fixing imports * removing redundant classes that were messing up grid alignment * fixing broken sidebar * updating backups tab ordering --- .../linodes/LinodesCreate/CALinodeCreate.tsx | 4 +- .../LinodesCreate/CALinodeCreateSubTabs.tsx | 79 ++++++++---- .../LinodesCreate/LinodeCreateContainer.tsx | 114 +++++++++--------- .../TabbedContent/FromBackupsContent.tsx | 11 +- .../TabbedContent/FromLinodeContent.tsx | 13 +- .../TabbedContent/FromStackScriptContent.tsx | 11 +- 6 files changed, 140 insertions(+), 92 deletions(-) diff --git a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx index 7de930a863a..0eec87864a3 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx @@ -319,7 +319,7 @@ export class LinodeCreate extends React.PureComponent< return ( - + - {tabRender()} + {tabRender()} ); } diff --git a/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx b/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx index 3501ba09451..2389244e959 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreateSubTabs.tsx @@ -1,12 +1,34 @@ 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, + [theme.breakpoints.up('sm')]: { + padding: theme.spacing.unit * 3 + } + } +}); + export interface Tab { title: string; render: () => JSX.Element; @@ -24,7 +46,7 @@ interface State { selectedTab: number; } -type CombinedProps = Props; +type CombinedProps = Props & WithStyles; export const determinePreselectedTab = (tabsToRender: Tab[]): number => { /** get the query params as an object, excluding the "?" */ @@ -68,7 +90,7 @@ class CALinodeCreateSubTabs extends React.Component { }; render() { - const { tabs } = this.props; + const { tabs, classes } = this.props; const { selectedTab: selectedTabFromState } = this.state; const queryParams = parse(location.search.replace('?', '')); @@ -86,31 +108,38 @@ class CALinodeCreateSubTabs extends React.Component { const selectedTabContentRender = tabs[selectedTab].render; return ( - - - - - {tabs.map((tab, idx) => ( - - ))} - - + + + +
+ + Create From: + + + + {tabs.map((tab, idx) => ( + + ))} + + +
+
{selectedTabContentRender()} -
+
); } } -export default CALinodeCreateSubTabs; +export default withStyles(styles)(CALinodeCreateSubTabs); diff --git a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx index c155cb5b14e..c156db78c17 100644 --- a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -410,68 +410,66 @@ class LinodeCreateContainer extends React.PureComponent { - + Create New Linode - + ); diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx index 00a865cedc9..9bdb2591a64 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx @@ -40,10 +40,17 @@ type ClassNames = 'root' | 'main' | 'sidebar'; const styles: StyleRulesCallback = theme => ({ root: {}, - main: {}, + main: { + '&.mlMain': { + [theme.breakpoints.up('lg')]: { + order: 3 + } + } + }, sidebar: { [theme.breakpoints.up('lg')]: { - marginTop: -130 + marginTop: -130, + order: 2 } } }); diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx index fab6de3ee7c..6eff9a2e80a 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx @@ -34,10 +34,17 @@ type ClassNames = 'root' | 'main' | 'sidebar'; const styles: StyleRulesCallback = theme => ({ root: {}, - main: {}, + main: { + '&.mlMain': { + [theme.breakpoints.up('lg')]: { + order: 3 + } + } + }, sidebar: { [theme.breakpoints.up('lg')]: { - marginTop: -130 + marginTop: -130, + order: 2 } } }); @@ -121,7 +128,7 @@ export class FromLinodeContent extends React.PureComponent { return ( {linodes && linodes.length === 0 ? ( - + {error && } @@ -40,9 +41,9 @@ const Panel: React.StatelessComponent = (props) => { {children}
- ) -} + ); +}; const styled = withStyles(styles); -export default styled(Panel); \ No newline at end of file +export default styled(Panel); diff --git a/src/features/linodes/LinodesCreate/SelectAppPanel.tsx b/src/features/linodes/LinodesCreate/SelectAppPanel.tsx new file mode 100644 index 00000000000..5d95a19899b --- /dev/null +++ b/src/features/linodes/LinodesCreate/SelectAppPanel.tsx @@ -0,0 +1,168 @@ +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 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; + id: number; + label: string; + username: string; + userDefinedFields: Linode.StackScript.UserDefinedField[]; + availableImages: string[]; + disabled: boolean; + checked: boolean; +} + +class SelectionCardWrapper extends React.PureComponent { + handleSelectApp = (event: React.SyntheticEvent) => { + const { + id, + label, + username, + userDefinedFields, + availableImages + } = this.props; + + return this.props.handleClick( + id, + label, + username, + availableImages, + userDefinedFields + ); + }; + + render() { + const { id, checked, label, disabled } = this.props; + return ( + { + return ; + }} + heading={label} + subheadings={['']} + data-qa-selection-card + disabled={disabled} + /> + ); + } +} diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx new file mode 100644 index 00000000000..26e44eaf189 --- /dev/null +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx @@ -0,0 +1,374 @@ +import { assocPath, pathOr } from 'ramda'; +import * as React from 'react'; +import { Sticky, StickyProps } from 'react-sticky'; +import { compose } from 'recompose'; + +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, + StackScriptFormStateHandlers, + WithAll, + WithDisplayData +} from '../types'; + +type ClassNames = 'sidebar' | 'emptyImagePanel' | 'emptyImagePanelText'; + +const styles: StyleRulesCallback = theme => ({ + sidebar: { + [theme.breakpoints.up('lg')]: { + marginTop: -130 + } + }, + 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 InnerProps = WithDisplayData & WithAll & AppsData; + +type CombinedProps = InnerProps & + WithStyles & + 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 compose( + styled, + React.memo +)(FromAppsContent); diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx index 3899ed115fd..c6182aad309 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromImageContent.tsx @@ -47,10 +47,6 @@ interface Props extends BaseFormStateAndHandlers { imagePanelTitle?: string; } -/** - * image, region, type, label, backups, privateIP, tags, error, isLoading, - */ - const errorResources = { type: 'A plan selection', region: 'region', diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx index 659e4298c1f..ac17619776f 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx @@ -24,6 +24,8 @@ import getAPIErrorsFor from 'src/utilities/getAPIErrorFor'; import AddonsPanel from '../AddonsPanel'; import SelectImagePanel from '../SelectImagePanel'; import SelectPlanPanel from '../SelectPlanPanel'; + +import { filterPublicImages, filterUDFErrors } from './formUtilities'; import { renderBackupsDisplaySection } from './utils'; import { @@ -33,14 +35,12 @@ import { } from '../types'; type ClassNames = - | 'root' | 'main' | 'sidebar' | 'emptyImagePanel' | 'emptyImagePanelText'; const styles: StyleRulesCallback = theme => ({ - root: {}, main: { '&.mlMain': { [theme.breakpoints.up('lg')]: { @@ -63,13 +63,7 @@ const styles: StyleRulesCallback = theme => ({ } }); -interface Notice { - text: string; - level: 'warning' | 'error'; // most likely only going to need these two -} interface Props { - notice?: Notice; - selectedTabFromQuery?: string; request: ( username: string, params?: any, @@ -84,7 +78,8 @@ const errorResources = { label: 'A label', root_pass: 'A root password', image: 'image', - tags: 'Tags' + tags: 'Tags', + stackscript_id: 'A StackScript' }; type InnerProps = Props & WithAll; @@ -178,7 +173,6 @@ export class FromStackScriptContent extends React.PureComponent { const { accountBackupsEnabled, errors, - notice, backupsMonthlyPrice, regionsData, typesData, @@ -221,15 +215,8 @@ export class FromStackScriptContent extends React.PureComponent { return ( - + - {!disabled && notice && ( - - )} {generalError && } { /> {!disabled && userDefinedFields && userDefinedFields.length > 0 && ( { } } -/** - * @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 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 = (errors?: Linode.ApiFieldError[]) => { - return !errors - ? [] - : errors.filter(eachError => { - return !Object.keys(errorResources).some( - eachKey => eachKey === eachError.field - ); - }); -}; - const styled = withStyles(styles); const enhanced = compose(styled); diff --git a/src/features/linodes/LinodesCreate/TabbedContent/formUtilities.ts b/src/features/linodes/LinodesCreate/TabbedContent/formUtilities.ts new file mode 100644 index 00000000000..a9488bcd589 --- /dev/null +++ b/src/features/linodes/LinodesCreate/TabbedContent/formUtilities.ts @@ -0,0 +1,44 @@ +import { getStackscripts } from 'src/services/stackscripts'; + +/** + * @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 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 + ); + }); +}; + +/** + * helper function to get Cloud Apps StackScripts + * + * for the prototype, all the apps we need are going to be uploaded to + * Christine Puk's account. Keep in mind that the Linux distros will be missing from this + * list because we're intentionally not including the distros in this view + */ +export const getCloudApps = (params?: any, filter?: any) => + getStackscripts(params, { + ...filter, + username: 'capuk' + }); diff --git a/src/features/linodes/LinodesCreate/types.ts b/src/features/linodes/LinodesCreate/types.ts index 20d3db71d05..b157eb01c39 100644 --- a/src/features/linodes/LinodesCreate/types.ts +++ b/src/features/linodes/LinodesCreate/types.ts @@ -127,6 +127,11 @@ export interface BackupFormStateHandlers extends CloneFormStateHandlers { selectedBackupID?: number; setBackupID: (id: number) => void; } +export interface AppsData { + appInstances?: Linode.StackScript.Response[]; + appInstancesLoading: boolean; + appInstancesError?: string; +} export type AllFormStateAndHandlers = BaseFormStateAndHandlers & CloneFormStateHandlers & From 4593be463e66813eb6862f249195b4f60a2c4e94 Mon Sep 17 00:00:00 2001 From: Jared Date: Fri, 8 Mar 2019 07:57:34 -0500 Subject: [PATCH 17/44] CLAPPS Map UDF error fields (#4619) * Map UDF errors to the correct field * Fix Notice spacing for select and multiselect UDFs --- .../FieldTypes/UserDefinedMultiSelect.tsx | 5 ++- .../FieldTypes/UserDefinedSelect.tsx | 5 ++- .../FieldTypes/UserDefinedText.tsx | 7 ++-- .../UserDefinedFieldsPanel.tsx | 36 +++++++++++++------ 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedMultiSelect.tsx b/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedMultiSelect.tsx index a5d97913c4b..821ce25bc92 100644 --- a/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedMultiSelect.tsx +++ b/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedMultiSelect.tsx @@ -7,6 +7,7 @@ import { } 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 Toggle from 'src/components/Toggle'; @@ -26,6 +27,7 @@ interface Props { udf_data: Linode.StackScript.UserDefinedField; field: Linode.StackScript.UserDefinedField; isOptional: boolean; + error?: string; } interface State { @@ -74,7 +76,7 @@ class UserDefinedMultiSelect extends React.Component { render() { const { manyof } = this.state; - const { udf_data, field, classes, isOptional } = this.props; + const { udf_data, error, field, classes, isOptional } = this.props; // we are setting default values in the parent component, so we want to use these // default values to determine what will be checked upon initial render @@ -87,6 +89,7 @@ class UserDefinedMultiSelect extends React.Component { {field.label} {!isOptional && ' *'} + {error && } {manyof.map((choice: string, index) => { return ( diff --git a/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedSelect.tsx b/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedSelect.tsx index dc2a6a01b19..7af92c39818 100644 --- a/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedSelect.tsx +++ b/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedSelect.tsx @@ -6,6 +6,7 @@ import { WithStyles } from 'src/components/core/styles'; import Typography from 'src/components/core/Typography'; +import Notice from 'src/components/Notice'; import Radio from 'src/components/Radio'; import RenderGuard from 'src/components/RenderGuard'; @@ -24,6 +25,7 @@ interface Props { udf_data: Linode.StackScript.UserDefinedField; field: Linode.StackScript.UserDefinedField; isOptional: boolean; + error?: string; } interface State { @@ -46,7 +48,7 @@ class UserDefinedSelect extends React.Component { render() { const { oneof } = this.state; - const { udf_data, field, classes, isOptional } = this.props; + const { udf_data, error, field, classes, isOptional } = this.props; return (
@@ -54,6 +56,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..df636dbb7e4 100644 --- a/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx +++ b/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx @@ -25,13 +25,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 } = this.props; return ( { label={field.label} noPadding placeholder={placeholder} + error={error} /> ); }; diff --git a/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx b/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx index 8b13c6234a2..00e65676cbe 100644 --- a/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx +++ b/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx @@ -6,7 +6,6 @@ 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 UserDefinedMultiSelect from './FieldTypes/UserDefinedMultiSelect'; import UserDefinedSelect from './FieldTypes/UserDefinedSelect'; @@ -45,7 +44,10 @@ const UserDefinedFieldsPanel: React.StatelessComponent< > = props => { const { userDefinedFields, classes, handleChange } = props; - const renderField = (field: Linode.StackScript.UserDefinedField) => { + const renderField = ( + field: Linode.StackScript.UserDefinedField, + error?: string + ) => { // if the 'default' key is returned from the API, the field is optional const isOptional = field.hasOwnProperty('default'); if (isMultiSelect(field)) { @@ -55,8 +57,9 @@ const UserDefinedFieldsPanel: React.StatelessComponent< field={field} udf_data={props.udf_data} updateFormState={handleChange} - updateFor={[props.udf_data[field.name], classes]} + updateFor={[props.udf_data[field.name], error, classes]} isOptional={isOptional} + error={error} /> ); } @@ -66,9 +69,10 @@ const UserDefinedFieldsPanel: React.StatelessComponent< field={field} updateFormState={handleChange} udf_data={props.udf_data} - updateFor={[props.udf_data[field.name], classes]} + updateFor={[props.udf_data[field.name], error, classes]} isOptional={isOptional} key={field.name} + error={error} /> ); } @@ -80,9 +84,10 @@ const UserDefinedFieldsPanel: React.StatelessComponent< isPassword={true} field={field} udf_data={props.udf_data} - updateFor={[props.udf_data[field.name], classes]} + updateFor={[props.udf_data[field.name], error, classes]} isOptional={isOptional} placeholder={field.example} + error={error} /> ); } @@ -92,19 +97,16 @@ const UserDefinedFieldsPanel: React.StatelessComponent< updateFormState={handleChange} field={field} udf_data={props.udf_data} - updateFor={[props.udf_data[field.name], classes]} + updateFor={[props.udf_data[field.name], error, classes]} isOptional={isOptional} placeholder={field.example} + error={error} /> ); }; return ( - {props.errors && - props.errors.map(error => { - return ; - })} {`${ props.selectedUsername @@ -112,12 +114,24 @@ const UserDefinedFieldsPanel: React.StatelessComponent< {`${props.selectedLabel} Options`} {userDefinedFields!.map((field: Linode.StackScript.UserDefinedField) => { - return renderField(field); + const error = getError(field, props.errors); + return 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) => { return udfName.toLowerCase().includes('password'); }; From bd66cbef8b96d057e75ed779845959c2355ea9d0 Mon Sep 17 00:00:00 2001 From: Kayla Wilkins Date: Fri, 8 Mar 2019 09:22:10 -0500 Subject: [PATCH 18/44] Apps UI Edits (#4611) * adding errors to linodeContent panel instead of component * fixing gaping issue * removing sticky stuff from checkout component, adding sticky css * adding more vertical spacing for larger screensizes * adding conditional based on image variant public vs private * style adjustments for apps content * cleanup from moving notices --- src/components/CheckoutBar/CheckoutBar.tsx | 21 +---- .../linodes/LinodesCreate/CALinodeCreate.tsx | 28 +------ .../LinodesCreate/CALinodeCreateSubTabs.tsx | 8 +- .../LinodesCreate/LinodeCreateContainer.tsx | 2 +- .../LinodesCreate/SelectLinodePanel.tsx | 77 +++++++------------ .../TabbedContent/FromAppsContent.tsx | 4 +- .../TabbedContent/FromBackupsContent.tsx | 54 +++++-------- .../TabbedContent/FromImageContent.tsx | 48 ++++++++---- .../TabbedContent/FromLinodeContent.tsx | 60 +++++---------- .../TabbedContent/FromStackScriptContent.tsx | 14 +--- src/index.css | 5 +- 11 files changed, 124 insertions(+), 197 deletions(-) diff --git a/src/components/CheckoutBar/CheckoutBar.tsx b/src/components/CheckoutBar/CheckoutBar.tsx index a2793f7af48..629fc1f5c56 100644 --- a/src/components/CheckoutBar/CheckoutBar.tsx +++ b/src/components/CheckoutBar/CheckoutBar.tsx @@ -1,6 +1,5 @@ import * as classNames from 'classnames'; import * as React from 'react'; -import { StickyProps } from 'react-sticky'; import Button from 'src/components/Button'; import { StyleRulesCallback, @@ -67,13 +66,12 @@ interface Props { onDeploy: () => void; heading: string; calculatedPrice?: number; - isSticky?: boolean; disabled?: boolean; isMakingRequest?: boolean; displaySections?: { title: string; details?: string | number }[]; } -type CombinedProps = Props & StickyProps & WithStyles; +type CombinedProps = Props & WithStyles; class CheckoutBar extends React.Component { static defaultProps: Partial = { @@ -82,13 +80,6 @@ class CheckoutBar extends React.Component { render() { const { - /** - * Note: - * This 'style' prop is what gives us the "sticky" styles. Other special - * props are available, see https://github.com/captivationsoftware/react-sticky - */ - style, - isSticky, classes, onDeploy, heading, @@ -98,16 +89,8 @@ class CheckoutBar extends React.Component { isMakingRequest } = this.props; - let finalStyle; - if (isSticky) { - finalStyle = { - ...style, - paddingTop: 24 - }; - } - return ( -
+
{ +> { constructor(props: CombinedProps & DispatchProps) { super(props); @@ -210,18 +210,7 @@ export class LinodeCreate extends React.PureComponent< updatePassword, ...rest } = this.props; - return ( - - ); + return ; } }, { @@ -247,16 +236,7 @@ export class LinodeCreate extends React.PureComponent< appInstancesLoading, ...rest } = this.props; - return ( - - ); + return ; } }, { @@ -367,7 +347,7 @@ export class LinodeCreate extends React.PureComponent< return ( - + = theme => ({ backgroundColor: theme.color.white }, inner: { - padding: theme.spacing.unit * 2, + 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 + padding: `${theme.spacing.unit * 3}px ${theme.spacing.unit * + 3}px 0 ${theme.spacing.unit * 3}px` } } }); @@ -109,7 +111,7 @@ class CALinodeCreateSubTabs extends React.Component { return ( - +
diff --git a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx index c1dadcb9b45..bb0b818c31c 100644 --- a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -452,7 +452,7 @@ class LinodeCreateContainer extends React.PureComponent { return ( - + Create New Linode diff --git a/src/features/linodes/LinodesCreate/SelectLinodePanel.tsx b/src/features/linodes/LinodesCreate/SelectLinodePanel.tsx index 9599711ae85..36938bf8bb5 100644 --- a/src/features/linodes/LinodesCreate/SelectLinodePanel.tsx +++ b/src/features/linodes/LinodesCreate/SelectLinodePanel.tsx @@ -35,6 +35,11 @@ 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; @@ -42,6 +47,7 @@ interface Props { error?: string; header?: string; disabled?: boolean; + notice?: Notice; } type StyledProps = Props & WithStyles; @@ -66,56 +72,31 @@ 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, - handlePageChange, - handlePageSizeChange, - page, - pageSize - }) => { - return ( - <> - -
- {error && } - - {!!header ? header : 'Select Linode'} - - - - {linodes.map(linode => { - return this.renderCard(linode); - })} - - -
-
- - - ); - }} -
-
+ +
+ {notice && !disabled && ( + + )} + {error && } + + {!!header ? header : 'Select Linode'} + + + + {linodes.map(linode => { + return this.renderCard(linode); + })} + + +
+
); } } diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx index 26e44eaf189..37deb678263 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx @@ -40,7 +40,7 @@ type ClassNames = 'sidebar' | 'emptyImagePanel' | 'emptyImagePanelText'; const styles: StyleRulesCallback = theme => ({ sidebar: { [theme.breakpoints.up('lg')]: { - marginTop: -130 + marginTop: '-130px !important' } }, emptyImagePanel: { @@ -194,7 +194,7 @@ class FromAppsContent extends React.PureComponent { return ( - + {generalError && } = theme => ({ root: {}, - main: { - '&.mlMain': { - [theme.breakpoints.up('lg')]: { - order: 3 - } - } - }, + main: {}, sidebar: { [theme.breakpoints.up('lg')]: { - marginTop: -130, - order: 2 + marginTop: '-130px !important' } } }); interface Props { - notice?: Notice; linodesData: Linode.Linode[]; selectedBackupFromQuery?: number; selectedLinodeFromQuery?: number; @@ -81,11 +73,6 @@ type CombinedProps = Props & WithDisplayData & WithStyles; -interface Notice { - text: string; - level: 'warning' | 'error'; // most likely only going to need these two -} - const errorResources = { type: 'A plan selection', region: 'A region selection', @@ -203,7 +190,6 @@ export class FromBackupsContent extends React.Component { accountBackupsEnabled, classes, errors, - notice, privateIPEnabled, selectedBackupID, selectedDiskSize, @@ -226,7 +212,6 @@ export class FromBackupsContent extends React.Component { updateLabel } = this.props; const hasErrorFor = getAPIErrorsFor(errorResources, errors); - const generalError = hasErrorFor('none'); const imageInfo = selectedBackupInfo; @@ -234,28 +219,24 @@ export class FromBackupsContent extends React.Component { return ( - + {this.state.isGettingBackups ? ( - + + + ) : !this.userHasBackups() ? ( - + title="Create from Backup" + /> + ) : ( - {notice && !disabled && ( - - )} - {generalError && } { 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.` + }} /> = theme => ({ root: {}, main: {}, - sidebar: { + sidebarPrivate: { [theme.breakpoints.up('lg')]: { - marginTop: -130 + marginTop: '-130px !important' + } + }, + sidebarPublic: { + [theme.breakpoints.up('lg')]: { + marginTop: '0 !important' } } }); @@ -110,24 +116,26 @@ export class FromImageContent extends React.PureComponent { 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. - - } - /> + + + + You don't have any private Images. Visit the{' '} + Images section to create an Image + from one of your Linode's disks. + + } + /> + ); } return ( - + {notice && ( { disabled={userCannotCreateLinode} /> - + {(props: StickyProps) => { const displaySections = []; diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx index 6eff9a2e80a..4075d2cfbde 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx @@ -4,6 +4,7 @@ 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, @@ -12,7 +13,6 @@ 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 from 'src/components/SelectRegionPanel'; @@ -34,30 +34,14 @@ type ClassNames = 'root' | 'main' | 'sidebar'; const styles: StyleRulesCallback = theme => ({ root: {}, - main: { - '&.mlMain': { - [theme.breakpoints.up('lg')]: { - order: 3 - } - } - }, + main: {}, sidebar: { [theme.breakpoints.up('lg')]: { - marginTop: -130, - order: 2 + marginTop: '-130px !important' } } }); -interface Notice { - text: string; - level: 'warning' | 'error'; // most likely only going to need these two -} - -interface Props { - notice?: Notice; -} - const errorResources = { type: 'A plan selection', region: 'region', @@ -65,8 +49,7 @@ const errorResources = { root_pass: 'A root password' }; -type CombinedProps = Props & - WithStyles & +type CombinedProps = WithStyles & WithDisplayData & CloneFormStateHandlers & WithLinodesImagesTypesAndRegions; @@ -99,7 +82,6 @@ export class FromLinodeContent extends React.PureComponent { render() { const { - notice, classes, errors, accountBackupsEnabled, @@ -121,33 +103,26 @@ export class FromLinodeContent extends React.PureComponent { } = this.props; const hasErrorFor = getAPIErrorsFor(errorResources, errors); - const generalError = hasErrorFor('none'); const hasBackups = backupsEnabled || accountBackupsEnabled; return ( {linodes && linodes.length === 0 ? ( - - + + + + ) : ( - + - {notice && !userCannotCreateLinode && ( - - )} - {generalError && } { handleSelection={this.handleSelectLinode} updateFor={[selectedLinodeID, errors, classes]} disabled={userCannotCreateLinode} + notice={{ + level: 'warning', + text: `This newly created Linode will be created with + the same password and SSH Keys (if any) as the original Linode.` + }} /> { const styled = withStyles(styles); -const enhanced = compose(styled); +const enhanced = compose(styled); export default enhanced(FromLinodeContent); diff --git a/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx b/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx index ac17619776f..debfd4b3725 100644 --- a/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx +++ b/src/features/linodes/LinodesCreate/TabbedContent/FromStackScriptContent.tsx @@ -41,17 +41,11 @@ type ClassNames = | 'emptyImagePanelText'; const styles: StyleRulesCallback = theme => ({ - main: { - '&.mlMain': { - [theme.breakpoints.up('lg')]: { - order: 3 - } - } - }, + root: {}, + main: {}, sidebar: { [theme.breakpoints.up('lg')]: { - marginTop: -130, - order: 2 + marginTop: '-130px !important' } }, emptyImagePanel: { @@ -215,7 +209,7 @@ export class FromStackScriptContent extends React.PureComponent { return ( - + {generalError && } Date: Mon, 11 Mar 2019 13:25:23 -0400 Subject: [PATCH 19/44] CLAPPS: Fix StackScript routing (#4625) * Fix typing * Fix routing (was reloading full site) * Review feedback --- src/containers/profile.container.ts | 15 +++++++ .../CASelectStackScriptPanel.tsx | 42 +++++++++++-------- .../StackScriptActionMenu.tsx | 30 +++++++++++-- .../StackScripts/StackScriptsDetail.tsx | 42 ++++++++++++++++--- src/features/StackScripts/stackScriptUtils.ts | 27 ++++++++++++ .../linodes/LinodesCreate/CALinodeCreate.tsx | 4 +- .../LinodesCreate/LinodeCreateContainer.tsx | 10 +++++ .../TabbedContent/FromStackScriptContent.tsx | 1 + 8 files changed, 141 insertions(+), 30 deletions(-) create mode 100644 src/containers/profile.container.ts diff --git a/src/containers/profile.container.ts b/src/containers/profile.container.ts new file mode 100644 index 00000000000..c690067896d --- /dev/null +++ b/src/containers/profile.container.ts @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { ApplicationState } from 'src/store'; + +export interface ProfileProps { + profile?: Linode.Profile; +} + +export default ( + mapAccountToProps: (ownProps: TOutter, account?: Linode.Profile) => TInner +) => + connect((state: ApplicationState, ownProps: TOutter) => { + const profile = state.__resources.profile.data; + + return mapAccountToProps(ownProps, profile); + }); diff --git a/src/features/StackScripts/SelectStackScriptPanel/CASelectStackScriptPanel.tsx b/src/features/StackScripts/SelectStackScriptPanel/CASelectStackScriptPanel.tsx index 4c5bcf9aeeb..984f8a57faa 100644 --- a/src/features/StackScripts/SelectStackScriptPanel/CASelectStackScriptPanel.tsx +++ b/src/features/StackScripts/SelectStackScriptPanel/CASelectStackScriptPanel.tsx @@ -1,6 +1,8 @@ -import { compose, pathOr } from 'ramda'; +import { pathOr } from 'ramda'; import * as React from 'react'; import { connect } from 'react-redux'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; +import { compose } from 'recompose'; import Button from 'src/components/Button'; import CircleProgress from 'src/components/CircleProgress'; import Paper from 'src/components/core/Paper'; @@ -11,11 +13,12 @@ import { } 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 RenderGuard, { RenderGuardProps } from 'src/components/RenderGuard'; import Table from 'src/components/Table'; import { getStackScript } from 'src/services/stackscripts'; import { MapState } from 'src/store/types'; import { formatDate } from 'src/utilities/format-date-iso8601'; +import { getParamFromUrl } from 'src/utilities/queryParams'; import stripImageName from 'src/utilities/stripImageName'; import truncateText from 'src/utilities/truncateText'; import StackScriptTableHead from '../Partials/StackScriptTableHead'; @@ -76,7 +79,7 @@ const styles: StyleRulesCallback = theme => ({ } }); -interface Props { +interface Props extends RenderGuardProps { selectedId: number | undefined; selectedUsername?: string; error?: string; @@ -90,12 +93,20 @@ interface Props { publicImages: Linode.Image[]; resetSelectedStackScript: () => void; disabled?: boolean; - request: () => Promise>; + request: ( + username: string, + params?: any, + filter?: any + ) => Promise>; category: string; header: string; } -type CombinedProps = Props & StateProps & WithStyles; +type CombinedProps = Props & + StateProps & + RouteComponentProps<{}> & + RenderGuardProps & + WithStyles; interface State { stackScript?: Linode.StackScript.Response; @@ -112,9 +123,12 @@ class SelectStackScriptPanel extends React.Component { mounted: boolean = false; componentDidMount() { - if (this.props.selectedId) { + const selected = + this.props.selectedId || + getParamFromUrl(this.props.location.search, 'stackScriptID'); + if (selected) { this.setState({ stackScriptLoading: true }); - getStackScript(this.props.selectedId) + getStackScript(selected) .then(stackScript => { this.setState({ stackScript, stackScriptLoading: false }); this.props.onSelect( @@ -195,11 +209,7 @@ class SelectStackScriptPanel extends React.Component {
-
@@ -255,12 +265,8 @@ const connected = connect(mapStateToProps); const styled = withStyles(styles); -export default compose< - Linode.TodoAny, - Linode.TodoAny, - Linode.TodoAny, - Linode.TodoAny ->( +export default compose( + withRouter, connected, RenderGuard, styled diff --git a/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx b/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx index 22216d850fe..fa906b4e98e 100644 --- a/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx +++ b/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx @@ -1,7 +1,12 @@ +import { path } from 'ramda'; import * as React from 'react'; import { RouteComponentProps, withRouter } from 'react-router-dom'; +import { compose } from 'recompose'; import ActionMenu, { Action } from 'src/components/ActionMenu/ActionMenu'; +import withProfile from 'src/containers/profile.container'; + +import { getStackScriptUrl } from '../stackScriptUtils'; interface Props { stackScriptID: number; @@ -12,6 +17,8 @@ interface Props { canDelete: boolean; canEdit: boolean; isPublic: boolean; + // From Profile HOC + username?: string; } type CombinedProps = Props & RouteComponentProps<{}>; @@ -28,7 +35,8 @@ const StackScriptActionMenu: React.StatelessComponent< stackScriptLabel, canDelete, canEdit, - isPublic + isPublic, + username } = props; const createActions = () => { @@ -38,8 +46,12 @@ const StackScriptActionMenu: React.StatelessComponent< title: 'Deploy New Linode', onClick: (e: React.MouseEvent) => { history.push( - `/linodes/create?type=fromStackScript` + - `&stackScriptID=${stackScriptID}&stackScriptUsername=${stackScriptUsername}` + getStackScriptUrl( + stackScriptUsername, + stackScriptID, + stackScriptLabel, + username + ) ); e.preventDefault(); } @@ -83,4 +95,14 @@ const StackScriptActionMenu: React.StatelessComponent< return ; }; -export default withRouter(StackScriptActionMenu); +const enhanced = compose( + withRouter, + withProfile((ownProps, profile) => { + return { + ...ownProps, + username: path(['username'], profile) + }; + }) +); + +export default enhanced(StackScriptActionMenu); diff --git a/src/features/StackScripts/StackScriptsDetail.tsx b/src/features/StackScripts/StackScriptsDetail.tsx index fe6dea917fb..a84c4f587d0 100644 --- a/src/features/StackScripts/StackScriptsDetail.tsx +++ b/src/features/StackScripts/StackScriptsDetail.tsx @@ -1,4 +1,4 @@ -import { compose } from 'ramda'; +import { compose, path } from 'ramda'; import * as React from 'react'; import { RouteComponentProps } from 'react-router-dom'; @@ -14,10 +14,13 @@ import setDocs, { SetDocsProps } from 'src/components/DocsSidebar/setDocs'; import Grid from 'src/components/Grid'; import NotFound from 'src/components/NotFound'; import StackScript from 'src/components/StackScript'; +import withProfile from 'src/containers/profile.container'; import { StackScripts as StackScriptsDocs } from 'src/documentation'; import { getStackScript } from 'src/services/stackscripts'; +import { getStackScriptUrl } from './stackScriptUtils'; + interface MatchProps { stackScriptId: string; } @@ -49,7 +52,15 @@ const styles: StyleRulesCallback = theme => ({ } }); -type CombinedProps = RouteProps & WithStyles & SetDocsProps; +interface ProfileProps { + // From Profile container + username?: string; +} + +type CombinedProps = ProfileProps & + RouteProps & + WithStyles & + SetDocsProps; export class StackScriptsDetail extends React.Component { state: State = { @@ -69,6 +80,21 @@ export class StackScriptsDetail extends React.Component { }); } + handleClick = () => { + const { history, username } = this.props; + const { stackScript } = this.state; + if (!stackScript) { + return; + } + const url = getStackScriptUrl( + stackScript.username, + stackScript.id, + stackScript.label, + username + ); + history.push(url); + }; + render() { const { classes } = this.props; const { loading, stackScript } = this.state; @@ -97,9 +123,7 @@ export class StackScriptsDetail extends React.Component { -
-
- ); - } - } - - return ( - -
- {error && } - - {header} - - {stackScriptError && ( - - An error occured while loading the selected StackScript. - - )} - - - -
-
- ); - } -} - -interface StateProps { - username: string; -} - -const mapStateToProps: MapState = state => ({ - username: pathOr('', ['data', 'username'], state.__resources.profile) -}); - -const connected = connect(mapStateToProps); - -const styled = withStyles(styles); - -export default compose( - connected, - RenderGuard, - styled -)(SelectStackScriptPanel); diff --git a/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptPanel.tsx b/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptPanel.tsx index 19cafd56a35..cce14b1cad8 100644 --- a/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptPanel.tsx +++ b/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptPanel.tsx @@ -1,24 +1,26 @@ -import { compose, pathOr } from 'ramda'; +import { pathOr } from 'ramda'; import * as React from 'react'; import { connect } from 'react-redux'; +import { compose } from 'recompose'; import Button from 'src/components/Button'; import CircleProgress from 'src/components/CircleProgress'; +import Paper from 'src/components/core/Paper'; import { StyleRulesCallback, withStyles, WithStyles } from 'src/components/core/styles'; import Typography from 'src/components/core/Typography'; -import RenderGuard from 'src/components/RenderGuard'; -import TabbedPanel from 'src/components/TabbedPanel'; +import Notice from 'src/components/Notice'; +import RenderGuard, { RenderGuardProps } from 'src/components/RenderGuard'; import Table from 'src/components/Table'; import { getStackScript } from 'src/services/stackscripts'; import { MapState } from 'src/store/types'; import { formatDate } from 'src/utilities/format-date-iso8601'; +import { getParamFromUrl } from 'src/utilities/queryParams'; import stripImageName from 'src/utilities/stripImageName'; import truncateText from 'src/utilities/truncateText'; import StackScriptTableHead from '../Partials/StackScriptTableHead'; -import { StackScriptTabs } from '../stackScriptUtils'; import SelectStackScriptPanelContent from './SelectStackScriptPanelContent'; import StackScriptSelectionRow from './StackScriptSelectionRow'; @@ -27,7 +29,14 @@ export interface ExtendedLinode extends Linode.Linode { subHeadings: string[]; } -type ClassNames = 'root' | 'table' | 'link' | 'selecting'; +type ClassNames = + | 'root' + | 'table' + | 'link' + | 'selecting' + | 'panel' + | 'inner' + | 'header'; const styles: StyleRulesCallback = theme => ({ root: { @@ -51,10 +60,25 @@ const styles: StyleRulesCallback = theme => ({ textAlign: 'right', marginBottom: 24, marginTop: theme.spacing.unit + }, + panel: { + flexGrow: 1, + width: '100%', + backgroundColor: theme.color.white, + marginBottom: theme.spacing.unit * 3 + }, + inner: { + padding: theme.spacing.unit * 2, + [theme.breakpoints.up('sm')]: { + padding: theme.spacing.unit * 3 + } + }, + header: { + paddingBottom: theme.spacing.unit * 2 } }); -interface Props { +interface Props extends RenderGuardProps { selectedId: number | undefined; selectedUsername?: string; error?: string; @@ -68,9 +92,20 @@ interface Props { publicImages: Linode.Image[]; resetSelectedStackScript: () => void; disabled?: boolean; + request: ( + username: string, + params?: any, + filter?: any, + stackScriptGrants?: Linode.Grant[] + ) => Promise>; + category: string; + header: string; } -type CombinedProps = Props & StateProps & WithStyles; +type CombinedProps = Props & + StateProps & + RenderGuardProps & + WithStyles; interface State { stackScript?: Linode.StackScript.Response; @@ -87,9 +122,12 @@ class SelectStackScriptPanel extends React.Component { mounted: boolean = false; componentDidMount() { - if (this.props.selectedId) { + const selected = + this.props.selectedId || + getParamFromUrl(location.search, 'stackScriptID'); + if (selected) { this.setState({ stackScriptLoading: true }); - getStackScript(this.props.selectedId) + getStackScript(selected) .then(stackScript => { this.setState({ stackScript, stackScriptLoading: false }); this.props.onSelect( @@ -111,22 +149,6 @@ class SelectStackScriptPanel extends React.Component { this.mounted = false; } - createTabs = StackScriptTabs.map(tab => ({ - title: tab.title, - render: () => ( - - ) - })); - handleTabChange = () => { /* * if we're coming from a query string, the stackscript will be preselected @@ -141,7 +163,14 @@ class SelectStackScriptPanel extends React.Component { }; render() { - const { error, classes, selectedId } = this.props; + const { + category, + classes, + header, + request, + selectedId, + error + } = this.props; const { stackScript, stackScriptLoading, stackScriptError } = this.state; if (selectedId) { @@ -173,17 +202,13 @@ class SelectStackScriptPanel extends React.Component { deploymentsActive={stackScript.deployments_active} updated={formatDate(stackScript.updated, false)} checked={selectedId === stackScript.id} - updateFor={[selectedId === stackScript.id, classes]} + updateFor={[selectedId === stackScript.id]} stackScriptID={stackScript.id} />
-
@@ -193,23 +218,36 @@ class SelectStackScriptPanel extends React.Component { } return ( - - {stackScriptError && ( - - An error occured while loading the selected StackScript. Please - choose one from the list. + +
+ {error && } + + {header} - )} - - + {stackScriptError && ( + + An error occurred while loading the selected StackScript. + + )} + + + +
+
); } } @@ -226,12 +264,7 @@ const connected = connect(mapStateToProps); const styled = withStyles(styles); -export default compose< - Linode.TodoAny, - Linode.TodoAny, - Linode.TodoAny, - Linode.TodoAny ->( +export default compose( connected, RenderGuard, styled diff --git a/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptPanelContent.tsx b/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptPanelContent.tsx index e47481ab3bd..5f4aa7455d8 100644 --- a/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptPanelContent.tsx +++ b/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptPanelContent.tsx @@ -19,7 +19,8 @@ interface Props { request: ( username: string, params?: any, - filter?: any + filter?: any, + stackScriptGrants?: Linode.Grant[] ) => Promise>; category: string; disabled?: boolean; diff --git a/src/features/StackScripts/SelectStackScriptPanel/index.ts b/src/features/StackScripts/SelectStackScriptPanel/index.ts index d06eee18238..d39d3c670ae 100644 --- a/src/features/StackScripts/SelectStackScriptPanel/index.ts +++ b/src/features/StackScripts/SelectStackScriptPanel/index.ts @@ -1,3 +1,2 @@ import SelectStackScriptPanel from './SelectStackScriptPanel'; -// export { CommunityStackScripts, LinodeStackScripts, MyStackScripts }; export default SelectStackScriptPanel; diff --git a/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx b/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx index ab20e1fefb6..81793fa83db 100644 --- a/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx +++ b/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx @@ -13,12 +13,15 @@ import Table from 'src/components/Table'; import { isRestrictedUser } from 'src/features/Profile/permissionsHelpers'; import { MapState } from 'src/store/types'; import { sendEvent } from 'src/utilities/analytics'; +import { + getAPIErrorOrDefault, + handleUnauthorizedErrors +} from 'src/utilities/errorUtils'; import StackScriptTableHead from '../Partials/StackScriptTableHead'; import { AcceptedFilters, generateCatchAllFilter, - generateSpecificFilter, - getErrorText + generateSpecificFilter } from '../stackScriptUtils'; import withStyles, { StyleProps } from './StackScriptBase.styles'; @@ -45,7 +48,7 @@ export interface State { currentFilter: any; // @TODO type correctly currentSearchFilter: any; isSorting: boolean; - error?: Error; + error?: Linode.ApiFieldError[]; fieldError: Linode.ApiFieldError | undefined; isSearching: boolean; didSearch: boolean; @@ -98,7 +101,7 @@ const withStackScriptBase = (isSelecting: boolean) => ( componentDidMount() { this.mounted = true; - return this.getDataAtPage(0); + return this.getDataAtPage(1); } componentWillUnmount() { @@ -180,7 +183,10 @@ const withStackScriptBase = (isSelecting: boolean) => ( this.setState({ getMoreStackScriptsFailed: true }); } this.setState({ - error: e.response, + error: getAPIErrorOrDefault( + e, + 'There was an error loading StackScripts' + ), loading: false, gettingMoreStackScripts: false }); @@ -356,7 +362,13 @@ const withStackScriptBase = (isSelecting: boolean) => ( if (!this.mounted) { return; } - this.setState({ error: e, isSearching: false }); + this.setState({ + error: getAPIErrorOrDefault( + e, + 'There was an error loading StackScripts' + ), + isSearching: false + }); }); }; @@ -381,7 +393,14 @@ const withStackScriptBase = (isSelecting: boolean) => ( if (error) { return (
- +
); } diff --git a/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx b/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx index b24aa52d57c..29f34f98c3c 100644 --- a/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx +++ b/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx @@ -9,7 +9,10 @@ import { import RenderGuard from 'src/components/RenderGuard'; import TabbedPanel from 'src/components/TabbedPanel'; import { MapState } from 'src/store/types'; -import { StackScriptTabs } from '../stackScriptUtils'; +import { + getCommunityStackscripts, + getMineAndAccountStackScripts +} from '../stackScriptUtils'; import StackScriptPanelContent from './StackScriptPanelContent'; export interface ExtendedLinode extends Linode.Linode { @@ -93,6 +96,19 @@ class SelectStackScriptPanel extends React.Component { } } +export const StackScriptTabs = [ + { + title: 'Account StackScripts', + request: getMineAndAccountStackScripts, + category: 'account' + }, + { + title: 'Community StackScripts', + request: getCommunityStackscripts, + category: 'community' + } +]; + interface StateProps { username: string; } diff --git a/src/features/StackScripts/stackScriptUtils.ts b/src/features/StackScripts/stackScriptUtils.ts index fe4309c0002..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,15 +134,6 @@ 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.'; - } - return 'There was an error loading your StackScripts. Please try again later.'; -}; - export const getStackScriptUrl = ( username: string, id: number, diff --git a/src/features/linodes/LinodesCreate/LinodeCreate.tsx b/src/features/linodes/LinodesCreate/LinodeCreate.tsx index 8239c72a1aa..de9af128d5b 100644 --- a/src/features/linodes/LinodesCreate/LinodeCreate.tsx +++ b/src/features/linodes/LinodesCreate/LinodeCreate.tsx @@ -8,7 +8,7 @@ import ErrorState from 'src/components/ErrorState'; import Grid from 'src/components/Grid'; import { getCommunityStackscripts, - getStackScriptsByUser + getMineAndAccountStackScripts } from 'src/features/StackScripts/stackScriptUtils'; import { getParamsFromUrl } from 'src/utilities/queryParams'; import SubTabs, { Tab } from './LinodeCreateSubTabs'; @@ -332,7 +332,7 @@ export class LinodeCreate extends React.PureComponent< Promise>; header: string; } @@ -212,7 +213,7 @@ export class FromStackScriptContent extends React.PureComponent { {generalError && } - & 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 8f68769f6cb..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 && ( ({ linodeId: linode.id })); -const enhanced = compose( +const enhanced = compose( linodeContext, userSSHKeyHoc, styled, 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.'