From fcf7827e2274edfb28842128372f451917281a0c Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Tue, 9 Apr 2024 17:32:52 -0400 Subject: [PATCH 01/22] initial work --- .../src/components/PrimaryNav/PrimaryNav.tsx | 4 +- .../src/containers/withMarketplaceApps.ts | 4 +- .../Tabs/StackScripts/Images.tsx | 41 ++++++++ .../StackScripts/StackScriptSelectionList.tsx | 96 +++++++++++++++++++ .../StackScripts/StackScriptSelectionRow.tsx | 30 ++++++ .../Tabs/StackScripts/StackScripts.tsx | 45 +++++++++ .../Tabs/StackScripts/utilities.ts | 17 ++++ .../features/Linodes/LinodeCreatev2/index.tsx | 5 +- .../Linodes/LinodeCreatev2/utilities.ts | 3 + packages/manager/src/queries/stackscripts.ts | 70 ++++++++++---- 10 files changed, 294 insertions(+), 21 deletions(-) create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/Images.tsx create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 631a36b6991..5f6af7b2d8d 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -35,7 +35,7 @@ import { useObjectStorageClusters, } from 'src/queries/objectStorage'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { useStackScriptsOCA } from 'src/queries/stackscripts'; +import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import useStyles from './PrimaryNav.styles'; @@ -115,7 +115,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { data: oneClickApps, error: oneClickAppsError, isLoading: oneClickAppsLoading, - } = useStackScriptsOCA(enableMarketplacePrefetch); + } = useMarketplaceAppsQuery(enableMarketplacePrefetch); const { data: clusters, diff --git a/packages/manager/src/containers/withMarketplaceApps.ts b/packages/manager/src/containers/withMarketplaceApps.ts index 0dda04baf9f..f5040df7241 100644 --- a/packages/manager/src/containers/withMarketplaceApps.ts +++ b/packages/manager/src/containers/withMarketplaceApps.ts @@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom'; import { baseApps } from 'src/features/StackScripts/stackScriptUtils'; import { useFlags } from 'src/hooks/useFlags'; -import { useStackScriptsOCA } from 'src/queries/stackscripts'; +import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; const trimOneClickFromLabel = (script: StackScript) => { @@ -31,7 +31,7 @@ export const withMarketplaceApps = <Props>( // Only enable the query when the user is on the Marketplace page const enabled = type === 'One-Click'; - const { data, error, isLoading } = useStackScriptsOCA(enabled); + const { data, error, isLoading } = useMarketplaceAppsQuery(enabled); const newApps = flags.oneClickApps || []; const allowedApps = Object.keys({ ...baseApps, ...newApps }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/Images.tsx new file mode 100644 index 00000000000..4e5f09256a3 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/Images.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Controller, useWatch } from 'react-hook-form'; + +import { ImageSelectv2 } from 'src/components/ImageSelectv2/ImageSelectv2'; +import { Paper } from 'src/components/Paper'; +import { Typography } from 'src/components/Typography'; +import { useStackScriptQuery } from 'src/queries/stackscripts'; + +import type { CreateLinodeRequest } from '@linode/api-v4'; + +export const Images = () => { + const stackscriptId = useWatch<CreateLinodeRequest>({ + name: 'stackscript_id', + }); + + const { data: stackscript } = useStackScriptQuery( + stackscriptId, + Boolean(stackscriptId) + ); + + const imageSelectVariant = stackscript?.images.includes('any/all') + ? 'all' + : 'public'; + + return ( + <Paper> + <Typography variant="h2">Select an Image</Typography> + <Controller<CreateLinodeRequest, 'image'> + render={({ field, fieldState }) => ( + <ImageSelectv2 + errorText={fieldState.error?.message} + onChange={(e, image) => field.onChange(image?.id ?? null)} + value={field.value} + variant={imageSelectVariant} + /> + )} + name="image" + /> + </Paper> + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx new file mode 100644 index 00000000000..dec2b25b1bd --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { useController } from 'react-hook-form'; +import { Waypoint } from 'react-waypoint'; + +import { Box } from 'src/components/Box'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow/TableRow'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; +import { TableSortCell } from 'src/components/TableSortCell'; +import { useOrder } from 'src/hooks/useOrder'; +import { useStackScriptsInfiniteQuery } from 'src/queries/stackscripts'; + +import { StackScriptSelectionRow } from './StackScriptSelectionRow'; + +import type { StackScriptTabType } from './utilities'; +import type { CreateLinodeRequest } from '@linode/api-v4'; + +interface Props { + type: StackScriptTabType; +} + +export const StackScriptSelectionList = ({ type }: Props) => { + const { handleOrderChange, order, orderBy } = useOrder({ + order: 'desc', + orderBy: 'deployments_total', + }); + + const { field } = useController<CreateLinodeRequest, 'stackscript_id'>({ + name: 'stackscript_id', + }); + + const filter = + type === 'Community' + ? { + '+and': [ + { username: { '+neq': 'linode' } }, + { username: { '+neq': 'linode-stackscripts' } }, + ], + mine: false, + } + : { mine: true }; + + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useStackScriptsInfiniteQuery({ + ['+order']: order, + ['+order_by']: orderBy, + ...filter, + }); + + const stackscripts = data?.pages.flatMap((page) => page.data); + + return ( + <Box sx={{ maxHeight: 500, overflow: 'auto' }}> + <Table> + <TableHead> + <TableRow> + <TableCell sx={{ width: 20 }}></TableCell> + <TableSortCell + active={orderBy === 'label'} + direction={order} + handleClick={handleOrderChange} + label="label" + > + StackScript + </TableSortCell> + <TableCell></TableCell> + </TableRow> + </TableHead> + <TableBody> + {stackscripts?.map((stackscript) => ( + <StackScriptSelectionRow + isSelected={field.value === stackscript.id} + key={stackscript.id} + onSelect={() => field.onChange(stackscript.id)} + stackscript={stackscript} + /> + ))} + {error && <TableRowError colSpan={3} message={error[0].reason} />} + {isLoading && <TableRowLoading columns={3} rows={25} />} + {isFetchingNextPage && <TableRowLoading columns={3} rows={1} />} + {hasNextPage && <Waypoint onEnter={() => fetchNextPage()} />} + </TableBody> + </Table> + </Box> + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx new file mode 100644 index 00000000000..96095dfb67b --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import { Radio } from 'src/components/Radio/Radio'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; + +import type { StackScript } from '@linode/api-v4'; + +interface Props { + isSelected: boolean; + onSelect: () => void; + stackscript: StackScript; +} + +export const StackScriptSelectionRow = (props: Props) => { + const { isSelected, onSelect, stackscript } = props; + + return ( + <TableRow> + <TableCell> + <Radio checked={isSelected} onChange={onSelect} /> + </TableCell> + <TableCell>{stackscript.label}</TableCell> + <TableCell actionCell> + <InlineMenuAction actionText="Show Details" /> + </TableCell> + </TableRow> + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx new file mode 100644 index 00000000000..0449b38cf91 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { Tab } from 'src/components/Tabs/Tab'; +import { TabList } from 'src/components/Tabs/TabList'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; +import { Typography } from 'src/components/Typography'; + +import { useLinodeCreateQueryParams } from '../../utilities'; +import { Images } from './Images'; +import { StackScriptSelectionList } from './StackScriptSelectionList'; +import { getStackScriptTabIndex, tabs } from './utilities'; + +export const StackScripts = () => { + const { params, updateParams } = useLinodeCreateQueryParams(); + + return ( + <Stack spacing={3}> + <Paper> + <Typography variant="h2">Create From:</Typography> + <Tabs + index={getStackScriptTabIndex(params.subtype)} + onChange={(index) => updateParams({ subtype: tabs[index] })} + > + <TabList> + <Tab>Account StackScripts</Tab> + <Tab>Community StackScripts</Tab> + </TabList> + <TabPanels> + <SafeTabPanel index={0}> + <StackScriptSelectionList type="Account" /> + </SafeTabPanel> + <SafeTabPanel index={1}> + <StackScriptSelectionList type="Community" /> + </SafeTabPanel> + </TabPanels> + </Tabs> + </Paper> + <Images /> + </Stack> + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts new file mode 100644 index 00000000000..07fa6f6ecf7 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts @@ -0,0 +1,17 @@ +export type StackScriptTabType = 'Account' | 'Community'; + +export const getStackScriptTabIndex = (tab: StackScriptTabType | undefined) => { + if (tab === undefined) { + return 0; + } + + const tabIndex = tabs.indexOf(tab); + + if (tabIndex === -1) { + return 0; + } + + return tabIndex; +}; + +export const tabs = ['Account', 'Community'] as const; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index 59675e6923a..f248b30fa4a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -22,6 +22,7 @@ import { Region } from './Region'; import { Summary } from './Summary'; import { Distributions } from './Tabs/Distributions'; import { Images } from './Tabs/Images'; +import { StackScripts } from './Tabs/StackScripts/StackScripts'; import { UserData } from './UserData/UserData'; import { defaultValues, @@ -90,7 +91,9 @@ export const LinodeCreatev2 = () => { <Distributions /> </SafeTabPanel> <SafeTabPanel index={1}>Marketplace</SafeTabPanel> - <SafeTabPanel index={2}>StackScripts</SafeTabPanel> + <SafeTabPanel index={2}> + <StackScripts /> + </SafeTabPanel> <SafeTabPanel index={3}> <Images /> </SafeTabPanel> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 01da7ce0b23..c4a44c3c8e6 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -5,12 +5,14 @@ import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { utoa } from '../LinodesCreate/utilities'; import type { LinodeCreateType } from '../LinodesCreate/types'; +import type { StackScriptTabType } from './Tabs/StackScripts/utilities'; import type { CreateLinodeRequest, InterfacePayload } from '@linode/api-v4'; /** * This interface is used to type the query params on the Linode Create flow. */ interface LinodeCreateQueryParams { + subtype: StackScriptTabType | undefined; type: LinodeCreateType | undefined; } @@ -30,6 +32,7 @@ export const useLinodeCreateQueryParams = () => { }; const params = { + subtype: rawParams.subtype as StackScriptTabType | undefined, type: rawParams.type as LinodeCreateType | undefined, } as LinodeCreateQueryParams; diff --git a/packages/manager/src/queries/stackscripts.ts b/packages/manager/src/queries/stackscripts.ts index 607edb6c24a..0ac93e0c100 100644 --- a/packages/manager/src/queries/stackscripts.ts +++ b/packages/manager/src/queries/stackscripts.ts @@ -1,26 +1,64 @@ -import { StackScript } from '@linode/api-v4/lib/stackscripts'; -import { APIError, Params } from '@linode/api-v4/lib/types'; -import { useQuery } from '@tanstack/react-query'; +import { + StackScript, + getStackScript, + getStackScripts, +} from '@linode/api-v4/lib/stackscripts'; +import { + APIError, + Filter, + Params, + ResourcePage, +} from '@linode/api-v4/lib/types'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { getOneClickApps } from 'src/features/StackScripts/stackScriptUtils'; import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; -export const queryKey = 'stackscripts'; - -export const useStackScriptsOCA = (enabled: boolean, params: Params = {}) => { - return useQuery<StackScript[], APIError[]>( - [`${queryKey}-oca-all`, params], - () => getAllOCAsRequest(params), - { - enabled, - ...queryPresets.oneTimeFetch, - } - ); -}; - export const getAllOCAsRequest = (passedParams: Params = {}) => getAll<StackScript>((params) => getOneClickApps({ ...params, ...passedParams }) )().then((data) => data.data); + +const stackscriptQueries = createQueryKeys('stackscripts', { + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getStackScripts({ page: pageParam, page_size: 25 }, filter), + queryKey: [filter], + }), + marketplace: { + queryFn: () => getAllOCAsRequest(), + queryKey: null, + }, + stackscript: (id: number) => ({ + queryFn: () => getStackScript(id), + queryKey: [id], + }), +}); + +export const useMarketplaceAppsQuery = (enabled: boolean) => { + return useQuery<StackScript[], APIError[]>({ + ...stackscriptQueries.marketplace, + enabled, + ...queryPresets.oneTimeFetch, + }); +}; + +export const useStackScriptQuery = (id: number, enabled = true) => + useQuery<StackScript, APIError[]>({ + ...stackscriptQueries.stackscript(id), + enabled, + }); + +export const useStackScriptsInfiniteQuery = (filter: Filter = {}) => + useInfiniteQuery<ResourcePage<StackScript>, APIError[]>({ + ...stackscriptQueries.infinite(filter), + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + }); From 0b82b26ff75ce6f82842fafd1dcde88457193a3e Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Tue, 9 Apr 2024 17:52:16 -0400 Subject: [PATCH 02/22] filter options based on stackscript compatibility --- .../src/components/ImageSelectv2/ImageSelectv2.tsx | 10 ++++++++-- .../LinodeCreatev2/Tabs/StackScripts/Images.tsx | 11 ++++++++--- .../Tabs/StackScripts/StackScriptSelectionRow.tsx | 4 ++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx index c40255eda3e..ea1369ced0a 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx @@ -16,6 +16,10 @@ export type ImageSelectVariant = 'all' | 'private' | 'public'; interface Props extends Omit<Partial<EnhancedAutocompleteProps<Image>>, 'value'> { + /** + * Optional filter function applied to the options. + */ + filter?: (image: Image) => boolean; /** * The ID of the selected image */ @@ -30,7 +34,7 @@ interface Props } export const ImageSelectv2 = (props: Props) => { - const { variant, ...rest } = props; + const { filter, variant, ...rest } = props; const { data: images, error, isLoading } = useAllImagesQuery( {}, @@ -40,6 +44,8 @@ export const ImageSelectv2 = (props: Props) => { // We can't filter out Kubernetes images using the API so we filter them here const options = getFilteredImagesForImageSelect(images, variant); + const filteredOptions = filter ? options?.filter(filter) : options; + const value = images?.find((i) => i.id === props.value); return ( @@ -55,7 +61,7 @@ export const ImageSelectv2 = (props: Props) => { groupBy={(option) => option.vendor ?? 'My Images'} label="Images" loading={isLoading} - options={options ?? []} + options={filteredOptions ?? []} placeholder="Choose an image" {...rest} errorText={rest.errorText ?? error?.[0].reason} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/Images.tsx index 4e5f09256a3..739dc662d19 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/Images.tsx @@ -18,9 +18,9 @@ export const Images = () => { Boolean(stackscriptId) ); - const imageSelectVariant = stackscript?.images.includes('any/all') - ? 'all' - : 'public'; + const shouldFilterImages = !stackscript?.images.includes('any/all'); + + const imageSelectVariant = shouldFilterImages ? 'public' : 'all'; return ( <Paper> @@ -28,6 +28,11 @@ export const Images = () => { <Controller<CreateLinodeRequest, 'image'> render={({ field, fieldState }) => ( <ImageSelectv2 + filter={ + shouldFilterImages + ? (image) => stackscript?.images.includes(image.id) ?? false + : undefined + } errorText={fieldState.error?.message} onChange={(e, image) => field.onChange(image?.id ?? null)} value={field.value} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx index 96095dfb67b..4b5f58969bc 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx @@ -16,6 +16,10 @@ interface Props { export const StackScriptSelectionRow = (props: Props) => { const { isSelected, onSelect, stackscript } = props; + if (stackscript.username.startsWith('lke-service-account-')) { + return null; + } + return ( <TableRow> <TableCell> From e02f1c06309cf422f0564a598e2ed3558a3ac688 Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Tue, 9 Apr 2024 18:56:56 -0400 Subject: [PATCH 03/22] implement complex preselection logic --- .../StackScripts/StackScriptSelectionList.tsx | 67 ++++++++++++++++--- .../StackScripts/StackScriptSelectionRow.tsx | 5 +- .../Tabs/StackScripts/StackScripts.tsx | 4 +- .../Linodes/LinodeCreatev2/utilities.ts | 57 ++++++++++------ packages/manager/src/queries/stackscripts.ts | 6 +- 5 files changed, 104 insertions(+), 35 deletions(-) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx index dec2b25b1bd..c3ff1096136 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx @@ -3,6 +3,8 @@ import { useController } from 'react-hook-form'; import { Waypoint } from 'react-waypoint'; import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { Stack } from 'src/components/Stack'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell/TableCell'; @@ -12,11 +14,15 @@ import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; import { useOrder } from 'src/hooks/useOrder'; -import { useStackScriptsInfiniteQuery } from 'src/queries/stackscripts'; +import { + useStackScriptQuery, + useStackScriptsInfiniteQuery, +} from 'src/queries/stackscripts'; +import { useLinodeCreateQueryParams } from '../../utilities'; import { StackScriptSelectionRow } from './StackScriptSelectionRow'; +import { StackScriptTabType } from './utilities'; -import type { StackScriptTabType } from './utilities'; import type { CreateLinodeRequest } from '@linode/api-v4'; interface Props { @@ -44,6 +50,15 @@ export const StackScriptSelectionList = ({ type }: Props) => { } : { mine: true }; + const { params, updateParams } = useLinodeCreateQueryParams(); + + const hasPreselectedStackScript = Boolean(params.stackScriptID); + + const { data: stackscript } = useStackScriptQuery( + params.stackScriptID ?? -1, + hasPreselectedStackScript + ); + const { data, error, @@ -51,16 +66,50 @@ export const StackScriptSelectionList = ({ type }: Props) => { hasNextPage, isFetchingNextPage, isLoading, - } = useStackScriptsInfiniteQuery({ - ['+order']: order, - ['+order_by']: orderBy, - ...filter, - }); + } = useStackScriptsInfiniteQuery( + { + ['+order']: order, + ['+order_by']: orderBy, + ...filter, + }, + !hasPreselectedStackScript + ); const stackscripts = data?.pages.flatMap((page) => page.data); + if (hasPreselectedStackScript) { + return ( + <Stack spacing={1}> + <Table> + <TableHead> + <TableRow> + <TableCell sx={{ width: 20 }}></TableCell> + <TableCell>StackScript</TableCell> + <TableCell></TableCell> + </TableRow> + </TableHead> + <TableBody> + {stackscript && ( + <StackScriptSelectionRow + disabled + isSelected={field.value === stackscript.id} + onSelect={() => field.onChange(stackscript.id)} + stackscript={stackscript} + /> + )} + </TableBody> + </Table> + <Box display="flex" justifyContent="flex-end"> + <Button onClick={() => updateParams({ stackScriptID: undefined })}> + Choose Another StackScript + </Button> + </Box> + </Stack> + ); + } + return ( - <Box sx={{ maxHeight: 500, overflow: 'auto' }}> + <Stack spacing={1} sx={{ maxHeight: 500, overflow: 'auto' }}> <Table> <TableHead> <TableRow> @@ -91,6 +140,6 @@ export const StackScriptSelectionList = ({ type }: Props) => { {hasNextPage && <Waypoint onEnter={() => fetchNextPage()} />} </TableBody> </Table> - </Box> + </Stack> ); }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx index 4b5f58969bc..a675200b769 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx @@ -8,13 +8,14 @@ import { TableRow } from 'src/components/TableRow'; import type { StackScript } from '@linode/api-v4'; interface Props { + disabled?: boolean; isSelected: boolean; onSelect: () => void; stackscript: StackScript; } export const StackScriptSelectionRow = (props: Props) => { - const { isSelected, onSelect, stackscript } = props; + const { disabled, isSelected, onSelect, stackscript } = props; if (stackscript.username.startsWith('lke-service-account-')) { return null; @@ -23,7 +24,7 @@ export const StackScriptSelectionRow = (props: Props) => { return ( <TableRow> <TableCell> - <Radio checked={isSelected} onChange={onSelect} /> + <Radio checked={isSelected} disabled={disabled} onChange={onSelect} /> </TableCell> <TableCell>{stackscript.label}</TableCell> <TableCell actionCell> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx index 0449b38cf91..13d9546e581 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx @@ -22,8 +22,10 @@ export const StackScripts = () => { <Paper> <Typography variant="h2">Create From:</Typography> <Tabs + onChange={(index) => + updateParams({ stackScriptID: undefined, subtype: tabs[index] }) + } index={getStackScriptTabIndex(params.subtype)} - onChange={(index) => updateParams({ subtype: tabs[index] })} > <TabList> <Tab>Account StackScripts</Tab> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index c4a44c3c8e6..47197eb9b92 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -12,6 +12,7 @@ import type { CreateLinodeRequest, InterfacePayload } from '@linode/api-v4'; * This interface is used to type the query params on the Linode Create flow. */ interface LinodeCreateQueryParams { + stackScriptID: string | undefined; subtype: StackScriptTabType | undefined; type: LinodeCreateType | undefined; } @@ -32,9 +33,12 @@ export const useLinodeCreateQueryParams = () => { }; const params = { + stackScriptID: rawParams.stackScriptID + ? Number(rawParams.stackScriptID) + : undefined, subtype: rawParams.subtype as StackScriptTabType | undefined, type: rawParams.type as LinodeCreateType | undefined, - } as LinodeCreateQueryParams; + }; return { params, updateParams }; }; @@ -126,25 +130,34 @@ export const getInterfacesPayload = ( return interfaces; }; -export const defaultValues: CreateLinodeRequest = { - image: 'linode/debian11', - interfaces: [ - { - ipam_address: '', - label: '', - purpose: 'public', - }, - { - ipam_address: '', - label: '', - purpose: 'vlan', - }, - { - ipam_address: '', - label: '', - purpose: 'vpc', - }, - ], - region: '', - type: '', +export const defaultValues = async (): Promise<CreateLinodeRequest> => { + const queryParams = getQueryParamsFromQueryString(window.location.search); + + const stackScriptID = queryParams.stackScriptID + ? Number(queryParams.stackScriptID) + : undefined; + + return { + image: 'linode/debian11', + interfaces: [ + { + ipam_address: '', + label: '', + purpose: 'public', + }, + { + ipam_address: '', + label: '', + purpose: 'vlan', + }, + { + ipam_address: '', + label: '', + purpose: 'vpc', + }, + ], + region: '', + stackscript_id: stackScriptID, + type: '', + }; }; diff --git a/packages/manager/src/queries/stackscripts.ts b/packages/manager/src/queries/stackscripts.ts index 0ac93e0c100..b2eb2159179 100644 --- a/packages/manager/src/queries/stackscripts.ts +++ b/packages/manager/src/queries/stackscripts.ts @@ -52,9 +52,13 @@ export const useStackScriptQuery = (id: number, enabled = true) => enabled, }); -export const useStackScriptsInfiniteQuery = (filter: Filter = {}) => +export const useStackScriptsInfiniteQuery = ( + filter: Filter = {}, + enabled = true +) => useInfiniteQuery<ResourcePage<StackScript>, APIError[]>({ ...stackscriptQueries.infinite(filter), + enabled, getNextPageParam: ({ page, pages }) => { if (page === pages) { return undefined; From c656c1a42ffa78611d2928a404ddcb1af1ce5bfd Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Tue, 9 Apr 2024 19:02:51 -0400 Subject: [PATCH 04/22] clean up a bit --- .../StackScripts/StackScriptSelectionList.tsx | 26 +++++++++---------- .../Tabs/StackScripts/utilities.ts | 10 +++++++ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx index c3ff1096136..622b4d9a1ad 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx @@ -21,7 +21,11 @@ import { import { useLinodeCreateQueryParams } from '../../utilities'; import { StackScriptSelectionRow } from './StackScriptSelectionRow'; -import { StackScriptTabType } from './utilities'; +import { + StackScriptTabType, + accountStackScriptFilter, + communityStackScriptFilter, +} from './utilities'; import type { CreateLinodeRequest } from '@linode/api-v4'; @@ -39,17 +43,6 @@ export const StackScriptSelectionList = ({ type }: Props) => { name: 'stackscript_id', }); - const filter = - type === 'Community' - ? { - '+and': [ - { username: { '+neq': 'linode' } }, - { username: { '+neq': 'linode-stackscripts' } }, - ], - mine: false, - } - : { mine: true }; - const { params, updateParams } = useLinodeCreateQueryParams(); const hasPreselectedStackScript = Boolean(params.stackScriptID); @@ -59,6 +52,11 @@ export const StackScriptSelectionList = ({ type }: Props) => { hasPreselectedStackScript ); + const filter = + type === 'Community' + ? communityStackScriptFilter + : accountStackScriptFilter; + const { data, error, @@ -109,7 +107,7 @@ export const StackScriptSelectionList = ({ type }: Props) => { } return ( - <Stack spacing={1} sx={{ maxHeight: 500, overflow: 'auto' }}> + <Box sx={{ maxHeight: 500, overflow: 'auto' }}> <Table> <TableHead> <TableRow> @@ -140,6 +138,6 @@ export const StackScriptSelectionList = ({ type }: Props) => { {hasNextPage && <Waypoint onEnter={() => fetchNextPage()} />} </TableBody> </Table> - </Stack> + </Box> ); }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts index 07fa6f6ecf7..4bbf74f5f42 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts @@ -15,3 +15,13 @@ export const getStackScriptTabIndex = (tab: StackScriptTabType | undefined) => { }; export const tabs = ['Account', 'Community'] as const; + +export const communityStackScriptFilter = { + '+and': [ + { username: { '+neq': 'linode' } }, + { username: { '+neq': 'linode-stackscripts' } }, + ], + mine: false, +}; + +export const accountStackScriptFilter = { mine: true }; From 9a5b510341f692d29d8f37c8445f929702be5a72 Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Wed, 10 Apr 2024 11:27:01 -0400 Subject: [PATCH 05/22] implement details dialog --- .../Tabs/StackScripts/StackScriptDialog.tsx | 41 +++++++++++++++++++ .../StackScripts/StackScriptSelectionList.tsx | 14 ++++++- .../StackScripts/StackScriptSelectionRow.tsx | 33 ++++++++++++--- 3 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDialog.tsx diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDialog.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDialog.tsx new file mode 100644 index 00000000000..1b8469b31a0 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDialog.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { CircleProgress } from 'src/components/CircleProgress'; +import { Dialog } from 'src/components/Dialog/Dialog'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { StackScript } from 'src/components/StackScript/StackScript'; +import { useStackScriptQuery } from 'src/queries/stackscripts'; + +interface Props { + id: number | undefined; + onClose: () => void; + open: boolean; +} + +export const StackScriptDetailsDialog = (props: Props) => { + const { id, onClose, open } = props; + + const { data: stackscript, error, isLoading } = useStackScriptQuery( + id ?? -1, + Boolean(id) + ); + + const title = stackscript + ? `${stackscript.username} / ${stackscript.label}` + : 'StackScript'; + + return ( + <Dialog + fullHeight + fullWidth + maxWidth="md" + onClose={onClose} + open={open} + title={title} + > + {isLoading && <CircleProgress />} + {error && <ErrorState errorText={error[0].reason} />} + {stackscript && <StackScript data={stackscript} userCanModify={false} />} + </Dialog> + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx index 622b4d9a1ad..7eb3c1ff9cb 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useController } from 'react-hook-form'; import { Waypoint } from 'react-waypoint'; @@ -28,6 +28,7 @@ import { } from './utilities'; import type { CreateLinodeRequest } from '@linode/api-v4'; +import { StackScriptDetailsDialog } from './StackScriptDialog'; interface Props { type: StackScriptTabType; @@ -43,6 +44,8 @@ export const StackScriptSelectionList = ({ type }: Props) => { name: 'stackscript_id', }); + const [selectedStackScriptId, setSelectedStackScriptId] = useState<number>(); + const { params, updateParams } = useLinodeCreateQueryParams(); const hasPreselectedStackScript = Boolean(params.stackScriptID); @@ -83,7 +86,7 @@ export const StackScriptSelectionList = ({ type }: Props) => { <TableRow> <TableCell sx={{ width: 20 }}></TableCell> <TableCell>StackScript</TableCell> - <TableCell></TableCell> + <TableCell sx={{ minWidth: 120 }}></TableCell> </TableRow> </TableHead> <TableBody> @@ -91,6 +94,7 @@ export const StackScriptSelectionList = ({ type }: Props) => { <StackScriptSelectionRow disabled isSelected={field.value === stackscript.id} + onOpenDetails={() => setSelectedStackScriptId(stackscript.id)} onSelect={() => field.onChange(stackscript.id)} stackscript={stackscript} /> @@ -128,6 +132,7 @@ export const StackScriptSelectionList = ({ type }: Props) => { <StackScriptSelectionRow isSelected={field.value === stackscript.id} key={stackscript.id} + onOpenDetails={() => setSelectedStackScriptId(stackscript.id)} onSelect={() => field.onChange(stackscript.id)} stackscript={stackscript} /> @@ -138,6 +143,11 @@ export const StackScriptSelectionList = ({ type }: Props) => { {hasNextPage && <Waypoint onEnter={() => fetchNextPage()} />} </TableBody> </Table> + <StackScriptDetailsDialog + id={selectedStackScriptId} + onClose={() => setSelectedStackScriptId(undefined)} + open={selectedStackScriptId !== undefined} + /> </Box> ); }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx index a675200b769..bd9b254e07a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx @@ -2,20 +2,24 @@ import React from 'react'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { Radio } from 'src/components/Radio/Radio'; +import { Stack } from 'src/components/Stack'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { Typography } from 'src/components/Typography'; +import { truncate } from 'src/utilities/truncate'; import type { StackScript } from '@linode/api-v4'; interface Props { disabled?: boolean; isSelected: boolean; + onOpenDetails: () => void; onSelect: () => void; stackscript: StackScript; } export const StackScriptSelectionRow = (props: Props) => { - const { disabled, isSelected, onSelect, stackscript } = props; + const { disabled, isSelected, onOpenDetails, onSelect, stackscript } = props; if (stackscript.username.startsWith('lke-service-account-')) { return null; @@ -24,11 +28,30 @@ export const StackScriptSelectionRow = (props: Props) => { return ( <TableRow> <TableCell> - <Radio checked={isSelected} disabled={disabled} onChange={onSelect} /> + <Radio + checked={isSelected} + disabled={disabled} + id={`stackscript-${stackscript.id}`} + onChange={onSelect} + /> </TableCell> - <TableCell>{stackscript.label}</TableCell> - <TableCell actionCell> - <InlineMenuAction actionText="Show Details" /> + <TableCell> + <Stack> + <Typography> + {stackscript.username} / {stackscript.label} + </Typography> + <Typography + sx={(theme) => ({ + color: theme.textColors.tableHeader, + fontSize: '.75rem', + })} + > + {truncate(stackscript.description, 100)} + </Typography> + </Stack> + </TableCell> + <TableCell actionCell sx={{ minWidth: 120 }}> + <InlineMenuAction actionText="Show Details" onClick={onOpenDetails} /> </TableCell> </TableRow> ); From 1b936980fb008e289619ddd4e921398538df565c Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Wed, 10 Apr 2024 11:39:25 -0400 Subject: [PATCH 06/22] fix some query param logic --- .../Tabs/StackScripts/StackScriptSelectionList.tsx | 11 ++++++++--- .../Tabs/StackScripts/StackScriptSelectionRow.tsx | 7 +------ .../src/features/Linodes/LinodeCreatev2/utilities.ts | 11 ++++++++++- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx index 7eb3c1ff9cb..d0d47f64889 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx @@ -20,15 +20,15 @@ import { } from 'src/queries/stackscripts'; import { useLinodeCreateQueryParams } from '../../utilities'; +import { StackScriptDetailsDialog } from './StackScriptDialog'; import { StackScriptSelectionRow } from './StackScriptSelectionRow'; import { - StackScriptTabType, accountStackScriptFilter, communityStackScriptFilter, } from './utilities'; +import type { StackScriptTabType } from './utilities'; import type { CreateLinodeRequest } from '@linode/api-v4'; -import { StackScriptDetailsDialog } from './StackScriptDialog'; interface Props { type: StackScriptTabType; @@ -102,7 +102,12 @@ export const StackScriptSelectionList = ({ type }: Props) => { </TableBody> </Table> <Box display="flex" justifyContent="flex-end"> - <Button onClick={() => updateParams({ stackScriptID: undefined })}> + <Button + onClick={() => { + field.onChange(null); + updateParams({ stackScriptID: undefined }); + }} + > Choose Another StackScript </Button> </Box> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx index bd9b254e07a..c6fc066c024 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx @@ -28,12 +28,7 @@ export const StackScriptSelectionRow = (props: Props) => { return ( <TableRow> <TableCell> - <Radio - checked={isSelected} - disabled={disabled} - id={`stackscript-${stackscript.id}`} - onChange={onSelect} - /> + <Radio checked={isSelected} disabled={disabled} onChange={onSelect} /> </TableCell> <TableCell> <Stack> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 47197eb9b92..749ce90047b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -28,7 +28,16 @@ export const useLinodeCreateQueryParams = () => { const rawParams = getQueryParamsFromQueryString(history.location.search); const updateParams = (params: Partial<LinodeCreateQueryParams>) => { - const newParams = new URLSearchParams({ ...rawParams, ...params }); + const newParams = new URLSearchParams(rawParams); + + for (const key in params) { + if (!params[key]) { + newParams.delete(key); + } else { + newParams.set(key, params[key]); + } + } + history.push({ search: newParams.toString() }); }; From 325ef5f926ca26b90b2c56baca75c43253be9f14 Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Wed, 10 Apr 2024 12:05:22 -0400 Subject: [PATCH 07/22] lots of behavior changes to match production --- packages/api-v4/src/linodes/types.ts | 2 +- .../ImageSelectv2/ImageSelectv2.tsx | 1 + .../StackScripts/StackScriptSelectionList.tsx | 20 +++++++++++++++---- .../Tabs/StackScripts/StackScripts.tsx | 20 ++++++++++++++++--- packages/validation/src/linodes.schema.ts | 2 +- 5 files changed, 36 insertions(+), 9 deletions(-) diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 2b39c104c81..70a4feeaef5 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -363,7 +363,7 @@ export interface CreateLinodeRequest { * * This field cannot be used when deploying from a Backup or a Private Image. */ - stackscript_id?: number; + stackscript_id?: number | null; /** * A Backup ID from another Linode’s available backups. * diff --git a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx index ea1369ced0a..ba6726267b0 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx @@ -58,6 +58,7 @@ export const ImageSelectv2 = (props: Props) => { listItemProps={props} /> )} + clearOnBlur groupBy={(option) => option.vendor ?? 'My Images'} label="Images" loading={isLoading} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx index d0d47f64889..e6ab66872b2 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { useController } from 'react-hook-form'; +import { useController, useFormContext } from 'react-hook-form'; import { Waypoint } from 'react-waypoint'; import { Box } from 'src/components/Box'; @@ -40,7 +40,10 @@ export const StackScriptSelectionList = ({ type }: Props) => { orderBy: 'deployments_total', }); - const { field } = useController<CreateLinodeRequest, 'stackscript_id'>({ + const { control, setValue } = useFormContext<CreateLinodeRequest>(); + + const { field } = useController({ + control, name: 'stackscript_id', }); @@ -135,17 +138,26 @@ export const StackScriptSelectionList = ({ type }: Props) => { <TableBody> {stackscripts?.map((stackscript) => ( <StackScriptSelectionRow + onSelect={() => { + setValue('image', null); + field.onChange(stackscript.id); + }} isSelected={field.value === stackscript.id} key={stackscript.id} onOpenDetails={() => setSelectedStackScriptId(stackscript.id)} - onSelect={() => field.onChange(stackscript.id)} stackscript={stackscript} /> ))} {error && <TableRowError colSpan={3} message={error[0].reason} />} {isLoading && <TableRowLoading columns={3} rows={25} />} {isFetchingNextPage && <TableRowLoading columns={3} rows={1} />} - {hasNextPage && <Waypoint onEnter={() => fetchNextPage()} />} + {hasNextPage && ( + <TableRow> + <TableCell> + <Waypoint onEnter={() => fetchNextPage()} /> + </TableCell> + </TableRow> + )} </TableBody> </Table> <StackScriptDetailsDialog diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx index 13d9546e581..fc61517c8e0 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { useFormContext } from 'react-hook-form'; +import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { Stack } from 'src/components/Stack'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; @@ -14,17 +16,29 @@ import { Images } from './Images'; import { StackScriptSelectionList } from './StackScriptSelectionList'; import { getStackScriptTabIndex, tabs } from './utilities'; +import type { CreateLinodeRequest } from '@linode/api-v4'; + export const StackScripts = () => { const { params, updateParams } = useLinodeCreateQueryParams(); + const { formState, setValue } = useFormContext<CreateLinodeRequest>(); return ( <Stack spacing={3}> <Paper> <Typography variant="h2">Create From:</Typography> + {formState.errors.stackscript_id && ( + <Notice + spacingBottom={0} + spacingTop={8} + text={formState.errors.stackscript_id.message} + variant="error" + /> + )} <Tabs - onChange={(index) => - updateParams({ stackScriptID: undefined, subtype: tabs[index] }) - } + onChange={(index) => { + updateParams({ stackScriptID: undefined, subtype: tabs[index] }); + setValue('stackscript_id', null); + }} index={getStackScriptTabIndex(params.subtype)} > <TabList> diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 551e912a0a5..927e643e697 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -267,7 +267,7 @@ const PlacementGroupPayloadSchema = object({ export const CreateLinodeSchema = object({ type: string().ensure().required('Plan is required.'), region: string().ensure().required('Region is required.'), - stackscript_id: number().notRequired(), + stackscript_id: number().nullable().notRequired(), backup_id: number().notRequired(), swap_size: number().notRequired(), image: string().when('stackscript_id', { From 8f1d6d66026a57db27113fcaacd05d4ddd1937d2 Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Wed, 10 Apr 2024 12:18:55 -0400 Subject: [PATCH 08/22] fix image select clear behavior and fix table waypoint console error --- .../manager/src/components/ImageSelectv2/utilities.ts | 6 +++--- .../Tabs/StackScripts/StackScriptSelectionList.tsx | 10 +++------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/manager/src/components/ImageSelectv2/utilities.ts b/packages/manager/src/components/ImageSelectv2/utilities.ts index 23510e3aafc..fadd4bf42cf 100644 --- a/packages/manager/src/components/ImageSelectv2/utilities.ts +++ b/packages/manager/src/components/ImageSelectv2/utilities.ts @@ -33,7 +33,7 @@ export const getFilteredImagesForImageSelect = ( images: Image[] | undefined, variant: ImageSelectVariant | undefined ) => { - return variant === 'public' - ? images?.filter((image) => !image.id.includes('kube')) - : images; + return variant === 'private' + ? images + : images?.filter((image) => !image.id.includes('kube')); }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx index e6ab66872b2..61e7397750b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx @@ -150,16 +150,12 @@ export const StackScriptSelectionList = ({ type }: Props) => { ))} {error && <TableRowError colSpan={3} message={error[0].reason} />} {isLoading && <TableRowLoading columns={3} rows={25} />} - {isFetchingNextPage && <TableRowLoading columns={3} rows={1} />} - {hasNextPage && ( - <TableRow> - <TableCell> - <Waypoint onEnter={() => fetchNextPage()} /> - </TableCell> - </TableRow> + {(isFetchingNextPage || hasNextPage) && ( + <TableRowLoading columns={3} rows={1} /> )} </TableBody> </Table> + {hasNextPage && <Waypoint onEnter={() => fetchNextPage()} />} <StackScriptDetailsDialog id={selectedStackScriptId} onClose={() => setSelectedStackScriptId(undefined)} From c694197c4ab6d6afd189ba68f7d8002dad2d4b14 Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Wed, 10 Apr 2024 12:29:44 -0400 Subject: [PATCH 09/22] improve validation for when image is `null` --- packages/validation/src/linodes.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index 927e643e697..b3456f6bb9f 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -272,7 +272,7 @@ export const CreateLinodeSchema = object({ swap_size: number().notRequired(), image: string().when('stackscript_id', { is: (value?: number) => value !== undefined, - then: string().required('Image is required.'), + then: string().ensure().required('Image is required.'), otherwise: string().nullable().notRequired(), }), authorized_keys: array().of(string()).notRequired(), From 5148d520676ce349c8cf7a82d9318b5ef8525c88 Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Wed, 10 Apr 2024 14:02:36 -0400 Subject: [PATCH 10/22] first unit tests --- .../StackScriptSelectionList.test.tsx | 88 +++++++++++++++++++ .../StackScripts/StackScriptSelectionRow.tsx | 36 +++++--- 2 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.test.tsx diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.test.tsx new file mode 100644 index 00000000000..c7f3c178571 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.test.tsx @@ -0,0 +1,88 @@ +import React from 'react'; + +import { stackScriptFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { StackScriptSelectionList } from './StackScriptSelectionList'; + +describe('StackScriptSelectionList', () => { + it('renders StackScripts returned by the API', async () => { + const stackscripts = stackScriptFactory.buildList(5); + + server.use( + http.get('*/v4/linode/stackscripts', () => { + return HttpResponse.json(makeResourcePage(stackscripts)); + }) + ); + + const { findByText } = renderWithThemeAndHookFormContext({ + component: <StackScriptSelectionList type="Account" />, + }); + + for (const stackscript of stackscripts) { + // eslint-disable-next-line no-await-in-loop + const item = await findByText(stackscript.label, { exact: false }); + + expect(item).toBeVisible(); + } + }); + + it('renders and selected a StackScript from query params if one is specified', async () => { + const stackscript = stackScriptFactory.build(); + + server.use( + http.get('*/v4/linode/stackscripts/:id', () => { + return HttpResponse.json(stackscript); + }) + ); + + const { findByLabelText, getByText } = renderWithThemeAndHookFormContext({ + component: <StackScriptSelectionList type="Account" />, + options: { + MemoryRouter: { + initialEntries: [ + '/linodes/create?type=StackScripts&subtype=Account&stackScriptID=921609', + ], + }, + }, + }); + + const stackscriptItem = await findByLabelText(stackscript.label, { + exact: false, + }); + + expect(stackscriptItem).toBeInTheDocument(); + + expect(getByText('Choose Another StackScript')).toBeVisible(); + }); + + it('checks the selected StackScripts Radio if it is selected', async () => { + const stackscripts = stackScriptFactory.buildList(5); + + const selectedStackScript = stackscripts[2]; + + server.use( + http.get('*/v4/linode/stackscripts', () => { + return HttpResponse.json(makeResourcePage(stackscripts)); + }) + ); + + const { findByLabelText } = renderWithThemeAndHookFormContext({ + component: <StackScriptSelectionList type="Account" />, + useFormOptions: { + defaultValues: { stackscript_id: selectedStackScript.id }, + }, + }); + + const selectedStackScriptRadio = await findByLabelText( + selectedStackScript.label, + { + exact: false, + } + ); + + expect(selectedStackScriptRadio).toBeChecked(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx index c6fc066c024..a15d8b0fa05 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx @@ -1,3 +1,4 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ import React from 'react'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; @@ -28,22 +29,29 @@ export const StackScriptSelectionRow = (props: Props) => { return ( <TableRow> <TableCell> - <Radio checked={isSelected} disabled={disabled} onChange={onSelect} /> + <Radio + checked={isSelected} + disabled={disabled} + id={`stackscript-${stackscript.id}`} + onChange={onSelect} + /> </TableCell> <TableCell> - <Stack> - <Typography> - {stackscript.username} / {stackscript.label} - </Typography> - <Typography - sx={(theme) => ({ - color: theme.textColors.tableHeader, - fontSize: '.75rem', - })} - > - {truncate(stackscript.description, 100)} - </Typography> - </Stack> + <label htmlFor={`stackscript-${stackscript.id}`}> + <Stack sx={{ cursor: 'pointer' }}> + <Typography> + {stackscript.username} / {stackscript.label} + </Typography> + <Typography + sx={(theme) => ({ + color: theme.textColors.tableHeader, + fontSize: '.75rem', + })} + > + {truncate(stackscript.description, 100)} + </Typography> + </Stack> + </label> </TableCell> <TableCell actionCell sx={{ minWidth: 120 }}> <InlineMenuAction actionText="Show Details" onClick={onOpenDetails} /> From 94fa5f45d92a7ddb35133bde9e86a9ffcf7ce2ac Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Wed, 10 Apr 2024 15:47:59 -0400 Subject: [PATCH 11/22] add lots of unit testing --- .../Linodes/LinodeCreatev2/Access.test.tsx | 24 ++-- .../StackScriptDetailsDialog.test.tsx | 33 ++++++ ...ialog.tsx => StackScriptDetailsDialog.tsx} | 2 +- .../StackScripts/StackScriptImages.test.tsx | 73 ++++++++++++ .../{Images.tsx => StackScriptImages.tsx} | 8 +- .../StackScriptSelectionList.test.tsx | 2 +- .../StackScripts/StackScriptSelectionList.tsx | 2 +- .../StackScriptSelectionRow.test.tsx | 109 ++++++++++++++++++ .../Tabs/StackScripts/StackScripts.test.tsx | 24 ++++ .../Tabs/StackScripts/StackScripts.tsx | 4 +- .../Tabs/StackScripts/utilities.test.ts | 17 +++ .../Tabs/StackScripts/utilities.ts | 18 ++- .../Linodes/LinodeCreatev2/utilities.ts | 2 +- 13 files changed, 296 insertions(+), 22 deletions(-) create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.test.tsx rename packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/{StackScriptDialog.tsx => StackScriptDetailsDialog.tsx} (98%) create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.test.tsx rename packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/{Images.tsx => StackScriptImages.tsx} (86%) create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.test.tsx create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.test.tsx create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.test.ts diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Access.test.tsx index 93fe0b6fa6a..303005d5803 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Access.test.tsx @@ -10,16 +10,20 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { Access } from './Access'; describe('Access', () => { - it('should render a root password input', async () => { - const { findByLabelText } = renderWithThemeAndHookFormContext({ - component: <Access />, - }); - - const rootPasswordInput = await findByLabelText('Root Password'); - - expect(rootPasswordInput).toBeVisible(); - expect(rootPasswordInput).toBeEnabled(); - }); + it( + 'should render a root password input', + async () => { + const { findByLabelText } = renderWithThemeAndHookFormContext({ + component: <Access />, + }); + + const rootPasswordInput = await findByLabelText('Root Password'); + + expect(rootPasswordInput).toBeVisible(); + expect(rootPasswordInput).toBeEnabled(); + }, + { timeout: 5_000 } + ); it('should render a SSH Keys heading', async () => { const { getAllByText } = renderWithThemeAndHookFormContext({ diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.test.tsx new file mode 100644 index 00000000000..c1be7504701 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { stackScriptFactory } from 'src/factories'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { StackScriptDetailsDialog } from './StackScriptDetailsDialog'; + +describe('StackScriptDetailsDialog', () => { + it('should render StackScript data from the API', async () => { + const stackscript = stackScriptFactory.build(); + + server.use( + http.get('*/v4/linode/stackscripts/:id', () => { + return HttpResponse.json(stackscript); + }) + ); + + const { findByText } = renderWithTheme( + <StackScriptDetailsDialog + id={stackscript.id} + onClose={vi.fn()} + open={true} + /> + ); + + await findByText(stackscript.id); + await findByText(stackscript.label); + await findByText(stackscript.username); + await findByText(stackscript.description); + await findByText(stackscript.script); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDialog.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.tsx similarity index 98% rename from packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDialog.tsx rename to packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.tsx index 1b8469b31a0..046a5fe7040 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.tsx @@ -17,7 +17,7 @@ export const StackScriptDetailsDialog = (props: Props) => { const { data: stackscript, error, isLoading } = useStackScriptQuery( id ?? -1, - Boolean(id) + id !== undefined ); const title = stackscript diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.test.tsx new file mode 100644 index 00000000000..c25ab318107 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.test.tsx @@ -0,0 +1,73 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { imageFactory, stackScriptFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { StackScriptImages } from './StackScriptImages'; + +describe('Images', () => { + it('should render a heading', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: <StackScriptImages />, + }); + + expect(getByText('Select an Image')).toBeVisible(); + }); + + it('should render an Image Select', () => { + const { getByLabelText } = renderWithThemeAndHookFormContext({ + component: <StackScriptImages />, + }); + + expect(getByLabelText('Images')).toBeVisible(); + }); + + it('should only render images that are compatible with the selected StackScript', async () => { + const images = imageFactory.buildList(5); + + // For the sake of this test, we pretend this image is the only compatible image. + const compatibleImage = images[2]; + + const stackscript = stackScriptFactory.build({ + images: [compatibleImage.id], + }); + + server.use( + http.get('*/v4/images', () => { + return HttpResponse.json(makeResourcePage(images)); + }), + http.get('*/v4/linode/stackscripts/:id', () => { + return HttpResponse.json(stackscript); + }) + ); + + const { + findByText, + getByLabelText, + queryByText, + } = renderWithThemeAndHookFormContext({ + component: <StackScriptImages />, + useFormOptions: { + defaultValues: { stackscript_id: stackscript.id }, + }, + }); + + const imageSelect = getByLabelText('Images'); + + await userEvent.click(imageSelect); + + // Verify that the compabile image is show in the dropdown. + await findByText(compatibleImage.label); + + // Verify that the images returned by the API that are NOT compatible + // with this StackScript are *not* shown in the dropdown. + for (const image of images) { + if (image !== compatibleImage) { + expect(queryByText(image.label)).toBeNull(); + } + } + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.tsx similarity index 86% rename from packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/Images.tsx rename to packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.tsx index 739dc662d19..3fb9543e8c2 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.tsx @@ -8,14 +8,14 @@ import { useStackScriptQuery } from 'src/queries/stackscripts'; import type { CreateLinodeRequest } from '@linode/api-v4'; -export const Images = () => { - const stackscriptId = useWatch<CreateLinodeRequest>({ +export const StackScriptImages = () => { + const stackscriptId = useWatch<CreateLinodeRequest, 'stackscript_id'>({ name: 'stackscript_id', }); const { data: stackscript } = useStackScriptQuery( - stackscriptId, - Boolean(stackscriptId) + stackscriptId ?? -1, + stackscriptId !== null && stackscriptId !== undefined ); const shouldFilterImages = !stackscript?.images.includes('any/all'); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.test.tsx index c7f3c178571..0ea53a9acf0 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.test.tsx @@ -58,7 +58,7 @@ describe('StackScriptSelectionList', () => { expect(getByText('Choose Another StackScript')).toBeVisible(); }); - it('checks the selected StackScripts Radio if it is selected', async () => { + it('checks the selected StackScripts Radio if it is clicked', async () => { const stackscripts = stackScriptFactory.buildList(5); const selectedStackScript = stackscripts[2]; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx index 61e7397750b..2790e3c2230 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx @@ -20,7 +20,7 @@ import { } from 'src/queries/stackscripts'; import { useLinodeCreateQueryParams } from '../../utilities'; -import { StackScriptDetailsDialog } from './StackScriptDialog'; +import { StackScriptDetailsDialog } from './StackScriptDetailsDialog'; import { StackScriptSelectionRow } from './StackScriptSelectionRow'; import { accountStackScriptFilter, diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.test.tsx new file mode 100644 index 00000000000..e3180ef5566 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.test.tsx @@ -0,0 +1,109 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { stackScriptFactory } from 'src/factories'; +import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; + +import { StackScriptSelectionRow } from './StackScriptSelectionRow'; + +describe('StackScriptSelectionRow', () => { + it('render a stackscript label and username', () => { + const stackscript = stackScriptFactory.build(); + + const { getByText } = renderWithTheme( + wrapWithTableBody( + <StackScriptSelectionRow + isSelected={false} + onOpenDetails={vi.fn()} + onSelect={vi.fn()} + stackscript={stackscript} + /> + ) + ); + + expect(getByText(stackscript.username, { exact: false })).toBeVisible(); + expect(getByText(stackscript.label, { exact: false })).toBeVisible(); + }); + + it('render a checked Radio if isSelected is true', () => { + const stackscript = stackScriptFactory.build(); + + const { getByLabelText } = renderWithTheme( + wrapWithTableBody( + <StackScriptSelectionRow + isSelected={true} + onOpenDetails={vi.fn()} + onSelect={vi.fn()} + stackscript={stackscript} + /> + ) + ); + + const radio = getByLabelText(stackscript.label, { exact: false }); + + expect(radio).toBeChecked(); + }); + + it('render an unchecked Radio if isSelected is false', () => { + const stackscript = stackScriptFactory.build(); + + const { getByLabelText } = renderWithTheme( + wrapWithTableBody( + <StackScriptSelectionRow + isSelected={false} + onOpenDetails={vi.fn()} + onSelect={vi.fn()} + stackscript={stackscript} + /> + ) + ); + + const radio = getByLabelText(stackscript.label, { exact: false }); + + expect(radio).not.toBeChecked(); + }); + + it('should call onSelect when a stackscript is clicked', async () => { + const stackscript = stackScriptFactory.build(); + const onSelect = vi.fn(); + + const { getByLabelText } = renderWithTheme( + wrapWithTableBody( + <StackScriptSelectionRow + isSelected={false} + onOpenDetails={vi.fn()} + onSelect={onSelect} + stackscript={stackscript} + /> + ) + ); + + const radio = getByLabelText(stackscript.label, { exact: false }); + + await userEvent.click(radio); + + expect(onSelect).toHaveBeenCalled(); + }); + + it('should call onOpenDetails when a stackscript details button is clicked', async () => { + const stackscript = stackScriptFactory.build(); + const onOpenDetails = vi.fn(); + + const { getByText } = renderWithTheme( + wrapWithTableBody( + <StackScriptSelectionRow + isSelected={false} + onOpenDetails={onOpenDetails} + onSelect={vi.fn()} + stackscript={stackscript} + /> + ) + ); + + const detailsButton = getByText('Show Details'); + + await userEvent.click(detailsButton); + + expect(onOpenDetails).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.test.tsx new file mode 100644 index 00000000000..c81b02dc195 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { StackScripts } from './StackScripts'; + +describe('StackScripts', () => { + it('should render a StackScript section', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: <StackScripts />, + }); + + expect(getByText('Account StackScripts')).toBeVisible(); + expect(getByText('Community StackScripts')).toBeVisible(); + }); + + it('should render an Image section', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: <StackScripts />, + }); + + expect(getByText('Select an Image')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx index fc61517c8e0..8de7bc5a5c5 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx @@ -12,7 +12,7 @@ import { Tabs } from 'src/components/Tabs/Tabs'; import { Typography } from 'src/components/Typography'; import { useLinodeCreateQueryParams } from '../../utilities'; -import { Images } from './Images'; +import { StackScriptImages } from './StackScriptImages'; import { StackScriptSelectionList } from './StackScriptSelectionList'; import { getStackScriptTabIndex, tabs } from './utilities'; @@ -55,7 +55,7 @@ export const StackScripts = () => { </TabPanels> </Tabs> </Paper> - <Images /> + <StackScriptImages /> </Stack> ); }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.test.ts new file mode 100644 index 00000000000..6a3831ee9b2 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.test.ts @@ -0,0 +1,17 @@ +import { getStackScriptTabIndex } from './utilities'; + +describe('getStackScriptTabIndex', () => { + it('should return 0 for Account', () => { + expect(getStackScriptTabIndex('Account')).toBe(0); + }); + it('should return 1 for Community', () => { + expect(getStackScriptTabIndex('Community')).toBe(1); + }); + it('should return 0 for an unexpected value', () => { + // @ts-expect-error intentionally passing an unexpected value + expect(getStackScriptTabIndex('hey')).toBe(0); + }); + it('should return 0 for undefined (default to first tab)', () => { + expect(getStackScriptTabIndex(undefined)).toBe(0); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts index 4bbf74f5f42..41bb6d1c721 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts @@ -1,5 +1,13 @@ export type StackScriptTabType = 'Account' | 'Community'; +export const tabs = ['Account', 'Community'] as const; + +/** + * Returns the index of the currently selected StackScripts tab + * + * @param tab the current tab. Currently, this value comes from 'subtype' query param on the Linode Create flow. + * @returns the index of the selected tab + */ export const getStackScriptTabIndex = (tab: StackScriptTabType | undefined) => { if (tab === undefined) { return 0; @@ -14,8 +22,11 @@ export const getStackScriptTabIndex = (tab: StackScriptTabType | undefined) => { return tabIndex; }; -export const tabs = ['Account', 'Community'] as const; - +/** + * API filter for fetching community StackScripts + * + * We omit some usernames so that Marketplace StackScripts don't show up. + */ export const communityStackScriptFilter = { '+and': [ { username: { '+neq': 'linode' } }, @@ -24,4 +35,7 @@ export const communityStackScriptFilter = { mine: false, }; +/** + * API filter for fetching account StackScripts + */ export const accountStackScriptFilter = { mine: true }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 749ce90047b..f810b97032f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -147,7 +147,7 @@ export const defaultValues = async (): Promise<CreateLinodeRequest> => { : undefined; return { - image: 'linode/debian11', + image: stackScriptID ? undefined : 'linode/debian11', interfaces: [ { ipam_address: '', From e4e71f69b634dd5121b01de29ebe3b05202beccc Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Wed, 10 Apr 2024 16:12:50 -0400 Subject: [PATCH 12/22] add stackscript event handler --- packages/manager/src/hooks/useEventHandlers.ts | 5 +++++ packages/manager/src/queries/stackscripts.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/manager/src/hooks/useEventHandlers.ts b/packages/manager/src/hooks/useEventHandlers.ts index dc674252841..659bec203e9 100644 --- a/packages/manager/src/hooks/useEventHandlers.ts +++ b/packages/manager/src/hooks/useEventHandlers.ts @@ -15,6 +15,7 @@ import { volumeEventsHandler } from 'src/queries/volumes'; import type { Event } from '@linode/api-v4'; import type { QueryClient } from '@tanstack/react-query'; +import { stackScriptEventHandler } from 'src/queries/stackscripts'; export interface EventHandlerData { event: Event; @@ -76,6 +77,10 @@ export const eventHandlers: { filter: (event) => event.action.startsWith('disk'), handler: diskEventHandler, }, + { + filter: (event) => event.action.startsWith('stackscript'), + handler: stackScriptEventHandler, + }, ]; export const useEventHandlers = () => { diff --git a/packages/manager/src/queries/stackscripts.ts b/packages/manager/src/queries/stackscripts.ts index b2eb2159179..5799751d14b 100644 --- a/packages/manager/src/queries/stackscripts.ts +++ b/packages/manager/src/queries/stackscripts.ts @@ -13,6 +13,7 @@ import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { getOneClickApps } from 'src/features/StackScripts/stackScriptUtils'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; @@ -66,3 +67,18 @@ export const useStackScriptsInfiniteQuery = ( return page + 1; }, }); + +export const stackScriptEventHandler = ({ + event, + queryClient, +}: EventHandlerData) => { + // Keep the infinite store up to date + queryClient.invalidateQueries(stackscriptQueries.infinite._def); + + // If the event has a StackScript entity attached, invalidate it + if (event.entity?.id) { + queryClient.invalidateQueries( + stackscriptQueries.stackscript(event.entity.id).queryKey + ); + } +}; From f56e4ad16d281c5b8ee1a20c9cd1f1ec2992f18b Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Wed, 10 Apr 2024 17:45:56 -0400 Subject: [PATCH 13/22] hook up validation packages for realtime validation --- packages/manager/package.json | 1 + .../manager/src/features/Linodes/LinodeCreatev2/index.tsx | 8 +++++++- yarn.lock | 5 +++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/manager/package.json b/packages/manager/package.json index 95ba61b9112..8a7751e8327 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -16,6 +16,7 @@ "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@hookform/resolvers": "2.9.11", "@linode/api-v4": "*", "@linode/validation": "*", "@lukemorales/query-key-factory": "^1.3.4", diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index f248b30fa4a..3e39b009799 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -1,3 +1,5 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { CreateLinodeSchema } from '@linode/validation'; import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { useHistory } from 'react-router-dom'; @@ -37,7 +39,11 @@ import type { CreateLinodeRequest } from '@linode/api-v4'; import type { SubmitHandler } from 'react-hook-form'; export const LinodeCreatev2 = () => { - const methods = useForm<CreateLinodeRequest>({ defaultValues }); + const methods = useForm<CreateLinodeRequest>({ + defaultValues, + resolver: yupResolver(CreateLinodeSchema), + mode: 'onChange', + }); const history = useHistory(); const { mutateAsync: createLinode } = useCreateLinodeMutation(); diff --git a/yarn.lock b/yarn.lock index 878b7763065..30739364956 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1817,6 +1817,11 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== +"@hookform/resolvers@2.9.11": + version "2.9.11" + resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-2.9.11.tgz#9ce96e7746625a89239f68ca57c4f654264c17ef" + integrity sha512-bA3aZ79UgcHj7tFV7RlgThzwSSHZgvfbt2wprldRkYBcMopdMvHyO17Wwp/twcJasNFischFfS7oz8Katz8DdQ== + "@humanwhocodes/config-array@^0.11.13": version "0.11.14" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" From c464078ae8a6465cca1961897336c19594fcc504 Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Wed, 10 Apr 2024 17:54:56 -0400 Subject: [PATCH 14/22] use default validation behavior --- packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index 65e2f044fee..9886d896043 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -44,8 +44,8 @@ export const LinodeCreatev2 = () => { const methods = useForm<CreateLinodeRequest>({ defaultValues, resolver: yupResolver(CreateLinodeSchema), - mode: 'onChange', }); + const history = useHistory(); const { mutateAsync: createLinode } = useCreateLinodeMutation(); From de158af0f8cb764747628332dd4d22b5f22f12f3 Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Thu, 11 Apr 2024 00:37:43 -0400 Subject: [PATCH 15/22] Revert "hook up validation packages for realtime validation" This reverts commit f56e4ad16d281c5b8ee1a20c9cd1f1ec2992f18b. --- packages/manager/package.json | 1 - .../manager/src/features/Linodes/LinodeCreatev2/index.tsx | 8 +------- yarn.lock | 5 ----- 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/manager/package.json b/packages/manager/package.json index 8a7751e8327..95ba61b9112 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -16,7 +16,6 @@ "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", - "@hookform/resolvers": "2.9.11", "@linode/api-v4": "*", "@linode/validation": "*", "@lukemorales/query-key-factory": "^1.3.4", diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index 9886d896043..60f48af6a91 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -1,5 +1,3 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { CreateLinodeSchema } from '@linode/validation'; import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { useHistory } from 'react-router-dom'; @@ -41,11 +39,7 @@ import type { CreateLinodeRequest } from '@linode/api-v4'; import type { SubmitHandler } from 'react-hook-form'; export const LinodeCreatev2 = () => { - const methods = useForm<CreateLinodeRequest>({ - defaultValues, - resolver: yupResolver(CreateLinodeSchema), - }); - + const methods = useForm<CreateLinodeRequest>({ defaultValues }); const history = useHistory(); const { mutateAsync: createLinode } = useCreateLinodeMutation(); diff --git a/yarn.lock b/yarn.lock index 30739364956..878b7763065 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1817,11 +1817,6 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== -"@hookform/resolvers@2.9.11": - version "2.9.11" - resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-2.9.11.tgz#9ce96e7746625a89239f68ca57c4f654264c17ef" - integrity sha512-bA3aZ79UgcHj7tFV7RlgThzwSSHZgvfbt2wprldRkYBcMopdMvHyO17Wwp/twcJasNFischFfS7oz8Katz8DdQ== - "@humanwhocodes/config-array@^0.11.13": version "0.11.14" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" From e7f668b84dfc07bd97a6fe53a1df4066e5a981a3 Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Thu, 11 Apr 2024 16:41:27 -0400 Subject: [PATCH 16/22] handle resets when switching tabs --- .../Tabs/StackScripts/StackScriptImages.tsx | 24 +++-- .../Tabs/StackScripts/StackScripts.tsx | 9 +- .../features/Linodes/LinodeCreatev2/index.tsx | 9 +- .../Linodes/LinodeCreatev2/utilities.ts | 98 +++++++++++++++---- 4 files changed, 112 insertions(+), 28 deletions(-) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.tsx index 3fb9543e8c2..d82bb2fbb42 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.tsx @@ -6,34 +6,44 @@ import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; import { useStackScriptQuery } from 'src/queries/stackscripts'; -import type { CreateLinodeRequest } from '@linode/api-v4'; +import type { CreateLinodeRequest, Image } from '@linode/api-v4'; export const StackScriptImages = () => { const stackscriptId = useWatch<CreateLinodeRequest, 'stackscript_id'>({ name: 'stackscript_id', }); + const hasStackScriptSelected = + stackscriptId !== null && stackscriptId !== undefined; + const { data: stackscript } = useStackScriptQuery( stackscriptId ?? -1, - stackscriptId !== null && stackscriptId !== undefined + hasStackScriptSelected ); const shouldFilterImages = !stackscript?.images.includes('any/all'); const imageSelectVariant = shouldFilterImages ? 'public' : 'all'; + const imageFilter = shouldFilterImages + ? (image: Image) => stackscript?.images.includes(image.id) ?? false + : undefined; + + const helperText = !hasStackScriptSelected + ? 'Select a StackScript to see compatible Images.' + : undefined; + return ( <Paper> <Typography variant="h2">Select an Image</Typography> <Controller<CreateLinodeRequest, 'image'> render={({ field, fieldState }) => ( <ImageSelectv2 - filter={ - shouldFilterImages - ? (image) => stackscript?.images.includes(image.id) ?? false - : undefined - } + disabled={!hasStackScriptSelected} errorText={fieldState.error?.message} + filter={imageFilter} + helperText={helperText} + noOptionsText="No Compatible Images Available" onChange={(e, image) => field.onChange(image?.id ?? null)} value={field.value} variant={imageSelectVariant} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx index 8de7bc5a5c5..3f4e4f7ed3d 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx @@ -20,7 +20,7 @@ import type { CreateLinodeRequest } from '@linode/api-v4'; export const StackScripts = () => { const { params, updateParams } = useLinodeCreateQueryParams(); - const { formState, setValue } = useFormContext<CreateLinodeRequest>(); + const { formState, reset } = useFormContext<CreateLinodeRequest>(); return ( <Stack spacing={3}> @@ -37,7 +37,12 @@ export const StackScripts = () => { <Tabs onChange={(index) => { updateParams({ stackScriptID: undefined, subtype: tabs[index] }); - setValue('stackscript_id', null); + reset((prev) => ({ + ...prev, + image: null, + stackscript_data: null, + stackscript_id: null, + })); }} index={getStackScriptTabIndex(params.subtype)} > diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index 60f48af6a91..4fdf4e22dfc 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -27,6 +27,7 @@ import { StackScripts } from './Tabs/StackScripts/StackScripts'; import { UserData } from './UserData/UserData'; import { defaultValues, + defaultValuesMap, getLinodeCreatePayload, getTabIndex, tabs, @@ -62,7 +63,7 @@ export const LinodeCreatev2 = () => { } }; - const { params, updateParams } = useLinodeCreateQueryParams(); + const { params, setParams } = useLinodeCreateQueryParams(); const currentTabIndex = getTabIndex(params.type); @@ -78,8 +79,12 @@ export const LinodeCreatev2 = () => { <Error /> <Stack gap={3}> <Tabs + onChange={(index) => { + const newTab = tabs[index]; + setParams({ type: newTab }); + methods.reset(defaultValuesMap[newTab]); + }} index={currentTabIndex} - onChange={(index) => updateParams({ type: tabs[index] })} > <TabList> <Tab>Distributions</Tab> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 6c6d35d59dd..dc8adfd788a 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -12,6 +12,7 @@ import type { CreateLinodeRequest, InterfacePayload } from '@linode/api-v4'; * This interface is used to type the query params on the Linode Create flow. */ interface LinodeCreateQueryParams { + imageID: string | undefined; stackScriptID: string | undefined; subtype: StackScriptTabType | undefined; type: LinodeCreateType | undefined; @@ -27,6 +28,9 @@ export const useLinodeCreateQueryParams = () => { const rawParams = getQueryParamsFromQueryString(history.location.search); + /** + * Updates query params + */ const updateParams = (params: Partial<LinodeCreateQueryParams>) => { const newParams = new URLSearchParams(rawParams); @@ -41,7 +45,17 @@ export const useLinodeCreateQueryParams = () => { history.push({ search: newParams.toString() }); }; + /** + * Replaces query params with the provided values + */ + const setParams = (params: Partial<LinodeCreateQueryParams>) => { + const newParams = new URLSearchParams(params); + + history.push({ search: newParams.toString() }); + }; + const params = { + imageID: rawParams.imageID as string | undefined, stackScriptID: rawParams.stackScriptID ? Number(rawParams.stackScriptID) : undefined, @@ -49,7 +63,7 @@ export const useLinodeCreateQueryParams = () => { type: rawParams.type as LinodeCreateType | undefined, }; - return { params, updateParams }; + return { params, setParams, updateParams }; }; /** @@ -155,6 +169,24 @@ export const getInterfacesPayload = ( return undefined; }; +const defaultVPCInterface = { + ipam_address: '', + label: '', + purpose: 'vpc', +} as const; + +const defaultVLANInterface = { + ipam_address: '', + label: '', + purpose: 'vlan', +} as const; + +const defaultPublicInterface = { + ipam_address: '', + label: '', + purpose: 'public', +} as const; + export const defaultValues = async (): Promise<CreateLinodeRequest> => { const queryParams = getQueryParamsFromQueryString(window.location.search); @@ -162,27 +194,59 @@ export const defaultValues = async (): Promise<CreateLinodeRequest> => { ? Number(queryParams.stackScriptID) : undefined; + const imageID = queryParams.imageID; + return { - image: stackScriptID ? undefined : 'linode/debian11', + image: stackScriptID ? undefined : imageID ?? 'linode/debian11', interfaces: [ - { - ipam_address: '', - label: '', - purpose: 'vpc', - }, - { - ipam_address: '', - label: '', - purpose: 'vlan', - }, - { - ipam_address: '', - label: '', - purpose: 'public', - }, + defaultVPCInterface, + defaultVLANInterface, + defaultPublicInterface, ], region: '', stackscript_id: stackScriptID, type: '', }; }; + +const defaultValuesForImages = { + interfaces: [ + defaultVPCInterface, + defaultVLANInterface, + defaultPublicInterface, + ], + region: '', + type: '', +}; + +const defaultValuesForDistributions = { + image: 'linode/debian11', + interfaces: [ + defaultVPCInterface, + defaultVLANInterface, + defaultPublicInterface, + ], + region: '', + type: '', +}; + +const defaultValuesForStackScripts = { + image: undefined, + interfaces: [ + defaultVPCInterface, + defaultVLANInterface, + defaultPublicInterface, + ], + region: '', + stackscript_id: null, + type: '', +}; + +export const defaultValuesMap: Record<LinodeCreateType, CreateLinodeRequest> = { + Backups: defaultValuesForImages, + 'Clone Linode': defaultValuesForImages, + Distributions: defaultValuesForDistributions, + Images: defaultValuesForImages, + 'One-Click': defaultValuesForImages, + StackScripts: defaultValuesForStackScripts, +}; From 5ed5f57f85425870a60c341891a7ab94ded36266 Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Thu, 11 Apr 2024 16:48:28 -0400 Subject: [PATCH 17/22] add changesets --- packages/api-v4/.changeset/pr-10367-changed-1712868469030.md | 5 +++++ .../.changeset/pr-10367-upcoming-features-1712868443784.md | 5 +++++ .../validation/.changeset/pr-10367-changed-1712868495330.md | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 packages/api-v4/.changeset/pr-10367-changed-1712868469030.md create mode 100644 packages/manager/.changeset/pr-10367-upcoming-features-1712868443784.md create mode 100644 packages/validation/.changeset/pr-10367-changed-1712868495330.md diff --git a/packages/api-v4/.changeset/pr-10367-changed-1712868469030.md b/packages/api-v4/.changeset/pr-10367-changed-1712868469030.md new file mode 100644 index 00000000000..696ea3d2fbc --- /dev/null +++ b/packages/api-v4/.changeset/pr-10367-changed-1712868469030.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Allow `stackscript_id` to be `null` in `CreateLinodeRequest` ([#10367](https://github.com/linode/manager/pull/10367)) diff --git a/packages/manager/.changeset/pr-10367-upcoming-features-1712868443784.md b/packages/manager/.changeset/pr-10367-upcoming-features-1712868443784.md new file mode 100644 index 00000000000..38e31bf052a --- /dev/null +++ b/packages/manager/.changeset/pr-10367-upcoming-features-1712868443784.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Linode Create Refactor - StackScripts ([#10367](https://github.com/linode/manager/pull/10367)) diff --git a/packages/validation/.changeset/pr-10367-changed-1712868495330.md b/packages/validation/.changeset/pr-10367-changed-1712868495330.md new file mode 100644 index 00000000000..c5e47f8f393 --- /dev/null +++ b/packages/validation/.changeset/pr-10367-changed-1712868495330.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Allow `stackscript_id` to be `null` in `CreateLinodeSchema` ([#10367](https://github.com/linode/manager/pull/10367)) From 658e7ec681f4011f080e2924801e1e4c5f50a326 Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Fri, 12 Apr 2024 09:09:37 -0400 Subject: [PATCH 18/22] add some comments --- .../StackScripts/StackScriptDetailsDialog.tsx | 9 ++++++++ .../Tabs/StackScripts/StackScripts.tsx | 22 +++++++++++-------- .../features/Linodes/LinodeCreatev2/index.tsx | 17 +++++++------- .../Linodes/LinodeCreatev2/utilities.ts | 11 +++++++++- 4 files changed, 41 insertions(+), 18 deletions(-) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.tsx index 046a5fe7040..7e96c887204 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.tsx @@ -7,8 +7,17 @@ import { StackScript } from 'src/components/StackScript/StackScript'; import { useStackScriptQuery } from 'src/queries/stackscripts'; interface Props { + /** + * The id of the StackScript + */ id: number | undefined; + /** + * Function called when when the dialog is closed + */ onClose: () => void; + /** + * Controls the open/close state of the dialog + */ open: boolean; } diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx index 3f4e4f7ed3d..d52ffc6fe69 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx @@ -22,6 +22,18 @@ export const StackScripts = () => { const { params, updateParams } = useLinodeCreateQueryParams(); const { formState, reset } = useFormContext<CreateLinodeRequest>(); + const onTabChange = (index: number) => { + // Update the "subtype" query param. (This switches between "Community" and "Account" tabs). + updateParams({ stackScriptID: undefined, subtype: tabs[index] }); + // Reset the selected image, the selected StackScript, and the StackScript data when changing tabs. + reset((prev) => ({ + ...prev, + image: null, + stackscript_data: null, + stackscript_id: null, + })); + }; + return ( <Stack spacing={3}> <Paper> @@ -35,16 +47,8 @@ export const StackScripts = () => { /> )} <Tabs - onChange={(index) => { - updateParams({ stackScriptID: undefined, subtype: tabs[index] }); - reset((prev) => ({ - ...prev, - image: null, - stackscript_data: null, - stackscript_id: null, - })); - }} index={getStackScriptTabIndex(params.subtype)} + onChange={onTabChange} > <TabList> <Tab>Account StackScripts</Tab> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index 4fdf4e22dfc..8e9532947da 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -67,6 +67,14 @@ export const LinodeCreatev2 = () => { const currentTabIndex = getTabIndex(params.type); + const onTabChange = (index: number) => { + const newTab = tabs[index]; + // Update tab "type" query param. (This changes the selected tab) + setParams({ type: newTab }); + // Reset the form values + methods.reset(defaultValuesMap[newTab]); + }; + return ( <FormProvider {...methods}> <DocumentTitleSegment segment="Create a Linode" /> @@ -78,14 +86,7 @@ export const LinodeCreatev2 = () => { <form onSubmit={methods.handleSubmit(onSubmit)}> <Error /> <Stack gap={3}> - <Tabs - onChange={(index) => { - const newTab = tabs[index]; - setParams({ type: newTab }); - methods.reset(defaultValuesMap[newTab]); - }} - index={currentTabIndex} - > + <Tabs index={currentTabIndex} onChange={onTabChange}> <TabList> <Tab>Distributions</Tab> <Tab>Marketplace</Tab> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index dc8adfd788a..2e6798ba9c2 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -187,6 +187,12 @@ const defaultPublicInterface = { purpose: 'public', } as const; +/** + * This function initializes the Linode Create flow form + * when the form mounts. + * + * The default values are dependent on the query params present. + */ export const defaultValues = async (): Promise<CreateLinodeRequest> => { const queryParams = getQueryParamsFromQueryString(window.location.search); @@ -197,7 +203,7 @@ export const defaultValues = async (): Promise<CreateLinodeRequest> => { const imageID = queryParams.imageID; return { - image: stackScriptID ? undefined : imageID ?? 'linode/debian11', + image: stackScriptID ? imageID : imageID ?? 'linode/debian11', interfaces: [ defaultVPCInterface, defaultVLANInterface, @@ -242,6 +248,9 @@ const defaultValuesForStackScripts = { type: '', }; +/** + * A map that conatins default values for each Tab of the Linode Create flow. + */ export const defaultValuesMap: Record<LinodeCreateType, CreateLinodeRequest> = { Backups: defaultValuesForImages, 'Clone Linode': defaultValuesForImages, From fe974cb4431d00aee3eb3f6d887a3691cd8c0db4 Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Fri, 12 Apr 2024 10:30:42 -0400 Subject: [PATCH 19/22] bold label and pre-select if only one option --- .../ImageSelectv2/ImageSelectv2.tsx | 24 +++++++++++++++++-- .../LinodeCreatev2/Tabs/Distributions.tsx | 2 +- .../Linodes/LinodeCreatev2/Tabs/Images.tsx | 2 +- .../Tabs/StackScripts/StackScriptImages.tsx | 3 ++- .../StackScripts/StackScriptSelectionRow.tsx | 8 ++++++- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx index ba6726267b0..c4b0942f6bc 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx @@ -15,11 +15,22 @@ import type { EnhancedAutocompleteProps } from 'src/components/Autocomplete/Auto export type ImageSelectVariant = 'all' | 'private' | 'public'; interface Props - extends Omit<Partial<EnhancedAutocompleteProps<Image>>, 'value'> { + extends Omit< + Partial<EnhancedAutocompleteProps<Image>>, + 'onChange' | 'value' + > { /** * Optional filter function applied to the options. */ filter?: (image: Image) => boolean; + /** + * Called when the value is changed + */ + onChange: (image: Image | null) => void; + /** + * If there is only one avaiblable option, selected it by default. + */ + selectIfOnlyOneOption?: boolean; /** * The ID of the selected image */ @@ -34,7 +45,7 @@ interface Props } export const ImageSelectv2 = (props: Props) => { - const { filter, variant, ...rest } = props; + const { filter, onChange, selectIfOnlyOneOption, variant, ...rest } = props; const { data: images, error, isLoading } = useAllImagesQuery( {}, @@ -48,6 +59,14 @@ export const ImageSelectv2 = (props: Props) => { const value = images?.find((i) => i.id === props.value); + if ( + filteredOptions?.length === 1 && + props.onChange && + selectIfOnlyOneOption + ) { + props.onChange(filteredOptions[0]); + } + return ( <Autocomplete renderOption={(props, option, state) => ( @@ -66,6 +85,7 @@ export const ImageSelectv2 = (props: Props) => { placeholder="Choose an image" {...rest} errorText={rest.errorText ?? error?.[0].reason} + onChange={(e, image) => onChange(image)} value={value ?? null} /> ); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx index 67fb49eef7f..d0121d60ca6 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx @@ -23,7 +23,7 @@ export const Distributions = () => { <ImageSelectv2 disabled={isCreateLinodeRestricted} errorText={fieldState.error?.message} - onChange={(_, image) => field.onChange(image?.id ?? null)} + onChange={(image) => field.onChange(image?.id ?? null)} value={field.value} variant="public" /> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx index 84ccb844c03..272a79a94f1 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx @@ -23,7 +23,7 @@ export const Images = () => { <ImageSelectv2 disabled={isCreateLinodeRestricted} errorText={fieldState.error?.message} - onChange={(_, image) => field.onChange(image?.id ?? null)} + onChange={(image) => field.onChange(image?.id ?? null)} value={field.value} variant="private" /> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.tsx index d82bb2fbb42..dfbac510d91 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.tsx @@ -44,7 +44,8 @@ export const StackScriptImages = () => { filter={imageFilter} helperText={helperText} noOptionsText="No Compatible Images Available" - onChange={(e, image) => field.onChange(image?.id ?? null)} + onChange={(image) => field.onChange(image?.id ?? null)} + selectIfOnlyOneOption value={field.value} variant={imageSelectVariant} /> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx index a15d8b0fa05..ed33f0361a4 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx @@ -40,7 +40,13 @@ export const StackScriptSelectionRow = (props: Props) => { <label htmlFor={`stackscript-${stackscript.id}`}> <Stack sx={{ cursor: 'pointer' }}> <Typography> - {stackscript.username} / {stackscript.label} + {stackscript.username} /{' '} + <Typography + component="span" + fontFamily={(theme) => theme.font.bold} + > + {stackscript.label} + </Typography> </Typography> <Typography sx={(theme) => ({ From d53719aef324c3736df0a4a90c9056fb69decb57 Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Fri, 12 Apr 2024 10:40:19 -0400 Subject: [PATCH 20/22] improve ux --- .../manager/src/components/ImageSelectv2/ImageSelectv2.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx index c4b0942f6bc..49cc3774bff 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx @@ -84,6 +84,10 @@ export const ImageSelectv2 = (props: Props) => { options={filteredOptions ?? []} placeholder="Choose an image" {...rest} + disableClearable={ + rest.disableClearable ?? + (selectIfOnlyOneOption && filteredOptions?.length === 1) + } errorText={rest.errorText ?? error?.[0].reason} onChange={(e, image) => onChange(image)} value={value ?? null} From 884b4b4c8d494a171c7dc603913fe2b007547e1e Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Fri, 12 Apr 2024 10:59:57 -0400 Subject: [PATCH 21/22] fix unit tests --- .../src/components/ImageSelectv2/ImageSelectv2.test.tsx | 7 +------ .../src/components/ImageSelectv2/ImageSelectv2.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.test.tsx b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.test.tsx index a18f808dabb..5e3862d1684 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.test.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.test.tsx @@ -65,12 +65,7 @@ describe('ImageSelectv2', () => { await userEvent.click(imageOption); - expect(onChange).toHaveBeenCalledWith( - expect.anything(), - image, - 'selectOption', - expect.anything() - ); + expect(onChange).toHaveBeenCalledWith(image); }); it('should correctly initialize with a default value', async () => { diff --git a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx index 49cc3774bff..363ad62d11e 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx @@ -26,7 +26,7 @@ interface Props /** * Called when the value is changed */ - onChange: (image: Image | null) => void; + onChange?: (image: Image | null) => void; /** * If there is only one avaiblable option, selected it by default. */ @@ -88,8 +88,12 @@ export const ImageSelectv2 = (props: Props) => { rest.disableClearable ?? (selectIfOnlyOneOption && filteredOptions?.length === 1) } + onChange={(e, image) => { + if (onChange) { + onChange(image); + } + }} errorText={rest.errorText ?? error?.[0].reason} - onChange={(e, image) => onChange(image)} value={value ?? null} /> ); From 09188d4645358c82ba9b5f19cd1aea7572445229 Mon Sep 17 00:00:00 2001 From: Banks Nussman <banks@nussman.us> Date: Mon, 15 Apr 2024 11:02:58 -0400 Subject: [PATCH 22/22] add comment --- .../Tabs/StackScripts/StackScriptSelectionRow.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx index ed33f0361a4..52a1c8c32d2 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx @@ -22,6 +22,8 @@ interface Props { export const StackScriptSelectionRow = (props: Props) => { const { disabled, isSelected, onOpenDetails, onSelect, stackscript } = props; + // Never show LKE StackScripts. We try to hide these from the user, even though they + // are returned by the API. if (stackscript.username.startsWith('lke-service-account-')) { return null; }