From cd5f8af9e423fd25c91ad9e2198949c08bb36146 Mon Sep 17 00:00:00 2001 From: Patrick Fic <> Date: Tue, 17 Nov 2020 13:39:31 -0800 Subject: [PATCH] First prototype of image upload working. IO-397 IO-398 --- .../screen-camera/screen-camera.component.jsx | 3 +- .../screen-media-cache.component.jsx | 19 +- env.js | 39 ++++ graphql/bodyshop.queries.js | 2 + graphql/documents.queries.js | 46 +++++ package-lock.json | 18 ++ package.json | 3 + redux/app/app.actions.js | 2 +- redux/app/app.reducer.js | 2 +- redux/app/app.types.js | 2 +- redux/photos/photos.sagas.js | 58 +++++- redux/user/user.reducer.js | 2 - util/CleanAxios.js | 27 +++ util/document-upload.utility.js | 184 ++++++++++++++++++ yarn.lock | 7 +- 15 files changed, 395 insertions(+), 19 deletions(-) create mode 100644 env.js create mode 100644 graphql/documents.queries.js create mode 100644 util/CleanAxios.js create mode 100644 util/document-upload.utility.js diff --git a/components/screen-camera/screen-camera.component.jsx b/components/screen-camera/screen-camera.component.jsx index 40d3634..e562d88 100644 --- a/components/screen-camera/screen-camera.component.jsx +++ b/components/screen-camera/screen-camera.component.jsx @@ -47,7 +47,6 @@ export function ScreenCamera({ cameraJobId, cameraJob, addPhoto }) { }; const handleShortCapture = async () => { - console.log("Taking the picture!"); if (cameraRef.current) { const options = { //quality: 0.5, @@ -109,7 +108,7 @@ export function ScreenCamera({ cameraJobId, cameraJob, addPhoto }) { return No access to camera; } - const { hasCameraPermission, flashMode, cameraType, capturing } = state; + const { flashMode, cameraType, capturing } = state; return ( diff --git a/components/screen-media-cache/screen-media-cache.component.jsx b/components/screen-media-cache/screen-media-cache.component.jsx index 3d2f827..58e2884 100644 --- a/components/screen-media-cache/screen-media-cache.component.jsx +++ b/components/screen-media-cache/screen-media-cache.component.jsx @@ -3,37 +3,44 @@ import React from "react"; import { FlatList, SafeAreaView, Text } from "react-native"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; -import { removeAllPhotos } from "../../redux/photos/photos.actions"; +import { + removeAllPhotos, + uploadAllPhotos, +} from "../../redux/photos/photos.actions"; import { selectPhotos } from "../../redux/photos/photos.selectors"; const mapStateToProps = createStructuredSelector({ photos: selectPhotos, }); const mapDispatchToProps = (dispatch) => ({ - //setUserLanguage: language => dispatch(setUserLanguage(language)) removeAllPhotos: () => dispatch(removeAllPhotos()), + uploadAllphotos: () => dispatch(uploadAllPhotos()), }); -export function ScreenMediaCache({ photos, removeAllPhotos }) { +export function ScreenMediaCache({ photos, removeAllPhotos, uploadAllphotos }) { return ( This is the media cache screen. + {photos.length} item.id} renderItem={(object) => ( {object.item.uri} - + {!object.item.video && ( + + )} )} - //ItemSeparatorComponent={FlatListItemSeparator} /> ); diff --git a/env.js b/env.js new file mode 100644 index 0000000..1f9ff48 --- /dev/null +++ b/env.js @@ -0,0 +1,39 @@ +import Constants from "expo-constants"; + +export const prodUrl = "https://someapp.herokuapp.com"; + +const ENV = { + dev: { + REACT_APP_CLOUDINARY_ENDPOINT: + "https://api.cloudinary.com/v1_1/bodyshop/image", + REACT_APP_CLOUDINARY_IMAGE_ENDPOINT: + "https://res.cloudinary.com/bodyshop/image/upload", + REACT_APP_CLOUDINARY_API_KEY: "473322739956866", + REACT_APP_CLOUDINARY_THUMB_TRANSFORMATIONS: "h_200,w_200,c_thumb", + }, + staging: { + REACT_APP_CLOUDINARY_ENDPOINT: + "https://api.cloudinary.com/v1_1/bodyshop/image", + REACT_APP_CLOUDINARY_IMAGE_ENDPOINT: + "https://res.cloudinary.com/bodyshop/image/upload", + REACT_APP_CLOUDINARY_API_KEY: "473322739956866", + REACT_APP_CLOUDINARY_THUMB_TRANSFORMATIONS: "h_200,w_200,c_thumb", + }, + prod: { + REACT_APP_CLOUDINARY_ENDPOINT: + "https://api.cloudinary.com/v1_1/bodyshop/image", + REACT_APP_CLOUDINARY_IMAGE_ENDPOINT: + "https://res.cloudinary.com/bodyshop/image/upload", + REACT_APP_CLOUDINARY_API_KEY: "473322739956866", + REACT_APP_CLOUDINARY_THUMB_TRANSFORMATIONS: "h_200,w_200,c_thumb", + }, +}; + +function getEnvVars(env = "") { + if (env === null || env === undefined || env === "") return ENV.dev; + if (env.indexOf("dev") !== -1) return ENV.dev; + if (env.indexOf("staging") !== -1) return ENV.staging; + if (env.indexOf("prod") !== -1) return ENV.prod; +} + +export default getEnvVars(Constants.manifest.releaseChannel); diff --git a/graphql/bodyshop.queries.js b/graphql/bodyshop.queries.js index 8f332f1..eb3b146 100644 --- a/graphql/bodyshop.queries.js +++ b/graphql/bodyshop.queries.js @@ -3,6 +3,8 @@ import gql from "graphql-tag"; export const QUERY_BODYSHOP = gql` query QUERY_BODYSHOP { bodyshops(where: { associations: { active: { _eq: true } } }) { + id + md_ro_statuses md_order_statuses shopname diff --git a/graphql/documents.queries.js b/graphql/documents.queries.js new file mode 100644 index 0000000..361eb11 --- /dev/null +++ b/graphql/documents.queries.js @@ -0,0 +1,46 @@ +import gql from "graphql-tag"; + +export const GET_DOCUMENTS_BY_JOB = gql` + query GET_DOCUMENTS_BY_JOB($jobId: uuid!) { + documents( + where: { jobid: { _eq: $jobId } } + order_by: { updated_at: desc } + ) { + id + name + key + type + bill { + id + invoice_number + date + vendor { + id + name + } + } + } + } +`; + +export const INSERT_NEW_DOCUMENT = gql` + mutation INSERT_NEW_DOCUMENT($docInput: [documents_insert_input!]!) { + insert_documents(objects: $docInput) { + returning { + id + name + key + } + } + } +`; + +export const DELETE_DOCUMENT = gql` + mutation DELETE_DOCUMENT($id: uuid) { + delete_documents(where: { id: { _eq: $id } }) { + returning { + id + } + } + } +`; diff --git a/package-lock.json b/package-lock.json index f820008..17165f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2848,6 +2848,14 @@ } } }, + "axios": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.0.tgz", + "integrity": "sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, "babel-plugin-dynamic-import-node": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", @@ -4425,6 +4433,11 @@ "@firebase/util": "0.2.40" } }, + "follow-redirects": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz", + "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==" + }, "fontfaceobserver": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.1.0.tgz", @@ -7945,6 +7958,11 @@ } } }, + "react-native-image-base64": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/react-native-image-base64/-/react-native-image-base64-0.1.4.tgz", + "integrity": "sha512-WLdzwHdXFRLS9VStG1CG46+t+fcInjVWxKd1+AATElBhaAS3zwDHz7mYIZS1OX4VMuNClwl5G8dowuqUJ9aMGQ==" + }, "react-native-indicators": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-native-indicators/-/react-native-indicators-0.17.0.tgz", diff --git a/package.json b/package.json index fa388fd..67e11e3 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@react-navigation/drawer": "^5.10.7", "@react-navigation/native": "^5.8.7", "@react-navigation/stack": "^5.12.4", + "axios": "^0.21.0", "dinero.js": "^1.8.1", "expo": "^39.0.4", "expo-camera": "~9.0.0", @@ -26,6 +27,7 @@ "expo-permissions": "~9.3.0", "expo-sqlite": "~8.4.0", "expo-status-bar": "~1.0.2", + "expo-video-thumbnails": "~4.3.0", "firebase": "7.9.0", "formik": "^2.2.3", "graphql": "^15.4.0", @@ -37,6 +39,7 @@ "react-native": "https://github.com/expo/react-native/archive/sdk-39.0.3.tar.gz", "react-native-easy-grid": "^0.2.2", "react-native-gesture-handler": "~1.7.0", + "react-native-image-base64": "^0.1.4", "react-native-indicators": "^0.17.0", "react-native-reanimated": "~1.13.0", "react-native-safe-area-context": "3.1.4", diff --git a/redux/app/app.actions.js b/redux/app/app.actions.js index 7c906be..b9af5db 100644 --- a/redux/app/app.actions.js +++ b/redux/app/app.actions.js @@ -16,7 +16,7 @@ export const documentUploadStart = (jobId) => ({ }); export const documentUploadSuccess = (jobId) => ({ - type: AppActionTypes.DOCUMNET_UPLOAD_SUCCESS, + type: AppActionTypes.DOCUMENT_UPLOAD_SUCCESS, payload: jobId, }); diff --git a/redux/app/app.reducer.js b/redux/app/app.reducer.js index 0ed3ae3..c22f6e9 100644 --- a/redux/app/app.reducer.js +++ b/redux/app/app.reducer.js @@ -25,7 +25,7 @@ const appReducer = (state = INITIAL_STATE, action) => { documentUploadError: null, documentUploadInProgress: action.payload, }; - case AppActionTypes.DOCUMNET_UPLOAD_SUCCESS: + case AppActionTypes.DOCUMENT_UPLOAD_SUCCESS: return { ...state, documentUploadError: null, diff --git a/redux/app/app.types.js b/redux/app/app.types.js index 2cf7f79..d706bb7 100644 --- a/redux/app/app.types.js +++ b/redux/app/app.types.js @@ -2,7 +2,7 @@ const AppActionTypes = { SET_CAMERA_JOB_ID: "SET_CAMERA_JOB_ID", SET_CAMERA_JOB: "SET_CAMERA_JOB", DOCUMENT_UPLOAD_START: "DOCUMENT_UPLOAD_START", - DOCUMNET_UPLOAD_SUCCESS: "DOCUMNET_UPLOAD_SUCCESS", + DOCUMENT_UPLOAD_SUCCESS: "DOCUMENT_UPLOAD_SUCCESS", DOCUMENT_UPLOAD_FAILURE: "DOCUMENT_UPLOAD_FAILURE", }; export default AppActionTypes; diff --git a/redux/photos/photos.sagas.js b/redux/photos/photos.sagas.js index e6e6a5d..6a53ff3 100644 --- a/redux/photos/photos.sagas.js +++ b/redux/photos/photos.sagas.js @@ -1,6 +1,7 @@ -import { all, call, takeLatest } from "redux-saga/effects"; -import PhotosActionTypes from "./photos.types"; import * as FileSystem from "expo-file-system"; +import { all, call, select, takeLatest } from "redux-saga/effects"; +import { handleUpload } from "../../util/document-upload.utility"; +import PhotosActionTypes from "./photos.types"; export function* onRemoveAllPhotos() { yield takeLatest(PhotosActionTypes.REMOVE_ALL_PHOTOS, removeAllPhotosAction); @@ -20,11 +21,58 @@ export function* removeAllPhotosAction() { console.log("All photos deleted."); } catch (error) { console.log("Saga Error: onRemoveAllPhotos", error); - //yield put(signInFailure(error)); - //logImEXEvent("redux_sign_in_failure", { user: email, error }); + } +} + +export function* onUploadAllPhotos() { + yield takeLatest( + PhotosActionTypes.UPLOAD_ALL_PHOTOS_START, + uploadAllPhotosAction + ); +} +export function* uploadAllPhotosAction() { + try { + const photos = yield select((state) => state.photos.photos); + const bodyshop = yield select((state) => state.user.bodyshop); + const user = yield select((state) => state.user); + const actions = []; + photos.forEach(async (p) => + actions.push( + handleUpload( + { + file: await (await fetch(p.uri)).blob(), + + onError: (props) => { + console.log("Error Callback", props); + }, + onProgress: (props) => { + console.log("Progress Calback", props); + }, + onSuccess: (props) => { + console.log("Success Calback", props); + }, + }, + { + bodyshop: bodyshop, + jobId: p.jobId, + uploaded_by: user.currentUser.email, + callback: (props) => { + console.log("Context Callback", props); + }, + photo: { + ...p, + name: p.uri.substring(p.uri.lastIndexOf("/") + 1), + }, + } + ) + ) + ); + yield Promise.all(actions); + } catch (error) { + console.log("Saga Error: onRemoveAllPhotos", error); } } export function* photosSagas() { - yield all([call(onRemoveAllPhotos)]); + yield all([call(onRemoveAllPhotos), call(onUploadAllPhotos)]); } diff --git a/redux/user/user.reducer.js b/redux/user/user.reducer.js index 69eab5f..a5cf187 100644 --- a/redux/user/user.reducer.js +++ b/redux/user/user.reducer.js @@ -5,10 +5,8 @@ const INITIAL_STATE = { authorized: null, }, bodyshop: null, - fingerprint: null, signingIn: false, error: null, - conflict: false, }; const userReducer = (state = INITIAL_STATE, action) => { diff --git a/util/CleanAxios.js b/util/CleanAxios.js new file mode 100644 index 0000000..de36f24 --- /dev/null +++ b/util/CleanAxios.js @@ -0,0 +1,27 @@ +import axios from "axios"; +import { auth } from "../firebase/firebase.utils"; + +if (process.env.NODE_ENV === "production") { + axios.defaults.baseURL = + process.env.REACT_APP_AXIOS_BASE_API_URL || "https://api.imex.online/"; +} + +export const axiosAuthInterceptorId = axios.interceptors.request.use( + async (config) => { + if (!config.headers.Authorization) { + const token = + auth.currentUser && (await auth.currentUser.getIdToken(true)); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + } + + return config; + }, + (error) => Promise.reject(error) +); + +const cleanAxios = axios.create(); +cleanAxios.interceptors.request.eject(axiosAuthInterceptorId); + +export default cleanAxios; diff --git a/util/document-upload.utility.js b/util/document-upload.utility.js new file mode 100644 index 0000000..75bfaa0 --- /dev/null +++ b/util/document-upload.utility.js @@ -0,0 +1,184 @@ +import axios from "axios"; +import env from "../env"; +import { client } from "../graphql/client"; +import { INSERT_NEW_DOCUMENT } from "../graphql/documents.queries"; +import { axiosAuthInterceptorId } from "./CleanAxios"; +//Context: currentUserEmail, bodyshop, jobid, invoiceid + +//Required to prevent headers from getting set and rejected from Cloudinary. +var cleanAxios = axios.create(); +cleanAxios.interceptors.request.eject(axiosAuthInterceptorId); + +export const handleUpload = (ev, context) => { + const { onError, onSuccess, onProgress } = ev; + const { bodyshop, jobId } = context; + + let key = `${bodyshop.id}/${jobId}/${ev.file.data.name.replace( + /\.[^/.]+$/, + "" + )}`; + + uploadToCloudinary( + key, + ev.file.type, + ev.file, + onError, + onSuccess, + onProgress, + context + ); +}; + +export const uploadToCloudinary = async ( + key, + fileType, + file, + onError, + onSuccess, + onProgress, + context +) => { + const { + bodyshop, + jobId, + billId, + uploaded_by, + callback, + tagsArray, + photo, + } = context; + + //Set variables for getting the signed URL. + let timestamp = Math.floor(Date.now() / 1000); + let public_id = key; + let tags = `${bodyshop.textid},${ + tagsArray ? tagsArray.map((tag) => `${tag},`) : "" + }`; + // let eager = process.env.REACT_APP_CLOUDINARY_THUMB_TRANSFORMATIONS; + + //Get the signed url. + let signedURLResponse; + try { + signedURLResponse = await axios.post( + "https://d2ea3cff6920.ngrok.io/media/sign", + { + public_id: public_id, + tags: tags, + timestamp: timestamp, + upload_preset: "incoming_upload", + } + ); + } catch (error) { + console.log("ERROR GETTING SIGNED URL", error); + return; + } + + if (signedURLResponse.status !== 200) { + console.log("Error Getting Signed URL", signedURLResponse.statusText); + if (!!onError) onError(signedURLResponse.statusText); + // notification["error"]({ + // message: i18n.t("documents.errors.getpresignurl", { + // message: signedURLResponse.statusText, + // }), + // }); + return; + } + + //Build request to end to cloudinary. + var signature = signedURLResponse.data; + var options = { + headers: { "X-Requested-With": "XMLHttpRequest" }, + onUploadProgress: (e) => { + if (!!onProgress) onProgress({ percent: (e.loaded / e.total) * 100 }); + }, + }; + const formData = new FormData(); + + console.log("Sending!", { + uri: photo.uri, + type: fileType, + name: file.data.name, + }); + formData.append("file", { + uri: photo.uri, + type: fileType, + name: file.data.name, + }); + console.log("Applying lower quality transforms."); + formData.append("upload_preset", "incoming_upload"); + + formData.append("api_key", env.REACT_APP_CLOUDINARY_API_KEY); + formData.append("public_id", public_id); + formData.append("tags", tags); + formData.append("timestamp", timestamp); + formData.append("signature", signature); + + //Upload request to Cloudinary + let cloudinaryUploadResponse; + try { + cloudinaryUploadResponse = await cleanAxios.post( + `${env.REACT_APP_CLOUDINARY_ENDPOINT}/upload`, + formData, + { + ...options, + } + ); + console.log("Cloudinary Upload Response", cloudinaryUploadResponse.data); + } catch (error) { + console.log("CLOUDINARY error", error, cloudinaryUploadResponse); + } + + if (cloudinaryUploadResponse.status !== 200) { + console.log( + "Error uploading to cloudinary.", + cloudinaryUploadResponse.statusText, + cloudinaryUploadResponse + ); + if (!!onError) onError(cloudinaryUploadResponse.statusText); + // notification["error"]({ + // message: i18n.t("documents.errors.insert", { + // message: cloudinaryUploadResponse.statusText, + // }), + // }); + return; + } + + //Insert the document with the matching key. + const documentInsert = await client.mutate({ + mutation: INSERT_NEW_DOCUMENT, + variables: { + docInput: [ + { + jobid: jobId, + uploaded_by: uploaded_by, + key: key, + billid: billId, + type: fileType, + }, + ], + }, + }); + if (!documentInsert.errors) { + if (!!onSuccess) + onSuccess({ + uid: documentInsert.data.insert_documents.returning[0].id, + name: documentInsert.data.insert_documents.returning[0].name, + status: "done", + key: documentInsert.data.insert_documents.returning[0].key, + }); + // notification["success"]({ + // message: i18n.t("documents.successes.insert"), + // }); + if (callback) { + callback(); + } + } else { + if (!!onError) onError(JSON.stringify(documentInsert.errors)); + // notification["error"]({ + // message: i18n.t("documents.errors.insert", { + // message: JSON.stringify(JSON.stringify(documentInsert.errors)), + // }), + // }); + return; + } +}; diff --git a/yarn.lock b/yarn.lock index 29126ba..90de6f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3311,6 +3311,11 @@ expo-status-bar@~1.0.2: resolved "https://registry.yarnpkg.com/expo-status-bar/-/expo-status-bar-1.0.2.tgz#2441a77c56be31597898337b0d086981f2adefd8" integrity sha512-5313u744GcLzCadxIPXyTkYw77++UXv1dXCuhYDxDbtsEf93iMra7WSvzyE8a7mRQLIIPRuGnBOdrL/V1C7EOQ== +expo-video-thumbnails@~4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/expo-video-thumbnails/-/expo-video-thumbnails-4.3.0.tgz#8381a73616a3c36604d8375d5b634d525a7f85a8" + integrity sha512-qe4bvSlTP0FNGbkg99vCo/kijSKXqXEFlLM3e5phArdmmY0c7NNevQRWHHQHSguGaKp7HBZ7LX0CyNAjiH8SpA== + expo@^39.0.4: version "39.0.4" resolved "https://registry.yarnpkg.com/expo/-/expo-39.0.4.tgz#320b7453ac055fc37c64942d5ba442f4e2781993" @@ -5799,7 +5804,7 @@ react-native-drawer@2.5.1: prop-types "^15.5.8" tween-functions "^1.0.1" -react-native-easy-grid@0.2.2: +react-native-easy-grid@0.2.2, react-native-easy-grid@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/react-native-easy-grid/-/react-native-easy-grid-0.2.2.tgz#f0be33620be1ebe2d2295918eb58b0a27e8272ab" integrity sha512-MlYrNIldnEMKn6TVatQN1P64GoVlwGIuz+8ncdfJ0Wq/xtzUkQwlil8Uksyp7MhKfENE09MQnGNcba6Mx3oSAA==