diff --git a/packages/manager/.changeset/pr-10484-added-1715972836354.md b/packages/manager/.changeset/pr-10484-added-1715972836354.md new file mode 100644 index 00000000000..3786f7d08c2 --- /dev/null +++ b/packages/manager/.changeset/pr-10484-added-1715972836354.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Tags to image upload tab ([#10484](https://github.com/linode/manager/pull/10484)) diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index 687a54abb80..f5d5e051b46 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -131,7 +131,13 @@ const uploadImage = (label: string) => { mimeType: 'application/x-gzip', }); }); + cy.intercept('POST', apiMatcher('images/upload')).as('imageUpload'); + + ui.button.findByAttribute('type', 'submit') + .should('be.enabled') + .should('be.visible') + .click(); }; authenticate(); diff --git a/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx b/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx index 20a294d8303..cb86b4b93b0 100644 --- a/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx +++ b/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx @@ -1,16 +1,23 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { + CopyTooltip, + CopyTooltipProps, +} from 'src/components/CopyTooltip/CopyTooltip'; import { TextField, TextFieldProps } from 'src/components/TextField'; interface CopyableTextFieldProps extends TextFieldProps { + /** + * Optional props that are passed to the underlying CopyTooltip component + */ + CopyTooltipProps?: Partial; className?: string; hideIcon?: boolean; } export const CopyableTextField = (props: CopyableTextFieldProps) => { - const { className, hideIcon, value, ...restProps } = props; + const { CopyTooltipProps, className, hideIcon, value, ...restProps } = props; return ( { {...restProps} InputProps={{ endAdornment: hideIcon ? undefined : ( - + ), }} className={`${className} copy removeDisabledStyles`} diff --git a/packages/manager/src/components/LinodeCLIModal/LinodeCLIModal.tsx b/packages/manager/src/components/LinodeCLIModal/LinodeCLIModal.tsx deleted file mode 100644 index 75e4110188a..00000000000 --- a/packages/manager/src/components/LinodeCLIModal/LinodeCLIModal.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { styled } from '@mui/material/styles'; -import * as React from 'react'; - -import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; -import { Dialog } from 'src/components/Dialog/Dialog'; -import { sendCLIClickEvent } from 'src/utilities/analytics/customEventAnalytics'; - -export interface ImageUploadSuccessDialogProps { - analyticsKey?: string; - command: string; - isOpen: boolean; - onClose: () => void; -} - -export const LinodeCLIModal = React.memo( - (props: ImageUploadSuccessDialogProps) => { - const { analyticsKey, command, isOpen, onClose } = props; - - return ( - - - {command}{' '} - sendCLIClickEvent(analyticsKey) : undefined - } - text={command} - /> - - - ); - } -); - -const StyledLinodeCLIModal = styled(Dialog, { - label: 'StyledLinodeCLIModal', -})(({ theme }) => ({ - '& [data-qa-copied]': { - zIndex: 2, - }, - padding: `${theme.spacing()} ${theme.spacing(2)}`, - width: '100%', -})); - -const StyledCommandDisplay = styled('div', { - label: 'StyledCommandDisplay', -})(({ theme }) => ({ - alignItems: 'center', - backgroundColor: theme.bg.main, - border: `1px solid ${theme.color.border2}`, - display: 'flex', - fontFamily: '"UbuntuMono", monospace, sans-serif', - fontSize: '0.875rem', - justifyContent: 'space-between', - lineHeight: 1, - padding: theme.spacing(), - position: 'relative', - whiteSpace: 'nowrap', - width: '100%', - wordBreak: 'break-all', -})); - -const StyledCLIText = styled('div', { - label: 'StyledCLIText', -})(() => ({ - height: '1rem', - overflowX: 'auto', - overflowY: 'hidden', // For Edge - paddingRight: 15, -})); - -const StyledCopyTooltip = styled(CopyTooltip, { - label: 'StyledCopyTooltip', -})(() => ({ - '& svg': { - height: '1em', - width: '1em', - }, - display: 'flex', -})); diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx index dd01b6cae8b..35b7a42e3c2 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx @@ -9,10 +9,8 @@ import type { Meta, StoryObj } from '@storybook/react'; */ export const _ImageUploader: StoryObj = { args: { - description: 'My Ubuntu Image for Production', - dropzoneDisabled: false, - label: 'file upload', - region: 'us-east-1', + isUploading: false, + progress: undefined, }, render: (args) => { return ; diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.test.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.test.tsx index 0d41c330e2d..ae64d58a2db 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.test.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.test.tsx @@ -5,22 +5,17 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { ImageUploader } from './ImageUploader'; const props = { - apiError: undefined, - dropzoneDisabled: false, - label: 'Upload files here', - onSuccess: vi.fn(), - region: 'us-east-1', - setCancelFn: vi.fn(), - setErrors: vi.fn(), + isUploading: false, + progress: undefined, }; describe('File Uploader', () => { it('properly renders the File Uploader', () => { const screen = renderWithTheme(); - const browseFiles = screen.getByTestId('upload-button'); + const browseFiles = screen.getByText('Browse Files').closest('button'); expect(browseFiles).toBeVisible(); - expect(browseFiles).toHaveAttribute('aria-disabled', 'false'); + expect(browseFiles).toBeEnabled(); const text = screen.getByText( 'You can browse your device to upload an image file or drop it here.' ); @@ -28,16 +23,15 @@ describe('File Uploader', () => { }); it('disables the dropzone', () => { - const screen = renderWithTheme( - - ); + const screen = renderWithTheme(); - const browseFiles = screen.getByTestId('upload-button'); + const browseFiles = screen.getByText('Browse Files').closest('button'); expect(browseFiles).toBeVisible(); + expect(browseFiles).toBeDisabled(); expect(browseFiles).toHaveAttribute('aria-disabled', 'true'); const text = screen.getByText( - 'To upload an image, complete the required fields.' + 'You can browse your device to upload an image file or drop it here.' ); expect(text).toBeVisible(); }); diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx index 7e1a5f23f6f..0392f8e5145 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx @@ -1,397 +1,107 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import { useSnackbar } from 'notistack'; +import { styled } from '@mui/material'; +import { Duration } from 'luxon'; import * as React from 'react'; -import { flushSync } from 'react-dom'; -import { FileRejection, useDropzone } from 'react-dropzone'; -import { useQueryClient } from '@tanstack/react-query'; -import { useDispatch } from 'react-redux'; -import { useHistory } from 'react-router-dom'; - -import { FileUpload } from 'src/components/Uploaders/FileUpload'; -import { - StyledCopy, - StyledDropZoneContentDiv, - StyledDropZoneDiv, - StyledFileUploadsDiv, - StyledUploadButton, -} from 'src/components/Uploaders/ImageUploader/ImageUploader.styles'; -import { onUploadProgressFactory } from 'src/components/Uploaders/ObjectUploader/ObjectUploader'; -import { - MAX_FILE_SIZE_IN_BYTES, - MAX_PARALLEL_UPLOADS, - curriedObjectUploaderReducer, - defaultState, - pathOrFileName, -} from 'src/components/Uploaders/reducer'; -import { uploadImageFile } from 'src/features/Images/requests'; -import { Dispatch } from 'src/hooks/types'; -import { useCurrentToken } from 'src/hooks/useAuthentication'; -import { imageQueries, useUploadImageMutation } from 'src/queries/images'; -import { redirectToLogin } from 'src/session'; -import { setPendingUpload } from 'src/store/pendingUpload'; -import { sendImageUploadEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { DropzoneProps, useDropzone } from 'react-dropzone'; + +import { BarPercent } from 'src/components/BarPercent'; +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { MAX_FILE_SIZE_IN_BYTES } from 'src/components/Uploaders/reducer'; import { readableBytes } from 'src/utilities/unitConversions'; -interface ImageUploaderProps { - /** - * An error to display if an upload error occurred. - */ - apiError: string | undefined; - /** - * The description of the upload that will be sent to the Linode API (used for Image uploads) - */ - description?: string; - /** - * Disables the ability to select image(s) to upload. - */ - dropzoneDisabled: boolean; - isCloudInit?: boolean; - /** - * The label of the upload that will be sent to the Linode API (used for Image uploads). - */ - label: string; - /** - * A function that is called when an upload is successful. - */ - onSuccess?: () => void; - /** - * The region ID to upload the image to. - */ - region: string; +import type { AxiosProgressEvent } from 'axios'; + +interface Props extends Partial { /** - * Allows you to set a cancel upload function in the parent component. + * Whether or not the upload is in progress. */ - setCancelFn: React.Dispatch void) | null>>; + isUploading: boolean; /** - * A function that allows you to set an error value in the parent component. + * The progress of the image upload. */ - setErrors: React.Dispatch>; + progress: AxiosProgressEvent | undefined; } /** * This component enables users to attach and upload images from a device. */ -export const ImageUploader = React.memo((props: ImageUploaderProps) => { - const { - apiError, - description, - dropzoneDisabled, - isCloudInit, - label, - onSuccess, - region, - setErrors, - } = props; - - const { enqueueSnackbar } = useSnackbar(); - const [uploadToURL, setUploadToURL] = React.useState(''); - const queryClient = useQueryClient(); - const { mutateAsync: uploadImage } = useUploadImageMutation({ - cloud_init: isCloudInit ? isCloudInit : undefined, - description: description ? description : undefined, - label, - region, - }); - - const history = useHistory(); - - // Keep track of the session token since we may need to grab the user a new - // one after a long upload (if their session has expired). - const currentToken = useCurrentToken(); - - const [state, dispatch] = React.useReducer( - curriedObjectUploaderReducer, - defaultState - ); - - const dispatchAction: Dispatch = useDispatch(); - - React.useEffect(() => { - const preventDefault = (e: any) => { - e.preventDefault(); - }; - - // This event listeners prevent the browser from opening files dropped on - // the screen, which was happening when the dropzone was disabled. - - // eslint-disable-next-line scanjs-rules/call_addEventListener - window.addEventListener('dragover', preventDefault); - // eslint-disable-next-line scanjs-rules/call_addEventListener - window.addEventListener('drop', preventDefault); - - return () => { - window.removeEventListener('dragover', preventDefault); - window.removeEventListener('drop', preventDefault); - }; - }, []); - - // This function is fired when files are dropped in the upload area. - const onDrop = (files: File[]) => { - const prefix = ''; - - // If an upload attempt failed previously, clear the dropzone. - if (state.numErrors > 0) { - dispatch({ type: 'CLEAR_UPLOAD_HISTORY' }); - } - - dispatch({ files, prefix, type: 'ENQUEUE' }); - }; - - // This function will be called when the user drops non-.gz files, more than one file at a time, or files that are over the max size. - const onDropRejected = (files: FileRejection[]) => { - const wrongFileType = !files[0].file.type.match(/gzip/gi); - const fileTypeErrorMessage = - 'Only raw disk images (.img) compressed using gzip (.gz) can be uploaded.'; - - const moreThanOneFile = files.length > 1; - const fileNumberErrorMessage = 'Only one file may be uploaded at a time.'; - - const fileSizeErrorMessage = `Max file size (${ - readableBytes(MAX_FILE_SIZE_IN_BYTES).formatted - }) exceeded`; - - if (wrongFileType) { - enqueueSnackbar(fileTypeErrorMessage, { - autoHideDuration: 10000, - variant: 'error', - }); - } else if (moreThanOneFile) { - enqueueSnackbar(fileNumberErrorMessage, { - autoHideDuration: 10000, - variant: 'error', - }); - } else { - enqueueSnackbar(fileSizeErrorMessage, { - autoHideDuration: 10000, - variant: 'error', - }); - } - }; - - const nextBatch = React.useMemo(() => { - if (state.numQueued === 0 || state.numInProgress > 0) { - return []; - } - - const queuedUploads = state.files.filter( - (upload) => upload.status === 'QUEUED' - ); - - return queuedUploads.slice(0, MAX_PARALLEL_UPLOADS - state.numInProgress); - }, [state.numQueued, state.numInProgress, state.files]); - - const uploadInProgressOrFinished = - state.numInProgress > 0 || state.numFinished > 0; - - // When `nextBatch` changes, upload the files. - React.useEffect(() => { - if (nextBatch.length === 0) { - return; - } - - nextBatch.forEach((fileUpload) => { - const { file } = fileUpload; - - const path = pathOrFileName(fileUpload.file); - - const onUploadProgress = onUploadProgressFactory(dispatch, path); - - const handleSuccess = () => { - if (onSuccess) { - onSuccess(); - } - - dispatch({ - data: { - percentComplete: 100, - status: 'FINISHED', - }, - filesToUpdate: [path], - type: 'UPDATE_FILES', - }); - - const successfulUploadMessage = `Image ${label} uploaded successfully. It is being processed and will be available shortly.`; - - enqueueSnackbar(successfulUploadMessage, { - autoHideDuration: 6000, - variant: 'success', - }); - - // React force a render so that `pendingUpload` is false when navigating away - // from the upload page. - flushSync(() => { - dispatchAction(setPendingUpload(false)); - }); - - recordImageAnalytics('success', file); - - // EDGE CASE: - // The upload has finished, but the user's token has expired. - // Show the toast, then redirect them to /images, passing them through - // Login to get a new token. - if (!currentToken) { - setTimeout(() => { - redirectToLogin('/images'); - }, 3000); - } else { - queryClient.invalidateQueries(imageQueries.paginated._def); - queryClient.invalidateQueries(imageQueries.all._def); - history.push('/images'); - } - }; - - const handleError = () => { - dispatch({ - data: { - status: 'ERROR', - }, - filesToUpdate: [path], - type: 'UPDATE_FILES', - }); - - dispatchAction(setPendingUpload(false)); - }; - - if (!uploadToURL) { - uploadImage() - .then((response) => { - setUploadToURL(response.upload_to); - - // Let the entire app know that there's a pending upload via Redux. - // High-level components like AuthenticationWrapper need to know - // this, so the user isn't redirected to Login if the token expires. - dispatchAction(setPendingUpload(true)); - - dispatch({ - data: { status: 'IN_PROGRESS' }, - filesToUpdate: [pathOrFileName(fileUpload.file)], - type: 'UPDATE_FILES', - }); - - recordImageAnalytics('start', file); - - const { cancel, request } = uploadImageFile( - response.upload_to, - file, - onUploadProgress - ); - - // The parent might need to cancel this upload (e.g. if the user - // navigates away from the page). - props.setCancelFn(() => () => cancel()); - - request() - .then(() => handleSuccess()) - .catch(() => handleError()); - }) - .catch((e) => { - dispatch({ type: 'CLEAR_UPLOAD_HISTORY' }); - setErrors(e); - }); - } else { - recordImageAnalytics('start', file); - - // Overwrite any file that was previously uploaded to the upload_to URL. - const { cancel, request } = uploadImageFile( - uploadToURL, - file, - onUploadProgress - ); - - props.setCancelFn(cancel); - - request() - .then(() => handleSuccess()) - .catch(() => { - handleError(); - recordImageAnalytics('fail', file); - dispatch({ type: 'CLEAR_UPLOAD_HISTORY' }); - }); - } - }); - }, [nextBatch]); - +export const ImageUploader = React.memo((props: Props) => { + const { isUploading, progress, ...dropzoneProps } = props; const { acceptedFiles, getInputProps, getRootProps, - isDragAccept, isDragActive, - isDragReject, - open, } = useDropzone({ accept: ['application/x-gzip', 'application/gzip'], // Uploaded files must be compressed using gzip. - disabled: dropzoneDisabled || uploadInProgressOrFinished, // disabled when dropzoneDisabled === true, an upload is in progress, or if an upload finished. maxFiles: 1, maxSize: MAX_FILE_SIZE_IN_BYTES, - noClick: true, - noKeyboard: true, - onDrop, - onDropRejected, + ...dropzoneProps, + disabled: dropzoneProps.disabled || isUploading, }); - const hideDropzoneBrowseBtn = - (isDragAccept || acceptedFiles.length > 0) && !apiError; // Checking that there isn't an apiError set to prevent disappearance of button if image creation isn't available in a region at that moment, etc. - - // const UploadZoneActive = - // state.files.filter((upload) => upload.status !== 'QUEUED').length !== 0; - - const uploadZoneActive = state.files.length !== 0; - - const placeholder = dropzoneDisabled - ? 'To upload an image, complete the required fields.' - : 'You can browse your device to upload an image file or drop it here.'; - return ( - - - - {state.files.map((upload, idx) => { - const fileName = upload.file.name; - return ( - - ); - })} - - - {!uploadZoneActive && ( - {placeholder} + + + + {acceptedFiles.length === 0 && ( + + You can browse your device to upload an image file or drop it here. + )} - {!hideDropzoneBrowseBtn ? ( - ( + + {file.name} ({readableBytes(file.size, { base10: true }).formatted}) + + ))} + + {isUploading && ( + + + + + + + {readableBytes(progress?.rate ?? 0, { base10: true }).formatted}/s{' '} + + + {Duration.fromObject({ seconds: progress?.estimated }).toHuman({ + maximumFractionDigits: 0, + })}{' '} + remaining + + + + )} + {!isUploading && ( + + + + )} + ); }); -const recordImageAnalytics = ( - action: 'fail' | 'start' | 'success', - file: File -) => { - const readableFileSize = readableBytes(file.size).formatted; - sendImageUploadEvent(action, readableFileSize); -}; +const Dropzone = styled('div')<{ active: boolean }>(({ active, theme }) => ({ + borderColor: 'gray', + borderStyle: 'dashed', + borderWidth: 1, + display: 'flex', + flexDirection: 'column', + gap: 16, + justifyContent: 'center', + minHeight: 150, + padding: 16, + ...(active && { + backgroundColor: theme.palette.background.default, + borderColor: theme.palette.primary.main, + }), +})); diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImageUpload.tsx index 6e5738f00c0..704a0bce61a 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -1,152 +1,163 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; -import * as React from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useSnackbar } from 'notistack'; +import React, { useState } from 'react'; +import { flushSync } from 'react-dom'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; -import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; import { Checkbox } from 'src/components/Checkbox'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Link } from 'src/components/Link'; -import { LinodeCLIModal } from 'src/components/LinodeCLIModal/LinodeCLIModal'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { Prompt } from 'src/components/Prompt/Prompt'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { Stack } from 'src/components/Stack'; +import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { ImageUploader } from 'src/components/Uploaders/ImageUploader/ImageUploader'; +import { MAX_FILE_SIZE_IN_BYTES } from 'src/components/Uploaders/reducer'; import { Dispatch } from 'src/hooks/types'; -import { useCurrentToken } from 'src/hooks/useAuthentication'; import { useFlags } from 'src/hooks/useFlags'; +import { usePendingUpload } from 'src/hooks/usePendingUpload'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { reportAgreementSigningError, useAccountAgreements, useMutateAccountAgreements, } from 'src/queries/account/agreements'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useUploadImageMutation } from 'src/queries/images'; +import { useProfile } from 'src/queries/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { redirectToLogin } from 'src/session'; -import { ApplicationState } from 'src/store'; import { setPendingUpload } from 'src/store/pendingUpload'; -import { getErrorMap } from 'src/utilities/errorUtils'; import { getGDPRDetails } from 'src/utilities/formatRegion'; -import { wrapInQuotes } from 'src/utilities/stringUtils'; +import { readableBytes } from 'src/utilities/unitConversions'; import { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; +import { getRestrictedResourceText } from '../Account/utils'; +import { ImageUploadSchema, recordImageAnalytics } from './ImageUpload.utils'; +import { + ImageUploadFormData, + ImageUploadNavigationState, +} from './ImageUpload.utils'; +import { ImageUploadCLIDialog } from './ImageUploadCLIDialog'; +import { uploadImageFile } from './requests'; -const useStyles = makeStyles()((theme: Theme) => ({ - browseFilesButton: { - marginLeft: '1rem', - }, - cliModalButton: { - ...theme.applyLinkStyles, - fontFamily: theme.font.bold, - }, - cloudInitCheckboxWrapper: { - marginLeft: '3px', - marginTop: theme.spacing(2), - }, - container: { - '& .MuiFormHelperText-root': { - marginBottom: theme.spacing(2), - }, - minWidth: '100%', - paddingBottom: theme.spacing(), - paddingTop: theme.spacing(2), - }, - helperText: { - marginTop: theme.spacing(2), - [theme.breakpoints.down('sm')]: { - width: '100%', - }, - width: '90%', - }, -})); - -const cloudInitTooltipMessage = ( - - Only check this box if your Custom Image is compatible with cloud-init, or - has cloud-init installed, and the config has been changed to use our data - service.{' '} - - Learn how. - - -); +import type { AxiosError, AxiosProgressEvent } from 'axios'; -const imageSizeLimitsMessage = ( - - Image files must be raw disk images (.img) compressed using gzip (.gz). The - maximum file size is 5 GB (compressed) and maximum image size is 6 GB - (uncompressed). - -); +export const ImageUpload = () => { + const { location } = useHistory(); -export interface Props { - changeDescription: (e: React.ChangeEvent) => void; - changeIsCloudInit: () => void; - changeLabel: (e: React.ChangeEvent) => void; - description: string; - isCloudInit: boolean; - label: string; -} + const dispatch = useDispatch(); + const hasPendingUpload = usePendingUpload(); + const { push } = useHistory(); + const flags = useFlags(); -export const ImageUpload: React.FC = (props) => { - const { - changeDescription, - changeIsCloudInit, - changeLabel, - description, - isCloudInit, - label, - } = props; + const [uploadProgress, setUploadProgress] = useState(); + const cancelRef = React.useRef<(() => void) | null>(null); + const [hasSignedAgreement, setHasSignedAgreement] = useState(false); + const [linodeCLIModalOpen, setLinodeCLIModalOpen] = useState(false); const { data: profile } = useProfile(); - const { data: grants } = useGrants(); const { data: agreements } = useAccountAgreements(); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); + const { data: regions } = useRegionsQuery(); + const { mutateAsync: createImage } = useUploadImageMutation(); + const { enqueueSnackbar } = useSnackbar(); - const { classes } = useStyles(); - const regions = useRegionsQuery().data ?? []; - const dispatch: Dispatch = useDispatch(); - const { push } = useHistory(); - const flags = useFlags(); + const form = useForm({ + defaultValues: { + description: location.state?.imageDescription, + label: location.state?.imageLabel, + }, + mode: 'onBlur', + resolver: yupResolver(ImageUploadSchema), + }); - const [hasSignedAgreement, setHasSignedAgreement] = React.useState( - false - ); + const onSubmit = form.handleSubmit(async (values) => { + const { file, ...createPayload } = values; - const [region, setRegion] = React.useState(''); - const [errors, setErrors] = React.useState(); - const [linodeCLIModalOpen, setLinodeCLIModalOpen] = React.useState( - false - ); + try { + const { image, upload_to } = await createImage(createPayload); + + // Let the entire app know that there's a pending upload via Redux. + // High-level components like AuthenticationWrapper need to know + // this, so the user isn't redirected to Login if the token expires. + dispatch(setPendingUpload(true)); + + recordImageAnalytics('start', file); + + try { + const { cancel, request } = uploadImageFile( + upload_to, + file, + setUploadProgress + ); + + cancelRef.current = cancel; + + await request(); + + if (hasSignedAgreement) { + updateAccountAgreements({ + eu_model: true, + privacy_policy: true, + }).catch(reportAgreementSigningError); + } + + enqueueSnackbar( + `Image ${image.label} uploaded successfully. It is being processed and will be available shortly.`, + { variant: 'success' } + ); + + recordImageAnalytics('success', file); + + // Force a re-render so that `hasPendingUpload` is false when navigating away + // from the upload page. We need this to make the work as expected. + flushSync(() => { + dispatch(setPendingUpload(false)); + }); + + push('/images'); + } catch (error) { + // Handle an Axios error for the actual image upload + form.setError('root', { message: (error as AxiosError).message }); + // Update Redux to show we have no upload in progress + dispatch(setPendingUpload(false)); + recordImageAnalytics('fail', file); + } + } catch (errors) { + // Handle API errors from the POST /v4/images/upload + for (const error of errors) { + if (error.field) { + form.setError(error.field, { message: error.reason }); + } else { + window.scrollTo({ top: 0 }); + form.setError('root', { message: error.reason }); + } + } + // Update Redux to show we have no upload in progress + dispatch(setPendingUpload(false)); + } + }); + + const selectedRegionId = form.watch('region'); const { showGDPRCheckbox } = getGDPRDetails({ agreements, profile, regions, - selectedRegionId: region, + selectedRegionId, }); - // This holds a "cancel function" from the Axios instance that handles image - // uploads. Calling this function will cancel the HTTP request. - const [cancelFn, setCancelFn] = React.useState<(() => void) | null>(null); - - // Whether or not there is an upload pending. This is stored in Redux since - // high-level components like AuthenticationWrapper need to read it. - const pendingUpload = useSelector( - (state) => state.pendingUpload - ); - - // Keep track of the session token since we may need to grab the user a new - // one after a long upload (if their session has expired). - const currentToken = useCurrentToken(); - - const canCreateImage = - Boolean(!profile?.restricted) || Boolean(grants?.global?.add_images); + const isImageCreateRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_images', + }); // Called after a user confirms they want to navigate to another part of // Cloud during a pending upload. When we have refresh tokens this won't be @@ -154,55 +165,244 @@ export const ImageUpload: React.FC = (props) => { // will show the upload progress in the lower part of the screen. For now we // box the user on this page so we can handle token expiry (semi)-gracefully. const onConfirm = (nextLocation: string) => { - if (cancelFn) { - cancelFn(); + if (cancelRef.current) { + cancelRef.current(); } dispatch(setPendingUpload(false)); - // If the user's session has expired we need to send them to Login to get - // a new token. They will be redirected back to path they were trying to - // reach. - if (!currentToken) { - redirectToLogin(nextLocation); - } else { - push(nextLocation); - } - }; - - const onSuccess = () => { - if (hasSignedAgreement) { - updateAccountAgreements({ - eu_model: true, - privacy_policy: true, - }).catch(reportAgreementSigningError); - } + push(nextLocation); }; - const uploadingDisabled = - !label || - !region || - !canCreateImage || - (showGDPRCheckbox && !hasSignedAgreement); - - const errorMap = getErrorMap(['label', 'description', 'region'], errors); - - const cliLabel = formatForCLI(label, 'label'); - const cliDescription = formatForCLI(description, 'description'); - const cliRegion = formatForCLI(region, 'region'); - const linodeCLICommand = `linode-cli image-upload --label ${cliLabel} --description ${cliDescription} --region ${cliRegion} FILE`; - return ( - <> + +
+ + + + Image Details + + {form.formState.errors.root?.message && ( + + )} + {isImageCreateRestricted && ( + + )} + ( + + )} + control={form.control} + name="label" + /> + {flags.metadata && ( + + ( + + Only check this box if your Custom Image is compatible + with cloud-init, or has cloud-init installed, and the + config has been changed to use our data service.{' '} + + Learn how. + + + } + checked={field.value ?? false} + onChange={field.onChange} + text="This image is cloud-init compatible" + /> + )} + control={form.control} + name="cloud_init" + /> + + )} + ( + + )} + control={form.control} + name="region" + /> + ( + + field.onChange(items.map((item) => item.value)) + } + value={ + field.value?.map((tag) => ({ label: tag, value: tag })) ?? + [] + } + tagError={fieldState.error?.message} + /> + )} + control={form.control} + name="tags" + /> + ( + + )} + control={form.control} + name="description" + /> + {showGDPRCheckbox && ( + setHasSignedAgreement(e.target.checked)} + /> + )} + + + + Image Upload + + {form.formState.errors.file?.message && ( + + )} + + + Image files must be raw disk images (.img) compressed using gzip + (.gz). The maximum file size is 5 GB (compressed) and maximum + image size is 6 GB (uncompressed). + + + + Custom Images are billed at $0.10/GB per month based on the + uncompressed image size. + + ( + { + form.setError('file', {}); + field.onChange(files[0]); + }} + onDropRejected={(fileRejections) => { + let message = ''; + switch (fileRejections[0].errors[0].code) { + case 'file-invalid-type': + message = + 'Only raw disk images (.img) compressed using gzip (.gz) can be uploaded.'; + break; + case 'file-too-large': + message = `Max file size (${ + readableBytes(MAX_FILE_SIZE_IN_BYTES).formatted + }) exceeded`; + break; + default: + message = fileRejections[0].errors[0].message; + } + form.setError('file', { message }); + form.resetField('file', { keepError: true }); + }} + disabled={isImageCreateRestricted} + isUploading={form.formState.isSubmitting} + progress={uploadProgress} + /> + )} + control={form.control} + name="file" + /> + + + + + + +
+ setLinodeCLIModalOpen(false)} + /> {({ handleCancel, handleConfirm, isModalOpen }) => { return ( ( + actions={ = (props) => { onClick: handleCancel, }} /> - )} + } onClose={handleCancel} open={isModalOpen} title="Leave this page?" @@ -226,117 +426,6 @@ export const ImageUpload: React.FC = (props) => { ); }} - - - {errorMap.none ? : null} - {!canCreateImage ? ( - - ) : null} - -
- - - - {flags.metadata && ( -
- -
- )} - - {showGDPRCheckbox ? ( - setHasSignedAgreement(e.target.checked)} - /> - ) : null} - - {imageSizeLimitsMessage} - - - Custom Images are billed at $0.10/GB per month based on the - uncompressed image size. - - - - Or, upload an image using the{' '} - - . For more information, please see{' '} - - our guide on using the Linode CLI - - . - -
-
- setLinodeCLIModalOpen(false)} - /> - +
); }; - -export default ImageUpload; - -const formatForCLI = (value: string, fallback: string) => { - return value ? wrapInQuotes(value) : `[${fallback.toUpperCase()}]`; -}; diff --git a/packages/manager/src/features/Images/ImageUpload.utils.ts b/packages/manager/src/features/Images/ImageUpload.utils.ts new file mode 100644 index 00000000000..8e890f341f7 --- /dev/null +++ b/packages/manager/src/features/Images/ImageUpload.utils.ts @@ -0,0 +1,41 @@ +import { uploadImageSchema } from '@linode/validation'; +import { mixed } from 'yup'; + +import { sendImageUploadEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { readableBytes } from 'src/utilities/unitConversions'; + +import type { ImageUploadPayload } from '@linode/api-v4'; + +export const recordImageAnalytics = ( + action: 'fail' | 'start' | 'success', + file: File +) => { + const readableFileSize = readableBytes(file.size).formatted; + sendImageUploadEvent(action, readableFileSize); +}; + +/** + * We extend the image upload payload to contain the file + * so we can use react-hook-form to manage all of the form state. + */ +export interface ImageUploadFormData extends ImageUploadPayload { + file: File; +} + +/** + * We extend the image upload schema to contain the file + * so we can use react-hook-form to validate all of the + * form state at once. + */ +export const ImageUploadSchema = uploadImageSchema.shape({ + file: mixed().required('Image is required.'), +}); + +/** + * We use navigation state to pre-fill the upload form + * when the user "retries" an upload. + */ +export interface ImageUploadNavigationState { + imageDescription?: string; + imageLabel?: string; +} diff --git a/packages/manager/src/features/Images/ImageUploadCLIDialog.test.tsx b/packages/manager/src/features/Images/ImageUploadCLIDialog.test.tsx new file mode 100644 index 00000000000..ba282570ae2 --- /dev/null +++ b/packages/manager/src/features/Images/ImageUploadCLIDialog.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { ImageUploadCLIDialog } from './ImageUploadCLIDialog'; + +import type { ImageUploadFormData } from './ImageUpload.utils'; + +describe('ImageUploadCLIDialog', () => { + it('should render a title', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByText('Upload Image with the Linode CLI')).toBeVisible(); + }); + + it('should render nothing when isOpen is false', () => { + const { container } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render a default CLI command with no form data', () => { + const { getByDisplayValue } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect( + getByDisplayValue( + 'linode-cli image-upload --label [LABEL] --description [DESCRIPTION] --region [REGION] FILE' + ) + ).toBeVisible(); + }); + + it('should render a CLI command based on form data', () => { + const { + getByDisplayValue, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + description: 'this is my cool image', + label: 'my-image', + region: 'us-east', + }, + }, + }); + + expect( + getByDisplayValue( + 'linode-cli image-upload --label "my-image" --description "this is my cool image" --region "us-east" FILE' + ) + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Images/ImageUploadCLIDialog.tsx b/packages/manager/src/features/Images/ImageUploadCLIDialog.tsx new file mode 100644 index 00000000000..898ec158279 --- /dev/null +++ b/packages/manager/src/features/Images/ImageUploadCLIDialog.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { CopyableTextField } from 'src/components/CopyableTextField/CopyableTextField'; +import { Dialog } from 'src/components/Dialog/Dialog'; +import { Link } from 'src/components/Link'; +import { Typography } from 'src/components/Typography'; +import { sendCLIClickEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { wrapInQuotes } from 'src/utilities/stringUtils'; + +import type { ImageUploadFormData } from './ImageUpload.utils'; + +interface ImageUploadSuccessDialogProps { + isOpen: boolean; + onClose: () => void; +} + +export const ImageUploadCLIDialog = (props: ImageUploadSuccessDialogProps) => { + const { isOpen, onClose } = props; + + const form = useFormContext(); + + const { description, label, region } = form.getValues(); + + const cliLabel = formatForCLI(label, 'label'); + const cliDescription = formatForCLI(description ?? '', 'description'); + const cliRegion = formatForCLI(region, 'region'); + + const command = `linode-cli image-upload --label ${cliLabel} --description ${cliDescription} --region ${cliRegion} FILE`; + + return ( + + sendCLIClickEvent('Image Upload'), + }} + expand + hideLabel + label="CLI Command" + noMarginTop + sx={{ fontFamily: 'UbuntuMono, monospace, sans-serif' }} + value={command} + /> + + For more information, please see{' '} + + our guide on using the Linode CLI + + . + + + ); +}; + +const formatForCLI = (value: string, fallback: string) => { + return value ? wrapInQuotes(value) : `[${fallback.toUpperCase()}]`; +}; diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx index c954d558318..fafc8614c04 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx @@ -1,38 +1,22 @@ import * as React from 'react'; -import { useHistory, useRouteMatch } from 'react-router-dom'; +import { useRouteMatch } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { NavTab, NavTabs } from 'src/components/NavTabs/NavTabs'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; +const ImageUpload = React.lazy(() => + import('../ImageUpload').then((module) => ({ default: module.ImageUpload })) +); + const CreateImageTab = React.lazy(() => import('./CreateImageTab').then((module) => ({ default: module.CreateImageTab, })) ); -const ImageUpload = React.lazy(() => import('../ImageUpload')); export const ImageCreate = () => { const { url } = useRouteMatch(); - const { location } = useHistory(); - - const [label, setLabel] = React.useState( - location?.state ? location.state.imageLabel : '' - ); - const [description, setDescription] = React.useState( - location?.state ? location.state.imageDescription : '' - ); - const [isCloudInit, setIsCloudInit] = React.useState(false); - - const handleSetLabel = (e: React.ChangeEvent) => { - const value = e.target.value; - setLabel(value); - }; - - const handleSetDescription = (e: React.ChangeEvent) => { - const value = e.target.value; - setDescription(value); - }; const tabs: NavTab[] = [ { @@ -41,16 +25,7 @@ export const ImageCreate = () => { title: 'Capture Image', }, { - render: ( - setIsCloudInit(!isCloudInit)} - changeLabel={handleSetLabel} - description={description} - isCloudInit={isCloudInit} - label={label} - /> - ), + render: , routeName: `${url}/upload`, title: 'Upload Image', }, diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index 0fc3ea99859..fc5a7c114cb 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -119,10 +119,20 @@ export const useAllImagesQuery = ( enabled, }); -export const useUploadImageMutation = (payload: ImageUploadPayload) => - useMutation({ - mutationFn: () => uploadImage(payload), +export const useUploadImageMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: uploadImage, + onSuccess(data) { + queryClient.invalidateQueries(imageQueries.paginated._def); + queryClient.invalidateQueries(imageQueries.all._def); + queryClient.setQueryData( + imageQueries.image(data.image.id).queryKey, + data.image + ); + }, }); +}; export const imageEventsHandler = ({ event,