diff --git a/package.json b/package.json index 822c8343423..ba87d353ecb 100644 --- a/package.json +++ b/package.json @@ -204,6 +204,7 @@ "supercluster": "7.1.5", "unleash-proxy-client": "3.1.1", "url": "0.11.3", + "uuid": "10.0.0", "victory-native": "36.6.8", "yup": "0.31.1" }, @@ -253,6 +254,7 @@ "@types/styled-system": "5.1.16", "@types/styled-system__theme-get": "5.0.2", "@types/supercluster": "5.0.3", + "@types/uuid": "10.0.0", "@types/yup": "0.29.13", "@typescript-eslint/eslint-plugin": "5.57.0", "@typescript-eslint/parser": "5.57.0", diff --git a/src/app/Scenes/MyCollection/Screens/Artwork/Components/MyCollectionWhySell.tests.tsx b/src/app/Scenes/MyCollection/Screens/Artwork/Components/MyCollectionWhySell.tests.tsx index 247d53446f6..00a08921113 100644 --- a/src/app/Scenes/MyCollection/Screens/Artwork/Components/MyCollectionWhySell.tests.tsx +++ b/src/app/Scenes/MyCollection/Screens/Artwork/Components/MyCollectionWhySell.tests.tsx @@ -88,6 +88,8 @@ describe("MyCollectionWhySell", () => { framedWidth: undefined, internalID: "someInternalId", isFramed: undefined, + condition: undefined, + conditionDescription: undefined, }, attributionClass: "UNIQUE", category: undefined, @@ -123,6 +125,8 @@ describe("MyCollectionWhySell", () => { utmTerm: "", width: "13", year: "2019", + additionalDocuments: [], + externalId: null, }, }, }) @@ -239,6 +243,8 @@ describe("MyCollectionWhySell", () => { framedWidth: undefined, internalID: "someInternalId", isFramed: undefined, + condition: undefined, + conditionDescription: undefined, }, attributionClass: "UNIQUE", category: undefined, @@ -274,6 +280,8 @@ describe("MyCollectionWhySell", () => { utmTerm: "", width: "13", year: "2019", + additionalDocuments: [], + externalId: null, }, }, }) diff --git a/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/SubmitArtworkAdditionalDocuments.tsx b/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/SubmitArtworkAdditionalDocuments.tsx index b853428b7de..83c63e0ef8b 100644 --- a/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/SubmitArtworkAdditionalDocuments.tsx +++ b/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/SubmitArtworkAdditionalDocuments.tsx @@ -5,6 +5,7 @@ import { DocumentIcon, Flex, INPUT_BORDER_RADIUS, + ProgressBar, Separator, Spacer, Text, @@ -14,37 +15,29 @@ import { import { useActionSheet } from "@expo/react-native-action-sheet" import { NavigationProp, useNavigation } from "@react-navigation/native" import { useToast } from "app/Components/Toast/toastHook" +import { isImage } from "app/Scenes/MyCollection/Screens/ArtworkForm/MyCollectionImageUtil" import { SubmitArtworkFormStore } from "app/Scenes/SellWithArtsy/ArtworkForm/Components/SubmitArtworkFormStore" import { SubmitArtworkStackNavigation } from "app/Scenes/SellWithArtsy/ArtworkForm/SubmitArtworkForm" import { useNavigationListeners } from "app/Scenes/SellWithArtsy/ArtworkForm/Utils/useNavigationListeners" import { SubmissionModel } from "app/Scenes/SellWithArtsy/ArtworkForm/Utils/validation" import { ICON_SIZE } from "app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/UploadPhotosForm" -import { - isDocument, - isImage, - showDocumentsAndPhotosActionSheet, -} from "app/utils/showDocumentsAndPhotosActionSheet" +import { addAssetToConsignment } from "app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/addAssetToConsignment" +import { uploadDocument } from "app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/uploadDocumentToS3" // pragma: allowlist secret +import { removeAssetFromSubmission } from "app/Scenes/SellWithArtsy/mutations/removeAssetFromConsignmentSubmissionMutation" +import { NormalizedDocument, normalizeUploadedDocument } from "app/utils/normalizeUploadedDocument" +import { showDocumentsAndPhotosActionSheet } from "app/utils/showDocumentsAndPhotosActionSheet" import { ProvideScreenTrackingWithCohesionSchema } from "app/utils/track" import { screen } from "app/utils/track/helpers" import { useFormikContext } from "formik" import { useState } from "react" import { Image, LayoutAnimation, Platform, ScrollView } from "react-native" -import { DocumentPickerResponse } from "react-native-document-picker" -import { Image as RNPickerImage } from "react-native-image-crop-picker" - -type UploadedFile = { - error?: string -} & DocumentPickerResponse - -type UploadedImage = { - error?: string -} & RNPickerImage // 50 MB in bytes -const FILE_SIZE_LIMIT = 1 * 1024 * 1024 +const FILE_SIZE_LIMIT = 50 * 1024 * 1024 export const SubmitArtworkAdditionalDocuments = () => { - const { values } = useFormikContext() + const { values, setFieldValue } = useFormikContext() + const [progress, setProgress] = useState>({}) const space = useSpace() @@ -52,9 +45,6 @@ export const SubmitArtworkAdditionalDocuments = () => { const { show: showToast } = useToast() - const [documents, setDocuments] = useState([]) - const [images, setImages] = useState([]) - const setIsLoading = SubmitArtworkFormStore.useStoreActions((actions) => actions.setIsLoading) const setCurrentStep = SubmitArtworkFormStore.useStoreActions((actions) => actions.setCurrentStep) @@ -67,7 +57,6 @@ export const SubmitArtworkAdditionalDocuments = () => { setIsLoading(true) // Make API call to update submission - navigation.navigate("Condition") setCurrentStep("Condition") } catch (error) { @@ -81,55 +70,96 @@ export const SubmitArtworkAdditionalDocuments = () => { }, }) + // Uploading a file is a two step process + // 1. Upload the file to S3 + // 2. Associate the file to the consignment submission + const addDocumentToSubmission = async (document: NormalizedDocument) => { + try { + if (document.errorMessage) { + return + } + + document.loading = true + + // Upload the document to S3 + const response = await uploadDocument({ + document, + updateProgress: (progress) => { + setProgress((previousProgress) => ({ + ...previousProgress, + [document.id]: progress, + })) + }, + }) + + if (!response?.key) { + document.errorMessage = "Failed to upload file" + return + } + + document.sourceKey = response.key + + // Associate the document to the consignment submission + // upload & size the photo, and add it to processed photos + // let Convection know that the Gemini asset should be attached to the consignment + const res = await addAssetToConsignment({ + assetType: "additional_file", + source: { + key: response.key, + bucket: document.bucket || response.bucket, + }, + filename: document.name, + externalSubmissionId: values.externalId, + size: document.size, + submissionID: values.submissionId, + }) + + document.assetId = res.addAssetToConsignmentSubmission?.asset?.id + } catch (error) { + console.error("Error uploading file", error) + showToast("Could not upload file", "bottom", { + backgroundColor: "red100", + }) + } finally { + document.loading = false + } + } + const handleUpload = async () => { try { const results = await showDocumentsAndPhotosActionSheet(showActionSheetWithOptions, true) - const imagesResults = results.filter(isImage) - const documentsResults = results.filter(isDocument) - const filteredDocuments = documentsResults - // Remove duplicates - .filter(({ uri }) => !documents.find(({ uri: fileUri }) => fileUri === uri)) - // Remove files that are too large - .map((file) => { - if (file.size && file.size > FILE_SIZE_LIMIT) { - return { - ...file, - error: "File is too large (max. 50 MB)", - } - } - return file - }) + const normalizedFiles = results.map((document) => normalizeUploadedDocument(document)) - const filteredImages = imagesResults + const filteredDocuments: NormalizedDocument[] = normalizedFiles // Remove duplicates - .filter(({ path }) => !images.find(({ path: ImagePath }) => ImagePath === path)) - // Remove images that are too large - .map((image) => { - if (image.size && image.size > FILE_SIZE_LIMIT) { + .filter((document) => !values.additionalDocuments.find((doc) => doc.id === document.id)) + .map((document) => { + if (document.size && parseInt(document.size, 10) > FILE_SIZE_LIMIT) { return { - ...image, - error: "Image is too large (max. 50 MB)", + ...document, + errorMessage: "File is too large (max. 50 MB)", } } - return image + return document }) if (filteredDocuments.length > 0) { setIsLoading(true) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) - setDocuments(documents.concat(filteredDocuments)) - } - - if (filteredImages.length > 0) { - setIsLoading(true) - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) - setImages(images.concat(filteredImages)) + setFieldValue("additionalDocuments", values.additionalDocuments.concat(filteredDocuments)) + await Promise.all( + filteredDocuments + .filter((document) => !document.errorMessage) + .map((document) => addDocumentToSubmission(document)) + ) } } catch (error) { if (typeof error === "object" && (error as any).code === "DOCUMENT_PICKER_CANCELED") { return } + console.error("Error uploading document", error) + showToast("Could not upload documents, please try again.", "bottom", { backgroundColor: "red100", }) @@ -139,28 +169,25 @@ export const SubmitArtworkAdditionalDocuments = () => { } // remove image assets from submission - const handleImageDelete = async (path: string) => { + const handleDelete = async (document: NormalizedDocument) => { try { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) - const filteredImages = images.filter((image) => image.path !== path) - setImages(filteredImages) + document.removed = true + document.abortUploading?.() - // TODO: unlink from submission - } catch (error) { - console.error("Failed to delete image", error) - } - } + if (document.assetId) { + await removeAssetFromSubmission({ assetID: document.assetId }) + } - // remove image assets from submission - const handleDocumentDelete = async (uri: string) => { - try { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) - const filteredDocuments = documents.filter(({ uri: fileUri }) => fileUri !== uri) - setDocuments(filteredDocuments) - // TODO: unlink from submission + const filteredFiles = values.additionalDocuments.filter((doc) => doc.id !== document.id) + + setFieldValue("additionalDocuments", filteredFiles) } catch (error) { - console.error("Failed to delete document", error) + console.error("Failed to delete", error) + showToast("Could not delete file", "bottom", { + backgroundColor: "red100", + }) } } @@ -195,34 +222,15 @@ export const SubmitArtworkAdditionalDocuments = () => { - {documents.map(({ uri, name, size, error, type }) => { - return ( - { - handleDocumentDelete(uri) - }} - size={size} - type={type} - /> - ) - })} - {images.map(({ path, size, error }) => { + {values.additionalDocuments.map((document) => { return ( { - handleImageDelete(path) + handleDelete(document) }} - size={size} - type="image" /> ) })} @@ -237,68 +245,64 @@ export const SubmitArtworkAdditionalDocuments = () => { const CONTAINER_HEIGHT = 60 const UploadedFile: React.FC<{ - error?: string - name: string | null + document: NormalizedDocument onRemove: () => void - progress?: number - size: number | null - type: string | null - uri: string -}> = ({ error, name, size, onRemove, type, uri }) => { + progress?: number | null +}> = ({ document, onRemove, progress }) => { const space = useSpace() - const sizeInMb = size ? (size / 1024 / 1024).toFixed(2) : 0 + const sizeInMb = document.size ? (parseFloat(document.size) / 1024 / 1024).toFixed(2) : 0 return ( - + - {type && type.includes("image") ? ( - - ) : ( - - )} - - - - {name} - - {error ? ( - - {error} - - ) : ( - - {sizeInMb} MB + {isImage(document.item) && document.item.path ? ( + + ) : ( + + )} + + + + {document.name} - )} - - {!error && ( + {document.errorMessage ? ( + + {document.errorMessage} + + ) : ( + + {sizeInMb} MB + + )} + + + + {!!progress && ( + + + )} ) diff --git a/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/SubmitArtworkBottomNavigation.tsx b/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/SubmitArtworkBottomNavigation.tsx index d5e24981f3f..5c7e52587df 100644 --- a/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/SubmitArtworkBottomNavigation.tsx +++ b/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/SubmitArtworkBottomNavigation.tsx @@ -9,6 +9,7 @@ import { Photo } from "app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/valid import { GlobalStore } from "app/store/GlobalStore" import { dismissModal, navigate, popToRoot, switchTab } from "app/system/navigation/navigate" import { useIsKeyboardVisible } from "app/utils/hooks/useIsKeyboardVisible" +import { NormalizedDocument } from "app/utils/normalizeUploadedDocument" import { useFormikContext } from "formik" import { useEffect } from "react" import { LayoutAnimation } from "react-native" @@ -36,6 +37,13 @@ export const SubmitArtworkBottomNavigation: React.FC<{}> = () => { const { trackTappedNewSubmission, trackTappedStartMyCollection, trackConsignmentSubmitted } = useSubmitArtworkTracking() + const isUploadingAdditionalDocuments = values.additionalDocuments.some( + (document: NormalizedDocument) => document.loading + ) + const allDocumentsAreValid = values.additionalDocuments.every( + (document: NormalizedDocument) => !document.errorMessage + ) + const isUploadingPhotos = values.photos.some((photo: Photo) => photo.loading) const allPhotosAreValid = values.photos.every( (photo: Photo) => !photo.error && !photo.errorMessage @@ -180,8 +188,14 @@ export const SubmitArtworkBottomNavigation: React.FC<{}> = () => { diff --git a/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/SubmitArtworkTopNavigation.tsx b/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/SubmitArtworkTopNavigation.tsx index d74fb74661e..34bb7ae7184 100644 --- a/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/SubmitArtworkTopNavigation.tsx +++ b/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/SubmitArtworkTopNavigation.tsx @@ -49,6 +49,7 @@ export const SubmitArtworkTopNavigation: React.FC<{}> = () => { condition: values.artwork.condition, conditionDescription: values.artwork.conditionDescription, } + // Make API call to update my collection artwork await myCollectionUpdateArtwork(newValues) } diff --git a/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/__tests__/SubmitArtworkAdditionalDocuments.tests.tsx b/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/__tests__/SubmitArtworkAdditionalDocuments.tests.tsx new file mode 100644 index 00000000000..3a9dad3ba78 --- /dev/null +++ b/src/app/Scenes/SellWithArtsy/ArtworkForm/Components/__tests__/SubmitArtworkAdditionalDocuments.tests.tsx @@ -0,0 +1,121 @@ +import { useIsFocused, useNavigation } from "@react-navigation/native" +import { fireEvent, screen } from "@testing-library/react-native" +import { SubmitArtworkAdditionalDocuments } from "app/Scenes/SellWithArtsy/ArtworkForm/Components/SubmitArtworkAdditionalDocuments" +import { renderWithSubmitArtworkWrapper } from "app/Scenes/SellWithArtsy/ArtworkForm/Utils/testWrappers" +import { flushPromiseQueue } from "app/utils/tests/flushPromiseQueue" +import relay from "react-relay" + +const mockNavigate = jest.fn() + +const mockShowActionSheetWithOptions = jest.fn() + +const mockCommitMutation = (fn?: typeof relay.commitMutation) => + jest.fn>(fn as any) + +jest.mock("app/utils/showDocumentsAndPhotosActionSheet", () => ({ + showDocumentsAndPhotosActionSheet: () => { + return [ + { + uri: "file:///path/to/file", + fileCopyUri: "file:///path/to/file", + name: "file.pdf", + type: "document/pdf", + size: 10 * 1024 * 1024, + }, + ] + }, + isDocument: () => true, +})) + +jest.mock("app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/uploadDocumentToS3", () => ({ + // pragma: allowlist secret + uploadDocument: () => { + return { + key: "key", + bucket: "bucket", + } + }, +})) + +jest.mock("@expo/react-native-action-sheet", () => ({ + useActionSheet: () => ({ showActionSheetWithOptions: mockShowActionSheetWithOptions }), +})) + +jest.mock("@react-navigation/native", () => ({ + ...jest.requireActual("@react-navigation/native"), + useNavigation: jest.fn(), + useIsFocused: jest.fn(), +})) + +describe("SubmitArtworkAdditionalDocuments", () => { + const useNavigationMock = useNavigation as jest.Mock + const useIsFocusedMock = useIsFocused as jest.Mock + + afterEach(() => {}) + + beforeEach(() => { + useIsFocusedMock.mockReturnValue(() => true) + useNavigationMock.mockReturnValue({ + navigate: mockNavigate, + }) + + jest.clearAllMocks() + }) + + it("renders the list of uploaded documents", async () => { + relay.commitMutation = mockCommitMutation((_, { onCompleted }) => { + onCompleted!( + { + addAssetToConsignmentSubmission: { + asset: { + id: "asset-id", + submissionID: "submission-id", + }, + }, + }, + null + ) + return { dispose: jest.fn() } + }) as any + + renderWithSubmitArtworkWrapper({ + props: { currentStep: "AdditionalDocuments" }, + component: , + injectedFormikProps: { + submissionId: "submission-id", + externalId: "external-id", + additionalDocuments: [], + }, + }) + + fireEvent.press(screen.getByText("Add Documents")) + // Wait for the actoin sheet to show up + await flushPromiseQueue() + + expect(relay.commitMutation).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + variables: { + input: { + assetType: "additional_file", + clientMutationId: "random-client-mutation-id", + externalSubmissionId: "external-id", + filename: "file.pdf", + size: "10485760", + source: { bucket: "bucket", key: "key" }, + submissionID: "submission-id", + }, + }, + }) + ) + + expect(screen.getByText("file.pdf")).toBeOnTheScreen() + expect(screen.getByText(/10.00/)).toBeOnTheScreen() + + fireEvent(screen.getByText("Continue"), "onPress") + + await flushPromiseQueue() + + expect(mockNavigate).toHaveBeenCalledWith("Condition") + }) +}) diff --git a/src/app/Scenes/SellWithArtsy/ArtworkForm/SubmitArtworkForm.tsx b/src/app/Scenes/SellWithArtsy/ArtworkForm/SubmitArtworkForm.tsx index 513e78bf4a0..b12b058bf1e 100644 --- a/src/app/Scenes/SellWithArtsy/ArtworkForm/SubmitArtworkForm.tsx +++ b/src/app/Scenes/SellWithArtsy/ArtworkForm/SubmitArtworkForm.tsx @@ -253,17 +253,10 @@ const SubmitArtworkFormContent: React.FC = ({ options={{ gestureEnabled: false }} /> - {formik.values.state === "APPROVED" && ( - <> - - - - - - )} + + + + diff --git a/src/app/Scenes/SellWithArtsy/ArtworkForm/SubmitArtworkFormEdit.tsx b/src/app/Scenes/SellWithArtsy/ArtworkForm/SubmitArtworkFormEdit.tsx index 180625c9b45..5e78a278e2e 100644 --- a/src/app/Scenes/SellWithArtsy/ArtworkForm/SubmitArtworkFormEdit.tsx +++ b/src/app/Scenes/SellWithArtsy/ArtworkForm/SubmitArtworkFormEdit.tsx @@ -89,6 +89,15 @@ const submitArtworkFormEditQuery = graphql` size filename } + externalId + addtionalAssets: assets(assetType: [ADDITIONAL_FILE]) { + id + size + filename + documentPath + s3Path + s3Bucket + } } me { addressConnection { diff --git a/src/app/Scenes/SellWithArtsy/ArtworkForm/Utils/getInitialSubmissionValues.ts b/src/app/Scenes/SellWithArtsy/ArtworkForm/Utils/getInitialSubmissionValues.ts index ca1f9b478ef..bd009a86a54 100644 --- a/src/app/Scenes/SellWithArtsy/ArtworkForm/Utils/getInitialSubmissionValues.ts +++ b/src/app/Scenes/SellWithArtsy/ArtworkForm/Utils/getInitialSubmissionValues.ts @@ -7,6 +7,7 @@ import { COUNTRY_SELECT_OPTIONS } from "app/Components/CountrySelect" import { SubmissionModel } from "app/Scenes/SellWithArtsy/ArtworkForm/Utils/validation" import { acceptableCategoriesForSubmission } from "app/Scenes/SellWithArtsy/SubmitArtwork/ArtworkDetails/utils/acceptableCategoriesForSubmission" import { extractNodes } from "app/utils/extractNodes" +import { NormalizedDocument } from "app/utils/normalizeUploadedDocument" import { compact } from "lodash" export const getInitialSubmissionValues = ( @@ -75,8 +76,40 @@ export const getInitialSubmissionValues = ( framedWidth: values.myCollectionArtwork?.framedWidth ?? null, framedHeight: values.myCollectionArtwork?.framedHeight ?? null, framedDepth: values.myCollectionArtwork?.framedDepth ?? null, - condition: (values.myCollectionArtwork?.condition?.value as ArtworkConditionEnumType) ?? null, - conditionDescription: values.myCollectionArtwork?.conditionDescription?.details ?? null, + condition: + (values.myCollectionArtwork?.condition?.value as ArtworkConditionEnumType) ?? undefined, + conditionDescription: values.myCollectionArtwork?.conditionDescription?.details ?? undefined, }, + + externalId: values.externalId, + additionalDocuments: getInitialAdditionalDocuments(values.addtionalAssets), } } + +const getInitialAdditionalDocuments = ( + values: NonNullable["addtionalAssets"] +): NormalizedDocument[] => { + if (!values) return [] + + return compact( + values.map((document) => { + if (!document || !document.id) return null + return { + abortUploading: undefined, + assetId: document?.id, + bucket: document?.s3Bucket, + errorMessage: null, + externalUrl: document?.documentPath, + geminiToken: null, + id: document?.id as string, + item: null, + loading: false, + name: document?.filename, + progress: null, + removed: false, + size: document?.size, + sourceKey: document?.s3Path, + } as NormalizedDocument + }) + ) +} diff --git a/src/app/Scenes/SellWithArtsy/ArtworkForm/Utils/getInitialSubmissionValuesFromArtwork.ts b/src/app/Scenes/SellWithArtsy/ArtworkForm/Utils/getInitialSubmissionValuesFromArtwork.ts index b6093ad9091..58d6dcf5508 100644 --- a/src/app/Scenes/SellWithArtsy/ArtworkForm/Utils/getInitialSubmissionValuesFromArtwork.ts +++ b/src/app/Scenes/SellWithArtsy/ArtworkForm/Utils/getInitialSubmissionValuesFromArtwork.ts @@ -39,6 +39,7 @@ export const getInitialSubmissionFormValuesFromArtwork = ( // This is a tradeoff between type safety and ease of development const formValues: SubmissionModel = { submissionId: null, + externalId: null, artist: artwork.artist?.displayLabel || "", artistId: artwork.artist?.internalID || "", artistSearchResult: { @@ -104,6 +105,9 @@ export const getInitialSubmissionFormValuesFromArtwork = ( condition: artwork.condition?.value as ArtworkConditionEnumType | null | undefined, conditionDescription: artwork.conditionDescription?.details, }, + + // TODO: Add additional documents + additionalDocuments: [], } return formValues diff --git a/src/app/Scenes/SellWithArtsy/ArtworkForm/Utils/validation.ts b/src/app/Scenes/SellWithArtsy/ArtworkForm/Utils/validation.ts index c8b9daf0c81..1334ae3684d 100644 --- a/src/app/Scenes/SellWithArtsy/ArtworkForm/Utils/validation.ts +++ b/src/app/Scenes/SellWithArtsy/ArtworkForm/Utils/validation.ts @@ -15,6 +15,7 @@ import { import { Location } from "app/Scenes/SellWithArtsy/SubmitArtwork/ArtworkDetails/validation" import { Photo } from "app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/validation" import { unsafe_getFeatureFlag } from "app/store/GlobalStore" +import { NormalizedDocument } from "app/utils/normalizeUploadedDocument" import * as Yup from "yup" export const getCurrentValidationSchema = (_injectedStep?: keyof SubmitArtworkStackNavigation) => { @@ -39,11 +40,17 @@ export const getCurrentValidationSchema = (_injectedStep?: keyof SubmitArtworkSt return frameInformationSchema case "Condition": return conditionSchema + case "AdditionalDocuments": + return additionalDocumentsSchema default: return Yup.object() } } +const additionalDocumentsSchema = Yup.object().shape({ + additionalDocuments: Yup.array().min(1), +}) + const conditionSchema = Yup.object().shape({ condition: Yup.string() .oneOf( @@ -166,6 +173,9 @@ export interface SubmissionModel { condition: ArtworkConditionEnumType | null | undefined conditionDescription: string | null | undefined } + + externalId: string | null | undefined + additionalDocuments: NormalizedDocument[] } export const submissionModelInitialValues: SubmissionModel = { @@ -220,4 +230,7 @@ export const submissionModelInitialValues: SubmissionModel = { condition: null, conditionDescription: null, }, + + externalId: null, + additionalDocuments: [], } diff --git a/src/app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/addAssetToConsignment.ts b/src/app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/addAssetToConsignment.ts index c5f56771e9a..65946773bf2 100644 --- a/src/app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/addAssetToConsignment.ts +++ b/src/app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/addAssetToConsignment.ts @@ -21,7 +21,7 @@ export const addAssetToConsignment = (input: AddAssetToConsignmentSubmissionInpu variables: { input: { ...input, - clientMutationId: Math.random().toString(8), + clientMutationId: __TEST__ ? "random-client-mutation-id" : Math.random().toString(8), }, }, onError: reject, diff --git a/src/app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/uploadDocumentToS3.ts b/src/app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/uploadDocumentToS3.ts new file mode 100644 index 00000000000..cf8204bf4cb --- /dev/null +++ b/src/app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/uploadDocumentToS3.ts @@ -0,0 +1,52 @@ +import { + getConvectionGeminiKey, + getGeminiCredentialsForEnvironment, + uploadFileToS3, +} from "app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/uploadFileToS3" +import { NormalizedDocument } from "app/utils/normalizeUploadedDocument" +import { isDocument } from "app/utils/showDocumentsAndPhotosActionSheet" + +// ** +// This function is used to upload documents to S3 **regardless of their type** +// It is used in the following scenarios: +// - When the user selects a document from the document picker +// - When the user selects a document/photo from the photos gallery +// ** +export const uploadDocument = async ({ + document, + updateProgress, + acl = "private", +}: { + document: NormalizedDocument + updateProgress?: (progress: number) => void + acl?: string +}) => { + if (!document.item) { + throw new Error("No document provided") + } + + try { + const convectionKey = await getConvectionGeminiKey() + + if (!convectionKey) return + + // Get S3 Credentials from Gemini + const assetCredentials = await getGeminiCredentialsForEnvironment({ + acl: acl, + name: convectionKey, + }) + + // Upload file to S3 + const res = await uploadFileToS3({ + filePath: isDocument(document.item) ? document.item.uri : document.item.path, + acl, + assetCredentials, + updateProgress, + file: document, + }) + + return { ...res, bucket: assetCredentials.policyDocument.conditions.bucket } + } catch (error) { + console.error(error) + } +} diff --git a/src/app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/uploadFileToS3.ts b/src/app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/uploadFileToS3.ts index 2534478d90a..fc9bdcef390 100644 --- a/src/app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/uploadFileToS3.ts +++ b/src/app/Scenes/SellWithArtsy/SubmitArtwork/UploadPhotos/utils/uploadFileToS3.ts @@ -1,3 +1,6 @@ +import { NormalizedDocument } from "app/utils/normalizeUploadedDocument" +import { isImage } from "app/utils/showDocumentsAndPhotosActionSheet" +import { DocumentPickerResponse } from "react-native-document-picker" import { AssetCredentials } from "./gemini/getGeminiCredentialsForEnvironment" export { getGeminiCredentialsForEnvironment } from "./gemini/getGeminiCredentialsForEnvironment" @@ -20,6 +23,7 @@ interface Props { assetCredentials: AssetCredentials updateProgress?: (progress: number) => void filename?: string + file?: NormalizedDocument | null } export const uploadFileToS3 = ({ filePath, @@ -27,6 +31,7 @@ export const uploadFileToS3 = ({ assetCredentials, updateProgress, filename, + file, }: Props) => new Promise((resolve, reject) => { const formData = new FormData() @@ -34,14 +39,32 @@ export const uploadFileToS3 = ({ const bucket = assetCredentials.policyDocument.conditions.bucket const uploadURL = `https://${bucket}.s3.amazonaws.com` + const isImageType = + // File is explicitly an image + (file?.item && isImage(file?.item)) || + // File is an image because no document is available + // TODO: Replace all usages of uploadFileToS3 to use file then remove this + !file?.item + + const contentType = isImageType ? "image/jpg" : (file.item as DocumentPickerResponse).type + + const name = filename || file?.name + const key = `${geminiKey}+/${name}` const data = { acl, - "Content-Type": "image/jpg", - key: geminiKey + "/${filename}", // NOTE: This form (which _looks_ like ES6 interpolation) is required by AWS + "Content-Type": contentType, + key, AWSAccessKeyId: assetCredentials.credentials, success_action_status: assetCredentials.policyDocument.conditions.successActionStatus, policy: assetCredentials.policyEncoded, signature: assetCredentials.signature, + file: isImageType + ? { + uri: filePath, + type: "image/jpeg", + name: filename ?? "photo.jpg", + } + : file?.item, } for (const key in data) { @@ -52,12 +75,6 @@ export const uploadFileToS3 = ({ } } - formData.append("file", { - uri: filePath, - type: "image/jpeg", - name: filename ?? "photo.jpg", - }) - // Fetch didn't seem to work, so I had to move to a lower // level abstraction. Note that this request will fail if you are using a debugger. // @@ -69,10 +86,8 @@ export const uploadFileToS3 = ({ e.target.status.toString() === assetCredentials.policyDocument.conditions.successActionStatus ) { - // e.g. https://artsy-media-uploads.s3.amazonaws.com/A3tfuXp0t5OuUKv07XaBOw%2F%24%7Bfilename%7D - const url = e.target.responseHeaders.Location resolve({ - key: url.split("/").pop().replace("%2F", "/"), + key, }) } else { reject(new Error("S3 upload failed")) @@ -92,4 +107,11 @@ export const uploadFileToS3 = ({ request.setRequestHeader("Content-type", "multipart/form-data") request.send(formData) + + if (file?.item) { + file.abortUploading = () => { + request.abort() + reject(new Error("File upload aborted")) + } + } }) diff --git a/src/app/utils/normalizeUploadedDocument.ts b/src/app/utils/normalizeUploadedDocument.ts new file mode 100644 index 00000000000..7f1171f93e3 --- /dev/null +++ b/src/app/utils/normalizeUploadedDocument.ts @@ -0,0 +1,46 @@ +import { isDocument } from "app/utils/showDocumentsAndPhotosActionSheet" +import { DocumentPickerResponse } from "react-native-document-picker" +import { Image as RNPickerImage } from "react-native-image-crop-picker" +import { v4 as uuid } from "uuid" + +export type NormalizedDocument = { + abortUploading: (() => void) | undefined | null + assetId: string | undefined | null + bucket?: string | null | undefined + errorMessage: string | undefined | null + externalUrl: string | undefined | null + geminiToken: string | undefined | null + id: string + item: RNPickerImage | DocumentPickerResponse | null + loading: boolean + name: string | undefined | null + progress: number | undefined | null + removed: boolean + size: string | undefined | null + sourceKey?: string | null | undefined +} + +export function normalizeUploadedDocument( + document: RNPickerImage | DocumentPickerResponse, + errorMessage?: string, + externalUrl?: string +): NormalizedDocument { + const name = isDocument(document) + ? document.name + : document.filename || document.path.replace(/^.*[\\/]/, "") + + return { + id: uuid(), + assetId: undefined, + externalUrl, + item: document, + name: name, + size: document.size?.toString(), + geminiToken: undefined, + abortUploading: undefined, + progress: undefined, + removed: false, + loading: false, + errorMessage, + } +} diff --git a/src/app/utils/showDocumentsAndPhotosActionSheet.ts b/src/app/utils/showDocumentsAndPhotosActionSheet.ts index 074a4a9d96f..aa080fb8619 100644 --- a/src/app/utils/showDocumentsAndPhotosActionSheet.ts +++ b/src/app/utils/showDocumentsAndPhotosActionSheet.ts @@ -30,7 +30,7 @@ export async function showDocumentsAndPhotosActionSheet( return new Promise((resolve, reject) => { showActionSheet( { - options: ["Photo Library", "Take Photo", "Documents", "Cancel"], + options: ["Documents", "Photo Library", "Take Photo", "Cancel"], cancelButtonIndex: 3, useModal, }, @@ -38,8 +38,18 @@ export async function showDocumentsAndPhotosActionSheet( let photos = null try { if (buttonIndex === 0) { - photos = await requestPhotos(allowMultiple) - resolve(photos) + const results = await RNDocumentPicker.pick({ + mode: "import", + type: [ + RNDocumentPicker.types.images, + RNDocumentPicker.types.pdf, + RNDocumentPicker.types.docx, + RNDocumentPicker.types.doc, + ], + allowMultiSelection: true, + }) + + resolve(results) } if (buttonIndex === 1) { if (Platform.OS === "android") { @@ -54,19 +64,10 @@ export async function showDocumentsAndPhotosActionSheet( photos = [photo] resolve(photos) } - if (buttonIndex === 2) { - const results = await RNDocumentPicker.pick({ - mode: "import", - type: [ - RNDocumentPicker.types.images, - RNDocumentPicker.types.pdf, - RNDocumentPicker.types.docx, - RNDocumentPicker.types.doc, - ], - allowMultiSelection: true, - }) - resolve(results) + if (buttonIndex === 2) { + photos = await requestPhotos(allowMultiple) + resolve(photos) } } catch (error) { reject(error) diff --git a/src/setupJest.tsx b/src/setupJest.tsx index 7c446dd092b..90ae9f251c2 100644 --- a/src/setupJest.tsx +++ b/src/setupJest.tsx @@ -691,3 +691,8 @@ jest.mock("@react-native-community/geolocation", () => ({ setRNConfiguration: jest.fn(), stopObserving: jest.fn(), })) + +jest.mock("react-native-document-picker", () => ({ + default: jest.fn(), + pick: jest.fn(), +})) diff --git a/yarn.lock b/yarn.lock index 13729064f4a..b5d46312db4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4781,6 +4781,11 @@ resolved "https://registry.yarnpkg.com/@types/urijs/-/urijs-1.19.19.tgz#2789369799907fc11e2bc6e3a00f6478c2281b95" integrity sha512-FDJNkyhmKLw7uEvTxx5tSXfPeQpO0iy73Ry+PmYZJvQy0QIWX8a7kJ4kLWRf+EbTPJEPDSgPXHaM7pzr5lmvCg== +"@types/uuid@10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d" + integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ== + "@types/yargs-parser@*": version "13.1.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-13.1.0.tgz#c563aa192f39350a1d18da36c5a8da382bbd8228" @@ -14037,16 +14042,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -14160,7 +14156,7 @@ stringify-entities@^3.1.0: character-entities-legacy "^1.0.0" xtend "^4.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -14174,13 +14170,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2" @@ -14943,6 +14932,11 @@ utrie@^1.0.2: dependencies: base64-arraybuffer "^1.0.2" +uuid@10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" + integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + uuid@^3.3.2: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" @@ -15539,7 +15533,7 @@ word-wrap@^1.2.3, word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f" integrity sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -15557,15 +15551,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"