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/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 9eecaaa113c..55a76fd3359 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/.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/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 c40255eda3e..363ad62d11e 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageSelectv2.tsx @@ -15,7 +15,22 @@ import type { EnhancedAutocompleteProps } from 'src/components/Autocomplete/Auto export type ImageSelectVariant = 'all' | 'private' | 'public'; interface Props - extends Omit>, 'value'> { + extends Omit< + Partial>, + '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 */ @@ -30,7 +45,7 @@ interface Props } export const ImageSelectv2 = (props: Props) => { - const { variant, ...rest } = props; + const { filter, onChange, selectIfOnlyOneOption, variant, ...rest } = props; const { data: images, error, isLoading } = useAllImagesQuery( {}, @@ -40,8 +55,18 @@ 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); + if ( + filteredOptions?.length === 1 && + props.onChange && + selectIfOnlyOneOption + ) { + props.onChange(filteredOptions[0]); + } + return ( ( @@ -52,12 +77,22 @@ export const ImageSelectv2 = (props: Props) => { listItemProps={props} /> )} + clearOnBlur groupBy={(option) => option.vendor ?? 'My Images'} label="Images" loading={isLoading} - options={options ?? []} + options={filteredOptions ?? []} placeholder="Choose an image" {...rest} + disableClearable={ + rest.disableClearable ?? + (selectIfOnlyOneOption && filteredOptions?.length === 1) + } + onChange={(e, image) => { + if (onChange) { + onChange(image); + } + }} errorText={rest.errorText ?? error?.[0].reason} value={value ?? null} /> 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/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index e3c16f4761f..c55ac7ecf0b 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -36,7 +36,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'; @@ -116,7 +116,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 = ( // 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/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: , - }); - - const rootPasswordInput = await findByLabelText('Root Password'); - - expect(rootPasswordInput).toBeVisible(); - expect(rootPasswordInput).toBeEnabled(); - }); + it( + 'should render a root password input', + async () => { + const { findByLabelText } = renderWithThemeAndHookFormContext({ + component: , + }); + + 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/Distributions.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx index 8306b1adf5d..d76c3cdbbef 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx @@ -24,7 +24,7 @@ export const Distributions = () => { disabled={isCreateLinodeRestricted} errorText={fieldState.error?.message} onBlur={field.onBlur} - 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 3e766b86341..484b2e6f27d 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx @@ -24,7 +24,7 @@ export const Images = () => { disabled={isCreateLinodeRestricted} errorText={fieldState.error?.message} onBlur={field.onBlur} - 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/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( + + ); + + 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/StackScriptDetailsDialog.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.tsx new file mode 100644 index 00000000000..7e96c887204 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptDetailsDialog.tsx @@ -0,0 +1,50 @@ +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 { + /** + * 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; +} + +export const StackScriptDetailsDialog = (props: Props) => { + const { id, onClose, open } = props; + + const { data: stackscript, error, isLoading } = useStackScriptQuery( + id ?? -1, + id !== undefined + ); + + const title = stackscript + ? `${stackscript.username} / ${stackscript.label}` + : 'StackScript'; + + return ( + + {isLoading && } + {error && } + {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: , + }); + + expect(getByText('Select an Image')).toBeVisible(); + }); + + it('should render an Image Select', () => { + const { getByLabelText } = renderWithThemeAndHookFormContext({ + component: , + }); + + 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: , + 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/StackScriptImages.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.tsx new file mode 100644 index 00000000000..dfbac510d91 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptImages.tsx @@ -0,0 +1,57 @@ +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, Image } from '@linode/api-v4'; + +export const StackScriptImages = () => { + const stackscriptId = useWatch({ + name: 'stackscript_id', + }); + + const hasStackScriptSelected = + stackscriptId !== null && stackscriptId !== undefined; + + const { data: stackscript } = useStackScriptQuery( + stackscriptId ?? -1, + 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 ( + + Select an Image + + render={({ field, fieldState }) => ( + field.onChange(image?.id ?? null)} + selectIfOnlyOneOption + value={field.value} + variant={imageSelectVariant} + /> + )} + name="image" + /> + + ); +}; 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..0ea53a9acf0 --- /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: , + }); + + 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: , + 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 clicked', 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: , + 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/StackScriptSelectionList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx new file mode 100644 index 00000000000..2790e3c2230 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionList.tsx @@ -0,0 +1,166 @@ +import React, { useState } from 'react'; +import { useController, useFormContext } 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'; +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 { + useStackScriptQuery, + useStackScriptsInfiniteQuery, +} from 'src/queries/stackscripts'; + +import { useLinodeCreateQueryParams } from '../../utilities'; +import { StackScriptDetailsDialog } from './StackScriptDetailsDialog'; +import { StackScriptSelectionRow } from './StackScriptSelectionRow'; +import { + accountStackScriptFilter, + communityStackScriptFilter, +} from './utilities'; + +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 { control, setValue } = useFormContext(); + + const { field } = useController({ + control, + name: 'stackscript_id', + }); + + const [selectedStackScriptId, setSelectedStackScriptId] = useState(); + + const { params, updateParams } = useLinodeCreateQueryParams(); + + const hasPreselectedStackScript = Boolean(params.stackScriptID); + + const { data: stackscript } = useStackScriptQuery( + params.stackScriptID ?? -1, + hasPreselectedStackScript + ); + + const filter = + type === 'Community' + ? communityStackScriptFilter + : accountStackScriptFilter; + + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useStackScriptsInfiniteQuery( + { + ['+order']: order, + ['+order_by']: orderBy, + ...filter, + }, + !hasPreselectedStackScript + ); + + const stackscripts = data?.pages.flatMap((page) => page.data); + + if (hasPreselectedStackScript) { + return ( + + + + + + StackScript + + + + + {stackscript && ( + setSelectedStackScriptId(stackscript.id)} + onSelect={() => field.onChange(stackscript.id)} + stackscript={stackscript} + /> + )} + +
+ + + +
+ ); + } + + return ( + + + + + + + StackScript + + + + + + {stackscripts?.map((stackscript) => ( + { + setValue('image', null); + field.onChange(stackscript.id); + }} + isSelected={field.value === stackscript.id} + key={stackscript.id} + onOpenDetails={() => setSelectedStackScriptId(stackscript.id)} + stackscript={stackscript} + /> + ))} + {error && } + {isLoading && } + {(isFetchingNextPage || hasNextPage) && ( + + )} + +
+ {hasNextPage && fetchNextPage()} />} + setSelectedStackScriptId(undefined)} + open={selectedStackScriptId !== undefined} + /> +
+ ); +}; 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( + + ) + ); + + 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( + + ) + ); + + 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( + + ) + ); + + 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( + + ) + ); + + 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( + + ) + ); + + const detailsButton = getByText('Show Details'); + + await userEvent.click(detailsButton); + + expect(onOpenDetails).toHaveBeenCalled(); + }); +}); 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..52a1c8c32d2 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelectionRow.tsx @@ -0,0 +1,69 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +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, 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; + } + + return ( + + + + + + + + + + + + ); +}; 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: , + }); + + expect(getByText('Account StackScripts')).toBeVisible(); + expect(getByText('Community StackScripts')).toBeVisible(); + }); + + it('should render an Image section', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + 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 new file mode 100644 index 00000000000..d52ffc6fe69 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScripts.tsx @@ -0,0 +1,70 @@ +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'; +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 { StackScriptImages } from './StackScriptImages'; +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, reset } = useFormContext(); + + 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 ( + + + Create From: + {formState.errors.stackscript_id && ( + + )} + + + Account StackScripts + Community StackScripts + + + + + + + + + + + + + + ); +}; 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 new file mode 100644 index 00000000000..41bb6d1c721 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/utilities.ts @@ -0,0 +1,41 @@ +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; + } + + const tabIndex = tabs.indexOf(tab); + + if (tabIndex === -1) { + return 0; + } + + return tabIndex; +}; + +/** + * 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' } }, + { username: { '+neq': 'linode-stackscripts' } }, + ], + mine: false, +}; + +/** + * API filter for fetching account StackScripts + */ +export const accountStackScriptFilter = { mine: true }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index 5e6fe165541..a0b609a9237 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -23,9 +23,11 @@ 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, + defaultValuesMap, getLinodeCreatePayload, getTabIndex, resolver, @@ -67,10 +69,18 @@ export const LinodeCreatev2 = () => { } }; - const { params, updateParams } = useLinodeCreateQueryParams(); + const { params, setParams } = useLinodeCreateQueryParams(); 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 ( @@ -82,10 +92,7 @@ export const LinodeCreatev2 = () => {
- updateParams({ type: tabs[index] })} - > + Distributions Marketplace @@ -99,7 +106,9 @@ export const LinodeCreatev2 = () => { Marketplace - StackScripts + + + diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 34cde1fe1e5..5f8432b1087 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -7,6 +7,7 @@ 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'; import type { Resolver } from 'react-hook-form'; @@ -14,6 +15,9 @@ import type { Resolver } from 'react-hook-form'; * 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,16 +31,42 @@ export const useLinodeCreateQueryParams = () => { const rawParams = getQueryParamsFromQueryString(history.location.search); + /** + * Updates query params + */ const updateParams = (params: Partial) => { - 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() }); + }; + + /** + * Replaces query params with the provided values + */ + const setParams = (params: Partial) => { + 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, + subtype: rawParams.subtype as StackScriptTabType | undefined, type: rawParams.type as LinodeCreateType | undefined, - } as LinodeCreateQueryParams; + }; - return { params, updateParams }; + return { params, setParams, updateParams }; }; /** @@ -143,29 +173,97 @@ export const getInterfacesPayload = ( return undefined; }; -export const defaultValues: CreateLinodeRequest = { +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; + +/** + * 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 => { + const queryParams = getQueryParamsFromQueryString(window.location.search); + + const stackScriptID = queryParams.stackScriptID + ? Number(queryParams.stackScriptID) + : undefined; + + const imageID = queryParams.imageID; + + return { + image: stackScriptID ? imageID : imageID ?? 'linode/debian11', + interfaces: [ + defaultVPCInterface, + defaultVLANInterface, + defaultPublicInterface, + ], + region: '', + stackscript_id: stackScriptID, + type: '', + }; +}; + +const defaultValuesForImages = { + interfaces: [ + defaultVPCInterface, + defaultVLANInterface, + defaultPublicInterface, + ], + region: '', + type: '', +}; + +const defaultValuesForDistributions = { image: 'linode/debian11', interfaces: [ - { - ipam_address: '', - label: '', - purpose: 'vpc', - }, - { - ipam_address: '', - label: '', - purpose: 'vlan', - }, - { - ipam_address: '', - label: '', - purpose: 'public', - }, + defaultVPCInterface, + defaultVLANInterface, + defaultPublicInterface, + ], + region: '', + type: '', +}; + +const defaultValuesForStackScripts = { + image: undefined, + interfaces: [ + defaultVPCInterface, + defaultVLANInterface, + defaultPublicInterface, ], region: '', + stackscript_id: null, type: '', }; +/** + * A map that conatins default values for each Tab of the Linode Create flow. + */ +export const defaultValuesMap: Record = { + Backups: defaultValuesForImages, + 'Clone Linode': defaultValuesForImages, + Distributions: defaultValuesForDistributions, + Images: defaultValuesForImages, + 'One-Click': defaultValuesForImages, + StackScripts: defaultValuesForStackScripts, +}; + /** * Provides dynamic validation to the Linode Create form. * 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 607edb6c24a..5799751d14b 100644 --- a/packages/manager/src/queries/stackscripts.ts +++ b/packages/manager/src/queries/stackscripts.ts @@ -1,26 +1,84 @@ -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 { EventHandlerData } from 'src/hooks/useEventHandlers'; import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; -export const queryKey = 'stackscripts'; - -export const useStackScriptsOCA = (enabled: boolean, params: Params = {}) => { - return useQuery( - [`${queryKey}-oca-all`, params], - () => getAllOCAsRequest(params), - { - enabled, - ...queryPresets.oneTimeFetch, - } - ); -}; - export const getAllOCAsRequest = (passedParams: Params = {}) => getAll((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({ + ...stackscriptQueries.marketplace, + enabled, + ...queryPresets.oneTimeFetch, + }); +}; + +export const useStackScriptQuery = (id: number, enabled = true) => + useQuery({ + ...stackscriptQueries.stackscript(id), + enabled, + }); + +export const useStackScriptsInfiniteQuery = ( + filter: Filter = {}, + enabled = true +) => + useInfiniteQuery, APIError[]>({ + ...stackscriptQueries.infinite(filter), + enabled, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + 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 + ); + } +}; 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)) diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index b9a71269c4c..084155e5212 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -277,12 +277,12 @@ 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', { 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(),