From 8a1f4739009940440f47525ced372dbb3b9c92a9 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 16 May 2024 14:20:58 -0400 Subject: [PATCH 01/25] save progress --- .../src/features/Images/ImageUpload.tsx | 260 +++++++----------- 1 file changed, 101 insertions(+), 159 deletions(-) diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImageUpload.tsx index 9006c9a3be3..019d6d8d705 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -1,14 +1,14 @@ 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 { useHistory } from 'react-router-dom'; -import { makeStyles } from 'tss-react/mui'; +import { useForm } from 'react-hook-form'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Checkbox } from 'src/components/Checkbox'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Link } from 'src/components/Link'; +import { LinkButton } from 'src/components/LinkButton'; import { LinodeCLIModal } from 'src/components/LinodeCLIModal/LinodeCLIModal'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; @@ -35,80 +35,26 @@ import { getGDPRDetails } from 'src/utilities/formatRegion'; import { wrapInQuotes } from 'src/utilities/stringUtils'; import { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; +import type { ImageUploadPayload } from '@linode/api-v4'; -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. - - -); - -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 interface Props { - changeDescription: (e: React.ChangeEvent) => void; - changeIsCloudInit: () => void; - changeLabel: (e: React.ChangeEvent) => void; - description: string; - isCloudInit: boolean; - label: string; -} +export const ImageUpload = () => { + const { location } = useHistory<{ + imageDescription: string; + imageLabel?: string; + }>(); -export const ImageUpload: React.FC = (props) => { - const { - changeDescription, - changeIsCloudInit, - changeLabel, - description, - isCloudInit, - label, - } = props; + const form = useForm({ + defaultValues: { + label: location.state.imageLabel, + description: location.state.imageDescription, + }, + }); const { data: profile } = useProfile(); const { data: grants } = useGrants(); const { data: agreements } = useAccountAgreements(); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); - const { classes } = useStyles(); const regions = useRegionsQuery().data ?? []; const dispatch: Dispatch = useDispatch(); const { push } = useHistory(); @@ -226,105 +172,103 @@ export const ImageUpload: React.FC = (props) => { ); }} - - + {errorMap.none ? : null} - {!canCreateImage ? ( + {!canCreateImage && ( - ) : null} - -
- - - + + {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={isCloudInit} + onChange={changeIsCloudInit} + text="This image is cloud-init compatible" + toolTipInteractive /> - {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. - - + {showGDPRCheckbox && ( + setHasSignedAgreement(e.target.checked)} /> - - Or, upload an image using the{' '} - - . For more information, please see{' '} - - our guide on using the Linode CLI - - . + )} + + + 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. + + + + Or, upload an image using the{' '} + setLinodeCLIModalOpen(true)}> + Linode CLI + + . For more information, please see{' '} + + our guide on using the Linode CLI + + . +
= (props) => { ); }; -export default ImageUpload; - const formatForCLI = (value: string, fallback: string) => { return value ? wrapInQuotes(value) : `[${fallback.toUpperCase()}]`; }; From df44d35c0c89961e7f117b3e8fd9b1113eef626f Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 16 May 2024 14:23:58 -0400 Subject: [PATCH 02/25] save progress --- .../manager/src/features/Images/ImageUpload.tsx | 5 +++-- .../features/Images/ImagesCreate/ImageCreate.tsx | 15 ++++----------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImageUpload.tsx index 019d6d8d705..fcf48e9a0b1 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -1,8 +1,8 @@ import { APIError } from '@linode/api-v4/lib/types'; import * as React from 'react'; +import { useForm } from 'react-hook-form'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; -import { useForm } from 'react-hook-form'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Checkbox } from 'src/components/Checkbox'; @@ -35,6 +35,7 @@ import { getGDPRDetails } from 'src/utilities/formatRegion'; import { wrapInQuotes } from 'src/utilities/stringUtils'; import { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; + import type { ImageUploadPayload } from '@linode/api-v4'; export const ImageUpload = () => { @@ -45,8 +46,8 @@ export const ImageUpload = () => { const form = useForm({ defaultValues: { - label: location.state.imageLabel, description: location.state.imageDescription, + label: location.state.imageLabel, }, }); diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx index ebdcd452460..62a30d3e400 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx @@ -6,7 +6,9 @@ import { NavTab, NavTabs } from 'src/components/NavTabs/NavTabs'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; const CreateImageTab = React.lazy(() => import('./CreateImageTab')); -const ImageUpload = React.lazy(() => import('../ImageUpload')); +const ImageUpload = React.lazy(() => + import('../ImageUpload').then((module) => ({ default: module.ImageUpload })) +); export const ImageCreate = () => { const { url } = useRouteMatch(); @@ -46,16 +48,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', }, From 995bc14c8e41a4783776424c29a9617bd71c4b85 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 16 May 2024 15:30:51 -0400 Subject: [PATCH 03/25] save progress --- .../LinodeCLIModal/LinodeCLIModal.tsx | 21 ++- .../src/features/Images/ImageUpload.tsx | 169 ++++++++++-------- 2 files changed, 110 insertions(+), 80 deletions(-) diff --git a/packages/manager/src/components/LinodeCLIModal/LinodeCLIModal.tsx b/packages/manager/src/components/LinodeCLIModal/LinodeCLIModal.tsx index 75e4110188a..d408e6c02ca 100644 --- a/packages/manager/src/components/LinodeCLIModal/LinodeCLIModal.tsx +++ b/packages/manager/src/components/LinodeCLIModal/LinodeCLIModal.tsx @@ -1,20 +1,33 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; +import { useFormContext } from 'react-hook-form'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { Dialog } from 'src/components/Dialog/Dialog'; import { sendCLIClickEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { wrapInQuotes } from 'src/utilities/stringUtils'; + +import type { ImageUploadPayload } from '@linode/api-v4'; export interface ImageUploadSuccessDialogProps { analyticsKey?: string; - command: string; isOpen: boolean; onClose: () => void; } export const LinodeCLIModal = React.memo( (props: ImageUploadSuccessDialogProps) => { - const { analyticsKey, command, isOpen, onClose } = props; + const { analyticsKey, isOpen, onClose } = props; + + const form = useFormContext(); + + const { label, description, 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 ( { + return value ? wrapInQuotes(value) : `[${fallback.toUpperCase()}]`; +}; diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImageUpload.tsx index fcf48e9a0b1..2495eb09d8f 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -1,6 +1,5 @@ -import { APIError } from '@linode/api-v4/lib/types'; import * as React from 'react'; -import { useForm } from 'react-hook-form'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; @@ -16,7 +15,6 @@ import { Prompt } from 'src/components/Prompt/Prompt'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; -import { ImageUploader } from 'src/components/Uploaders/ImageUploader/ImageUploader'; import { Dispatch } from 'src/hooks/types'; import { useCurrentToken } from 'src/hooks/useAuthentication'; import { useFlags } from 'src/hooks/useFlags'; @@ -30,27 +28,32 @@ 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 { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; import type { ImageUploadPayload } from '@linode/api-v4'; +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; export const ImageUpload = () => { - const { location } = useHistory<{ - imageDescription: string; - imageLabel?: string; - }>(); + const { location } = useHistory< + | { + imageDescription: string; + imageLabel?: string; + } + | undefined + >(); const form = useForm({ defaultValues: { - description: location.state.imageDescription, - label: location.state.imageLabel, + description: location.state?.imageDescription, + label: location.state?.imageLabel, }, }); + const selectedRegionId = form.watch('region'); + const { data: profile } = useProfile(); const { data: grants } = useGrants(); const { data: agreements } = useAccountAgreements(); @@ -65,8 +68,6 @@ export const ImageUpload = () => { false ); - const [region, setRegion] = React.useState(''); - const [errors, setErrors] = React.useState(); const [linodeCLIModalOpen, setLinodeCLIModalOpen] = React.useState( false ); @@ -75,7 +76,7 @@ export const ImageUpload = () => { agreements, profile, regions, - selectedRegionId: region, + selectedRegionId, }); // This holds a "cancel function" from the Axios instance that handles image @@ -127,20 +128,10 @@ export const ImageUpload = () => { }; 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`; + !canCreateImage || (showGDPRCheckbox && !hasSignedAgreement); return ( - <> + { }} - {errorMap.none ? : null} + {form.formState.errors.root?.message && ( + + )} {!canCreateImage && ( )} - ( + + )} + control={form.control} + name="label" /> - ( + + )} + control={form.control} + name="description" /> {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={isCloudInit} - onChange={changeIsCloudInit} - text="This image is cloud-init compatible" - toolTipInteractive + ( + + 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" + toolTipInteractive + /> + )} + control={form.control} + name="cloud_init" /> )} - ( + + )} + control={form.control} + name="region" /> {showGDPRCheckbox && ( { Custom Images are billed at $0.10/GB per month based on the uncompressed image size. - { region={region} setCancelFn={setCancelFn} setErrors={setErrors} - /> + /> */} Or, upload an image using the{' '} setLinodeCLIModalOpen(true)}> @@ -271,16 +286,14 @@ export const ImageUpload = () => { . + + + setLinodeCLIModalOpen(false)} /> - + ); }; - -const formatForCLI = (value: string, fallback: string) => { - return value ? wrapInQuotes(value) : `[${fallback.toUpperCase()}]`; -}; From e62a9b25f96157ace0f56752847c8fac64bace00 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 16 May 2024 19:18:17 -0400 Subject: [PATCH 04/25] save progress --- .../Uploaders/ImageUploader/ImageUploader.tsx | 402 ++---------------- .../src/features/Images/ImageUpload.tsx | 331 +++++++------- packages/manager/src/queries/images.ts | 6 +- 3 files changed, 216 insertions(+), 523 deletions(-) diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx index 7e1a5f23f6f..88909e02558 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx @@ -1,316 +1,16 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import { useSnackbar } from 'notistack'; +import { styled } from '@mui/material'; 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 { DropzoneProps, useDropzone } from 'react-dropzone'; +import { Box } from 'src/components/Box'; -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 { 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; - /** - * Allows you to set a cancel upload function in the parent component. - */ - setCancelFn: React.Dispatch void) | null>>; - /** - * A function that allows you to set an error value in the parent component. - */ - setErrors: React.Dispatch>; -} +import { Button } from 'src/components/Button/Button'; +import { Typography } from 'src/components/Typography'; +import { MAX_FILE_SIZE_IN_BYTES } from 'src/components/Uploaders/reducer'; /** * 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: Partial) => { const { acceptedFiles, getInputProps, @@ -321,77 +21,33 @@ export const ImageUploader = React.memo((props: ImageUploaderProps) => { 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, + ...props, }); - 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} - )} - {!hideDropzoneBrowseBtn ? ( - - Browse Files - - ) : null} - - + + + + {acceptedFiles.map((file) => ( + {file.name} + ))} + + + + + ); }); -const recordImageAnalytics = ( - action: 'fail' | 'start' | 'success', - file: File -) => { - const readableFileSize = readableBytes(file.size).formatted; - sendImageUploadEvent(action, readableFileSize); -}; +const Dropzone = styled('div')({ + borderColor: 'gray', + borderStyle: 'dashed', + borderWidth: 1, + display: 'flex', + flexDirection: 'column', + gap: 16, + justifyContent: 'center', + padding: 24, +}); diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImageUpload.tsx index 2495eb09d8f..a13f7882c62 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -1,9 +1,14 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { uploadImageSchema } from '@linode/validation'; import * as React from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; +import { mixed } from 'yup'; 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'; @@ -13,19 +18,20 @@ 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 { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; +import { ImageUploader } from 'src/components/Uploaders/ImageUploader/ImageUploader'; import { Dispatch } from 'src/hooks/types'; -import { useCurrentToken } from 'src/hooks/useAuthentication'; import { useFlags } from 'src/hooks/useFlags'; import { reportAgreementSigningError, useAccountAgreements, useMutateAccountAgreements, } from 'src/queries/account/agreements'; +import { useUploadImageMutation } from 'src/queries/images'; import { useGrants, 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 { getGDPRDetails } from 'src/utilities/formatRegion'; @@ -33,8 +39,14 @@ import { getGDPRDetails } from 'src/utilities/formatRegion'; import { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; import type { ImageUploadPayload } from '@linode/api-v4'; -import { Box } from 'src/components/Box'; -import { Button } from 'src/components/Button/Button'; + +interface ImageUploadFormData extends ImageUploadPayload { + file: File; +} + +const ImageUploadSchema = uploadImageSchema.shape({ + file: mixed().required('You must pick an Image to upload.'), +}); export const ImageUpload = () => { const { location } = useHistory< @@ -45,13 +57,30 @@ export const ImageUpload = () => { | undefined >(); - const form = useForm({ + const { mutateAsync: createImage } = useUploadImageMutation(); + + const form = useForm({ defaultValues: { description: location.state?.imageDescription, label: location.state?.imageLabel, }, + resolver: yupResolver(ImageUploadSchema), }); + const onSubmit = async (values: ImageUploadPayload) => { + try { + const { upload_to } = await createImage(values); + } catch (errors) { + for (const error of errors) { + if (error.field) { + form.setError(error.field, { message: error.reason }); + } else { + form.setError('root', { message: error.reason }); + } + } + } + }; + const selectedRegionId = form.watch('region'); const { data: profile } = useProfile(); @@ -89,10 +118,6 @@ export const ImageUpload = () => { (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); @@ -108,14 +133,7 @@ export const ImageUpload = () => { 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); - } + push(nextLocation); }; const onSuccess = () => { @@ -132,6 +150,155 @@ export const ImageUpload = () => { return ( +
+ + + {form.formState.errors.root?.message && ( + + )} + {!canCreateImage && ( + + )} + ( + + )} + control={form.control} + name="label" + /> + ( + + )} + control={form.control} + name="description" + /> + {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" + toolTipInteractive + /> + )} + control={form.control} + name="cloud_init" + /> + )} + ( + + )} + control={form.control} + name="region" + /> + {showGDPRCheckbox && ( + setHasSignedAgreement(e.target.checked)} + /> + )} + + + + + 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. + + ( + <> + {fieldState.error?.message && ( + + )} + field.onChange(files[0])} /> + + )} + control={form.control} + name="file" + /> + + Or, upload an image using the{' '} + setLinodeCLIModalOpen(true)}> + Linode CLI + + . For more information, please see{' '} + + our guide on using the Linode CLI + + . + + + + + + +
+ setLinodeCLIModalOpen(false)} + /> { ); }} - - {form.formState.errors.root?.message && ( - - )} - {!canCreateImage && ( - - )} - ( - - )} - control={form.control} - name="label" - /> - ( - - )} - control={form.control} - name="description" - /> - {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" - toolTipInteractive - /> - )} - control={form.control} - name="cloud_init" - /> - )} - ( - - )} - control={form.control} - name="region" - /> - {showGDPRCheckbox && ( - setHasSignedAgreement(e.target.checked)} - /> - )} - - - 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. - - {/* */} - - Or, upload an image using the{' '} - setLinodeCLIModalOpen(true)}> - Linode CLI - - . For more information, please see{' '} - - our guide on using the Linode CLI - - . - - - - - - setLinodeCLIModalOpen(false)} - />
); }; diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index fcd52758f8a..52ce4b51d96 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -121,9 +121,9 @@ export const useAllImagesQuery = ( enabled, }); -export const useUploadImageMutation = (payload: ImageUploadPayload) => - useMutation({ - mutationFn: () => uploadImage(payload), +export const useUploadImageMutation = () => + useMutation({ + mutationFn: uploadImage, }); export const imageEventsHandler = ({ From 350003d5aba390efcfc5e2459cbfc60e2c2f03ed Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 16 May 2024 20:47:50 -0400 Subject: [PATCH 05/25] =?UTF-8?q?upload=20flow=20works=20=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/Images/ImageUpload.tsx | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImageUpload.tsx index a13f7882c62..61e81be2086 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -1,5 +1,6 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { uploadImageSchema } from '@linode/validation'; +import { useSnackbar } from 'notistack'; import * as React from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; import { useDispatch, useSelector } from 'react-redux'; @@ -37,6 +38,7 @@ import { setPendingUpload } from 'src/store/pendingUpload'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; +import { uploadImageFile } from './requests'; import type { ImageUploadPayload } from '@linode/api-v4'; @@ -51,13 +53,14 @@ const ImageUploadSchema = uploadImageSchema.shape({ export const ImageUpload = () => { const { location } = useHistory< | { - imageDescription: string; + imageDescription?: string; imageLabel?: string; } | undefined >(); const { mutateAsync: createImage } = useUploadImageMutation(); + const { enqueueSnackbar } = useSnackbar(); const form = useForm({ defaultValues: { @@ -67,9 +70,24 @@ export const ImageUpload = () => { resolver: yupResolver(ImageUploadSchema), }); - const onSubmit = async (values: ImageUploadPayload) => { + const onSubmit = form.handleSubmit(async (values) => { + const { file, ...createPayload } = values; try { - const { upload_to } = await createImage(values); + const { upload_to } = await createImage(createPayload); + enqueueSnackbar('Image creation successful, upload will begin'); + + try { + const { cancel, request } = uploadImageFile(upload_to, file, (e) => + setUploadProgress(e.progress ?? 0) + ); + + cancelRef.current = cancel; + + await request(); + + enqueueSnackbar('Upload successfull'); + push('/images'); + } catch (error) {} } catch (errors) { for (const error of errors) { if (error.field) { @@ -79,7 +97,7 @@ export const ImageUpload = () => { } } } - }; + }); const selectedRegionId = form.watch('region'); @@ -93,10 +111,11 @@ export const ImageUpload = () => { const { push } = useHistory(); const flags = useFlags(); + const [uploadProgress, setUploadProgress] = React.useState(0); + const cancelRef = React.useRef<(() => void) | null>(null); const [hasSignedAgreement, setHasSignedAgreement] = React.useState( false ); - const [linodeCLIModalOpen, setLinodeCLIModalOpen] = React.useState( false ); @@ -108,10 +127,6 @@ export const ImageUpload = () => { 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( @@ -127,8 +142,8 @@ export const ImageUpload = () => { // 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)); @@ -150,7 +165,7 @@ export const ImageUpload = () => { return ( -
+ {form.formState.errors.root?.message && ( @@ -271,6 +286,7 @@ export const ImageUpload = () => { control={form.control} name="file" /> + {uploadProgress} Or, upload an image using the{' '} setLinodeCLIModalOpen(true)}> From 15c2396aa2b80211ef798b67195f8524689c1946 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 17 May 2024 09:34:01 -0400 Subject: [PATCH 06/25] save progress --- .../Uploaders/ImageUploader/ImageUploader.tsx | 36 +++++++++++++------ .../src/features/Images/ImageUpload.tsx | 30 +++++++++------- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx index 88909e02558..eaa84d07f90 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx @@ -1,24 +1,30 @@ import { styled } from '@mui/material'; import * as React from 'react'; import { DropzoneProps, useDropzone } from 'react-dropzone'; -import { Box } from 'src/components/Box'; +import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { Typography } from 'src/components/Typography'; import { MAX_FILE_SIZE_IN_BYTES } from 'src/components/Uploaders/reducer'; +import type { AxiosProgressEvent } from 'axios'; + +interface Props extends Partial { + /** + * The progress of the image upload. + */ + progress: AxiosProgressEvent | undefined; +} + /** * This component enables users to attach and upload images from a device. */ -export const ImageUploader = React.memo((props: Partial) => { +export const ImageUploader = React.memo((props: Props) => { const { acceptedFiles, getInputProps, getRootProps, - isDragAccept, isDragActive, - isDragReject, - open, } = useDropzone({ accept: ['application/x-gzip', 'application/gzip'], // Uploaded files must be compressed using gzip. maxFiles: 1, @@ -27,21 +33,26 @@ export const ImageUploader = React.memo((props: Partial) => { }); return ( - + + {acceptedFiles.length === 0 && ( + + You can browse your device to upload an image file or drop it here. + + )} {acceptedFiles.map((file) => ( {file.name} ))} - + ); }); -const Dropzone = styled('div')({ +const Dropzone = styled('div')<{ active: boolean }>(({ active, theme }) => ({ borderColor: 'gray', borderStyle: 'dashed', borderWidth: 1, @@ -49,5 +60,10 @@ const Dropzone = styled('div')({ flexDirection: 'column', gap: 16, justifyContent: 'center', - padding: 24, -}); + paddingBottom: 32, + paddingTop: 32, + ...(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 61e81be2086..95686314824 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -1,7 +1,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { uploadImageSchema } from '@linode/validation'; import { useSnackbar } from 'notistack'; -import * as React from 'react'; +import React, { useState } from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; @@ -41,6 +41,7 @@ import { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; import { uploadImageFile } from './requests'; import type { ImageUploadPayload } from '@linode/api-v4'; +import type { AxiosProgressEvent } from 'axios'; interface ImageUploadFormData extends ImageUploadPayload { file: File; @@ -77,8 +78,10 @@ export const ImageUpload = () => { enqueueSnackbar('Image creation successful, upload will begin'); try { - const { cancel, request } = uploadImageFile(upload_to, file, (e) => - setUploadProgress(e.progress ?? 0) + const { cancel, request } = uploadImageFile( + upload_to, + file, + setUploadProgress ); cancelRef.current = cancel; @@ -111,14 +114,10 @@ export const ImageUpload = () => { const { push } = useHistory(); const flags = useFlags(); - const [uploadProgress, setUploadProgress] = React.useState(0); + const [uploadProgress, setUploadProgress] = useState(); const cancelRef = React.useRef<(() => void) | null>(null); - const [hasSignedAgreement, setHasSignedAgreement] = React.useState( - false - ); - const [linodeCLIModalOpen, setLinodeCLIModalOpen] = React.useState( - false - ); + const [hasSignedAgreement, setHasSignedAgreement] = useState(false); + const [linodeCLIModalOpen, setLinodeCLIModalOpen] = useState(false); const { showGDPRCheckbox } = getGDPRDetails({ agreements, @@ -280,13 +279,20 @@ export const ImageUpload = () => { {fieldState.error?.message && ( )} - field.onChange(files[0])} /> + + form.setError('file', { + message: fileRejections[0].errors[0].message, + }) + } + onDrop={(files) => field.onChange(files[0])} + progress={uploadProgress} + /> )} control={form.control} name="file" /> - {uploadProgress} Or, upload an image using the{' '} setLinodeCLIModalOpen(true)}> From 2e435b1d7db1213485bf1066ab5d87ecd7429210 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 17 May 2024 10:29:12 -0400 Subject: [PATCH 07/25] save progress --- .../Uploaders/ImageUploader/ImageUploader.tsx | 28 +++++++++++++++---- .../src/features/Images/ImageUpload.tsx | 14 ++++++---- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx index eaa84d07f90..ee85d2a2456 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx @@ -1,11 +1,14 @@ import { styled } from '@mui/material'; +import { Duration } from 'luxon'; import * as React from 'react'; 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 { Typography } from 'src/components/Typography'; import { MAX_FILE_SIZE_IN_BYTES } from 'src/components/Uploaders/reducer'; +import { readableBytes } from 'src/utilities/unitConversions'; import type { AxiosProgressEvent } from 'axios'; @@ -20,6 +23,7 @@ interface Props extends Partial { * This component enables users to attach and upload images from a device. */ export const ImageUploader = React.memo((props: Props) => { + const { progress, ...dropzoneProps } = props; const { acceptedFiles, getInputProps, @@ -29,7 +33,7 @@ export const ImageUploader = React.memo((props: Props) => { accept: ['application/x-gzip', 'application/gzip'], // Uploaded files must be compressed using gzip. maxFiles: 1, maxSize: MAX_FILE_SIZE_IN_BYTES, - ...props, + ...dropzoneProps, }); return ( @@ -42,12 +46,24 @@ export const ImageUploader = React.memo((props: Props) => { )} {acceptedFiles.map((file) => ( - {file.name} + + {file.name} ({readableBytes(file.size, { base10: true }).formatted}) + ))} - - - + {progress && ( + <> + + + {readableBytes(progress.rate ?? 0).formatted}/s {Duration.fromObject({ seconds: progress.estimated }).toHuman()} + + + )} + {!dropzoneProps.disabled && ( + + + + )} ); }); @@ -62,6 +78,8 @@ const Dropzone = styled('div')<{ active: boolean }>(({ active, theme }) => ({ justifyContent: 'center', paddingBottom: 32, paddingTop: 32, + paddingLeft: 16, + paddingRight: 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 95686314824..c3de5f6632d 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -48,7 +48,7 @@ interface ImageUploadFormData extends ImageUploadPayload { } const ImageUploadSchema = uploadImageSchema.shape({ - file: mixed().required('You must pick an Image to upload.'), + file: mixed().required('Image is required.'), }); export const ImageUpload = () => { @@ -280,12 +280,16 @@ export const ImageUpload = () => { )} + onDropAccepted={(files) => { + form.setError('file', {}); + field.onChange(files[0]); + }} + onDropRejected={(fileRejections) => { form.setError('file', { message: fileRejections[0].errors[0].message, - }) - } - onDrop={(files) => field.onChange(files[0])} + }); + }} + disabled={form.formState.isSubmitting} progress={uploadProgress} /> From 646066dc572770059df37a02350193caf422841a Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 17 May 2024 12:08:00 -0400 Subject: [PATCH 08/25] clean up --- .../Uploaders/ImageUploader/ImageUploader.tsx | 44 ++++++--- .../src/features/Images/ImageUpload.tsx | 90 +++++++++---------- 2 files changed, 75 insertions(+), 59 deletions(-) diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx index ee85d2a2456..a0c8f03de36 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx @@ -6,6 +6,7 @@ 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'; @@ -13,6 +14,10 @@ import { readableBytes } from 'src/utilities/unitConversions'; import type { AxiosProgressEvent } from 'axios'; interface Props extends Partial { + /** + * Whether or not the upload is in progress. + */ + isUploading: boolean; /** * The progress of the image upload. */ @@ -23,7 +28,7 @@ interface Props extends Partial { * This component enables users to attach and upload images from a device. */ export const ImageUploader = React.memo((props: Props) => { - const { progress, ...dropzoneProps } = props; + const { isUploading, progress, ...dropzoneProps } = props; const { acceptedFiles, getInputProps, @@ -31,6 +36,7 @@ export const ImageUploader = React.memo((props: Props) => { isDragActive, } = useDropzone({ accept: ['application/x-gzip', 'application/gzip'], // Uploaded files must be compressed using gzip. + disabled: isUploading, maxFiles: 1, maxSize: MAX_FILE_SIZE_IN_BYTES, ...dropzoneProps, @@ -51,15 +57,29 @@ export const ImageUploader = React.memo((props: Props) => { ))} - {progress && ( - <> - - - {readableBytes(progress.rate ?? 0).formatted}/s {Duration.fromObject({ seconds: progress.estimated }).toHuman()} - - + {isUploading && ( + + + + + + + {readableBytes(progress?.rate ?? 0).formatted}/s{' '} + + + {Duration.fromObject({ seconds: progress?.estimated }).toHuman({ + maximumFractionDigits: 0, + })}{' '} + remaining + + + )} - {!dropzoneProps.disabled && ( + {!isUploading && ( @@ -76,10 +96,8 @@ const Dropzone = styled('div')<{ active: boolean }>(({ active, theme }) => ({ flexDirection: 'column', gap: 16, justifyContent: 'center', - paddingBottom: 32, - paddingTop: 32, - paddingLeft: 16, - paddingRight: 16, + 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 c3de5f6632d..fc4e141dd88 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -3,7 +3,7 @@ import { uploadImageSchema } from '@linode/validation'; import { useSnackbar } from 'notistack'; import React, { useState } from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; import { mixed } from 'yup'; @@ -25,15 +25,15 @@ import { Typography } from 'src/components/Typography'; import { ImageUploader } from 'src/components/Uploaders/ImageUploader/ImageUploader'; import { Dispatch } from 'src/hooks/types'; import { useFlags } from 'src/hooks/useFlags'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { reportAgreementSigningError, useAccountAgreements, useMutateAccountAgreements, } from 'src/queries/account/agreements'; import { useUploadImageMutation } from 'src/queries/images'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { ApplicationState } from 'src/store'; import { setPendingUpload } from 'src/store/pendingUpload'; import { getGDPRDetails } from 'src/utilities/formatRegion'; @@ -60,6 +60,19 @@ export const ImageUpload = () => { | undefined >(); + const dispatch = useDispatch(); + const { push } = useHistory(); + const flags = useFlags(); + + 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: agreements } = useAccountAgreements(); + const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); + const { data: regions } = useRegionsQuery(); const { mutateAsync: createImage } = useUploadImageMutation(); const { enqueueSnackbar } = useSnackbar(); @@ -88,6 +101,12 @@ export const ImageUpload = () => { await request(); + if (hasSignedAgreement) { + updateAccountAgreements({ + eu_model: true, + privacy_policy: true, + }).catch(reportAgreementSigningError); + } enqueueSnackbar('Upload successfull'); push('/images'); } catch (error) {} @@ -104,21 +123,6 @@ export const ImageUpload = () => { const selectedRegionId = form.watch('region'); - const { data: profile } = useProfile(); - const { data: grants } = useGrants(); - const { data: agreements } = useAccountAgreements(); - const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); - - const regions = useRegionsQuery().data ?? []; - const dispatch: Dispatch = useDispatch(); - const { push } = useHistory(); - const flags = useFlags(); - - const [uploadProgress, setUploadProgress] = useState(); - const cancelRef = React.useRef<(() => void) | null>(null); - const [hasSignedAgreement, setHasSignedAgreement] = useState(false); - const [linodeCLIModalOpen, setLinodeCLIModalOpen] = useState(false); - const { showGDPRCheckbox } = getGDPRDetails({ agreements, profile, @@ -126,14 +130,9 @@ export const ImageUpload = () => { selectedRegionId, }); - // 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 - ); - - 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 @@ -150,18 +149,6 @@ export const ImageUpload = () => { push(nextLocation); }; - const onSuccess = () => { - if (hasSignedAgreement) { - updateAccountAgreements({ - eu_model: true, - privacy_policy: true, - }).catch(reportAgreementSigningError); - } - }; - - const uploadingDisabled = - !canCreateImage || (showGDPRCheckbox && !hasSignedAgreement); - return ( @@ -173,7 +160,7 @@ export const ImageUpload = () => { variant="error" /> )} - {!canCreateImage && ( + {isImageCreateRestricted && ( { ( { ( { } checked={field.value ?? false} + disabled={isImageCreateRestricted} onChange={field.onChange} text="This image is cloud-init compatible" toolTipInteractive @@ -236,13 +224,13 @@ export const ImageUpload = () => { render={({ field, fieldState }) => ( )} @@ -289,7 +277,8 @@ export const ImageUpload = () => { message: fileRejections[0].errors[0].message, }); }} - disabled={form.formState.isSubmitting} + disabled={isImageCreateRestricted} + isUploading={form.formState.isSubmitting} progress={uploadProgress} /> @@ -309,8 +298,17 @@ export const ImageUpload = () => { . - + + {form.formState.isSubmitting && ( + + )} + )} From 1848229f0cc01e2ed807e406c633688e8894c383 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 17 May 2024 13:00:21 -0400 Subject: [PATCH 10/25] more progress --- .../src/features/Images/ImageUpload.tsx | 110 +++++++++++------- 1 file changed, 68 insertions(+), 42 deletions(-) diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImageUpload.tsx index fc4e141dd88..1e2123f018e 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -1,5 +1,6 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { uploadImageSchema } from '@linode/validation'; +import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import React, { useState } from 'react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; @@ -20,6 +21,7 @@ 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'; @@ -32,6 +34,7 @@ import { useMutateAccountAgreements, } from 'src/queries/account/agreements'; import { useUploadImageMutation } from 'src/queries/images'; +import { imageQueries } from 'src/queries/images'; import { useProfile } from 'src/queries/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { setPendingUpload } from 'src/store/pendingUpload'; @@ -41,7 +44,7 @@ import { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; import { uploadImageFile } from './requests'; import type { ImageUploadPayload } from '@linode/api-v4'; -import type { AxiosProgressEvent } from 'axios'; +import type { AxiosError, AxiosProgressEvent } from 'axios'; interface ImageUploadFormData extends ImageUploadPayload { file: File; @@ -69,6 +72,7 @@ export const ImageUpload = () => { const [hasSignedAgreement, setHasSignedAgreement] = useState(false); const [linodeCLIModalOpen, setLinodeCLIModalOpen] = useState(false); + const queryClient = useQueryClient(); const { data: profile } = useProfile(); const { data: agreements } = useAccountAgreements(); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); @@ -86,8 +90,9 @@ export const ImageUpload = () => { const onSubmit = form.handleSubmit(async (values) => { const { file, ...createPayload } = values; + try { - const { upload_to } = await createImage(createPayload); + const { image, upload_to } = await createImage(createPayload); enqueueSnackbar('Image creation successful, upload will begin'); try { @@ -107,10 +112,22 @@ export const ImageUpload = () => { privacy_policy: true, }).catch(reportAgreementSigningError); } - enqueueSnackbar('Upload successfull'); + + enqueueSnackbar( + `Image ${image.label} uploaded successfully. It is being processed and will be available shortly.`, + { variant: 'success' } + ); + + queryClient.invalidateQueries(imageQueries.paginated._def); + queryClient.invalidateQueries(imageQueries.all._def); + push('/images'); - } catch (error) {} + } catch (error) { + // Handle an Axios error for the actual image upload + form.setError('root', { message: (error as AxiosError).message }); + } } 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 }); @@ -154,6 +171,9 @@ export const ImageUpload = () => { + + Image Details + {form.formState.errors.root?.message && ( { control={form.control} name="label" /> - ( - - )} - control={form.control} - name="description" - /> {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} - disabled={isImageCreateRestricted} - onChange={field.onChange} - text="This image is cloud-init compatible" - toolTipInteractive - /> - )} - control={form.control} - name="cloud_init" - /> + + ( + + 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} + disabled={isImageCreateRestricted} + onChange={field.onChange} + text="This image is cloud-init compatible" + toolTipInteractive + /> + )} + control={form.control} + name="cloud_init" + /> + )} ( @@ -237,6 +244,22 @@ export const ImageUpload = () => { control={form.control} name="region" /> + null} value={[]} /> + ( + + )} + control={form.control} + name="description" + /> {showGDPRCheckbox && ( { )} + + Image Upload + Date: Fri, 17 May 2024 13:32:16 -0400 Subject: [PATCH 11/25] re-add redux and analytics --- .../src/features/Images/ImageUpload.tsx | 55 ++++++++++--------- .../src/features/Images/ImageUpload.utils.ts | 28 ++++++++++ 2 files changed, 58 insertions(+), 25 deletions(-) create mode 100644 packages/manager/src/features/Images/ImageUpload.utils.ts diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImageUpload.tsx index 1e2123f018e..7977045ea84 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -1,12 +1,11 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { uploadImageSchema } from '@linode/validation'; import { useQueryClient } from '@tanstack/react-query'; 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 { mixed } from 'yup'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Box } from 'src/components/Box'; @@ -27,6 +26,7 @@ import { Typography } from 'src/components/Typography'; import { ImageUploader } from 'src/components/Uploaders/ImageUploader/ImageUploader'; import { Dispatch } from 'src/hooks/types'; import { useFlags } from 'src/hooks/useFlags'; +import { usePendingUpload } from 'src/hooks/usePendingUpload'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { reportAgreementSigningError, @@ -41,29 +41,20 @@ import { setPendingUpload } from 'src/store/pendingUpload'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; +import { ImageUploadSchema, recordImageAnalytics } from './ImageUpload.utils'; +import { + ImageUploadFormData, + ImageUploadNavigationState, +} from './ImageUpload.utils'; import { uploadImageFile } from './requests'; -import type { ImageUploadPayload } from '@linode/api-v4'; import type { AxiosError, AxiosProgressEvent } from 'axios'; -interface ImageUploadFormData extends ImageUploadPayload { - file: File; -} - -const ImageUploadSchema = uploadImageSchema.shape({ - file: mixed().required('Image is required.'), -}); - export const ImageUpload = () => { - const { location } = useHistory< - | { - imageDescription?: string; - imageLabel?: string; - } - | undefined - >(); + const { location } = useHistory(); const dispatch = useDispatch(); + const hasPendingUpload = usePendingUpload(); const { push } = useHistory(); const flags = useFlags(); @@ -93,7 +84,13 @@ export const ImageUpload = () => { try { const { image, upload_to } = await createImage(createPayload); - enqueueSnackbar('Image creation successful, upload will begin'); + + // 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( @@ -118,13 +115,24 @@ export const ImageUpload = () => { { variant: 'success' } ); + recordImageAnalytics('success', file); + queryClient.invalidateQueries(imageQueries.paginated._def); queryClient.invalidateQueries(imageQueries.all._def); + // 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 @@ -135,6 +143,8 @@ export const ImageUpload = () => { form.setError('root', { message: error.reason }); } } + // Update Redux to show we have no upload in progress + dispatch(setPendingUpload(false)); } }); @@ -325,11 +335,6 @@ export const ImageUpload = () => { - {form.formState.isSubmitting && ( - - )}