From aacc1f4e4f721bd710bd4c7c4ddbf6570594a6b9 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 7 Mar 2019 10:27:05 -0500 Subject: [PATCH] CLAPPS - Create from App (#4604) * init commit * cleanup * init createapppanel * add apps panel * update label logic * move apps request to container * reset errors on new stackscript selection * fix placement of closing grid tag * fix rebase issues --- .../linodes/LinodesCreate/CALinodeCreate.tsx | 30 +- .../LinodesCreate/LinodeCreateContainer.tsx | 56 ++- src/features/linodes/LinodesCreate/Panel.tsx | 11 +- .../linodes/LinodesCreate/SelectAppPanel.tsx | 168 ++++++++ .../TabbedContent/FromAppsContent.tsx | 374 ++++++++++++++++++ .../TabbedContent/FromImageContent.tsx | 4 - .../TabbedContent/FromStackScriptContent.tsx | 52 +-- .../TabbedContent/formUtilities.ts | 44 +++ src/features/linodes/LinodesCreate/types.ts | 5 + 9 files changed, 681 insertions(+), 63 deletions(-) create mode 100644 src/features/linodes/LinodesCreate/SelectAppPanel.tsx create mode 100644 src/features/linodes/LinodesCreate/TabbedContent/FromAppsContent.tsx create mode 100644 src/features/linodes/LinodesCreate/TabbedContent/formUtilities.ts diff --git a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx index 0f24f9114fb..e72e2b99146 100644 --- a/src/features/linodes/LinodesCreate/CALinodeCreate.tsx +++ b/src/features/linodes/LinodesCreate/CALinodeCreate.tsx @@ -7,11 +7,13 @@ 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 { State as userSSHKeyProps } from 'src/features/linodes/userSSHKeyHoc'; import { getCommunityStackscripts, getStackScriptsByUser } from 'src/features/StackScripts/stackScriptUtils'; import SubTabs, { Tab } from './CALinodeCreateSubTabs'; +import FromAppsContent from './TabbedContent/FromAppsContent'; import FromBackupsContent from './TabbedContent/FromBackupsContent'; import FromImageContent from './TabbedContent/FromImageContent'; import FromLinodeContent from './TabbedContent/FromLinodeContent'; @@ -24,6 +26,7 @@ import { import { AllFormStateAndHandlers, + AppsData, WithAll, WithDisplayData, WithLinodesImagesTypesAndRegions @@ -37,7 +40,9 @@ type CombinedProps = Props & WithLinodesImagesTypesAndRegions & WithDisplayData & WithAll & - AllFormStateAndHandlers; + AppsData & + AllFormStateAndHandlers & + userSSHKeyProps; interface State { selectedTab: number; @@ -46,7 +51,7 @@ interface State { export class LinodeCreate extends React.PureComponent< CombinedProps & DispatchProps, State -> { + > { constructor(props: CombinedProps & DispatchProps) { super(props); @@ -107,6 +112,9 @@ export class LinodeCreate extends React.PureComponent< selectedStackScriptUsername, selectedStackScriptLabel, selectedLinodeID, + appInstances, + appInstancesError, + appInstancesLoading, ...rest } = this.props; return ( @@ -168,6 +176,9 @@ export class LinodeCreate extends React.PureComponent< selectedStackScriptUsername, selectedStackScriptLabel, selectedLinodeID, + appInstances, + appInstancesError, + appInstancesLoading, ...rest } = this.props; @@ -231,6 +242,9 @@ export class LinodeCreate extends React.PureComponent< linodesError, regionsLoading, regionsError, + appInstances, + appInstancesError, + appInstancesLoading, ...rest } = this.props; return ( @@ -256,6 +270,9 @@ export class LinodeCreate extends React.PureComponent< updateLinodeID, selectedBackupID, setBackupID, + appInstances, + appInstancesError, + appInstancesLoading, ...rest } = this.props; return ( @@ -276,7 +293,14 @@ export class LinodeCreate extends React.PureComponent< title: 'One-Click Apps', type: 'fromApp', render: () => { - return ; + const { + setTab, + linodesError, + linodesLoading, + linodesData, + ...rest + } = this.props; + return ; } }, { diff --git a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx index c156db78c17..c1dadcb9b45 100644 --- a/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/src/features/linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -53,6 +53,7 @@ import { MapState } from 'src/store/types'; import { allocatePrivateIP } from 'src/utilities/allocateIPAddress'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView'; +import { getCloudApps } from './TabbedContent/formUtilities'; interface State { selectedImageID?: string; @@ -74,6 +75,9 @@ interface State { tags?: Tag[]; errors?: Linode.ApiFieldError[]; formIsSubmitting: boolean; + appInstances?: Linode.StackScript.Response[]; + appInstancesLoading: boolean; + appInstancesError?: string; } type CombinedProps = InjectedNotistackProps & @@ -99,7 +103,9 @@ const defaultState: State = { selectedRegionID: undefined, selectedTypeID: undefined, tags: [], - formIsSubmitting: false + formIsSubmitting: false, + errors: undefined, + appInstancesLoading: false }; const getRegionIDFromLinodeID = ( @@ -127,6 +133,23 @@ class LinodeCreateContainer extends React.PureComponent { } } + componentDidMount() { + this.setState({ appInstancesLoading: true }); + getCloudApps() + .then(response => { + this.setState({ + appInstancesLoading: false, + appInstances: response.data + }); + }) + .catch(e => { + this.setState({ + appInstancesLoading: false, + appInstancesError: 'There was an error loading Cloud Apps.' + }); + }); + } + clearCreationState = () => { this.props.resetSSHKeys(); this.setState(defaultState); @@ -180,7 +203,15 @@ class LinodeCreateContainer extends React.PureComponent { userDefinedFields: Linode.StackScript.UserDefinedField[], images: Linode.Image[], defaultData?: any - ) => + ) => { + /** + * reset the selected Image but only if we're creating a Linode from + * a StackScript and not an app + */ + if (this.props.createType !== 'fromApp') { + this.setState({ selectedImageID: undefined }); + } + this.setState({ selectedStackScriptID: id, selectedStackScriptLabel: label, @@ -189,8 +220,10 @@ class LinodeCreateContainer extends React.PureComponent { availableStackScriptImages: images, udfs: defaultData, /** reset image because stackscript might not be compatible with selected one */ - selectedImageID: undefined + selectedImageID: undefined, + errors: undefined }); + }; setDiskSize = (size: number) => this.setState({ selectedDiskSize: size }); @@ -208,14 +241,24 @@ class LinodeCreateContainer extends React.PureComponent { generateLabel = () => { const { getLabel, imagesData, regionsData } = this.props; - const { selectedImageID, selectedRegionID } = this.state; + const { + selectedImageID, + selectedRegionID, + selectedStackScriptLabel + } = this.state; /* tslint:disable-next-line */ let arg1, arg2, arg3 = ''; - if (selectedImageID) { + /** + * lean in favor of using stackscript label + * then next priority is image label + */ + if (selectedStackScriptLabel) { + arg1 = selectedStackScriptLabel; + } else 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) @@ -469,6 +512,9 @@ class LinodeCreateContainer extends React.PureComponent { resetSSHKeys={this.props.resetSSHKeys} selectedBackupID={this.state.selectedBackupID} setBackupID={this.setBackupID} + appInstances={this.state.appInstances} + appInstancesError={this.state.appInstancesError} + appInstancesLoading={this.state.appInstancesLoading} /> diff --git a/src/features/linodes/LinodesCreate/Panel.tsx b/src/features/linodes/LinodesCreate/Panel.tsx index 7c85cece145..d870628e780 100644 --- a/src/features/linodes/LinodesCreate/Panel.tsx +++ b/src/features/linodes/LinodesCreate/Panel.tsx @@ -23,15 +23,16 @@ interface Props { children: React.ReactElement; error?: string; title?: string; + className?: string; } type CombinedProps = Props & WithStyles; -const Panel: React.StatelessComponent = (props) => { +const Panel: React.StatelessComponent = props => { const { classes, children, error, title } = props; return ( {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 &