diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index fbc7465bc023..f16b41538531 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -420,6 +420,9 @@ const ONYXKEYS = { /** Stores the route to open after changing app permission from settings */ LAST_ROUTE: 'lastRoute', + /** Stores the information if user loaded the Onyx state through Import feature */ + IS_USING_IMPORTED_STATE: 'isUsingImportedState', + /** Stores the information about the saved searches */ SAVED_SEARCHES: 'nvp_savedSearches', @@ -989,9 +992,9 @@ type OnyxValuesMapping = { [ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflowOnyx; [ONYXKEYS.IMPORTED_SPREADSHEET]: OnyxTypes.ImportedSpreadsheet; [ONYXKEYS.LAST_ROUTE]: string; + [ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean; [ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP]: boolean; }; - type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; type OnyxCollectionKey = keyof OnyxCollectionValuesMapping; diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 2f62e6a813b5..975ea6c548c0 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -22,12 +22,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type IconAsset from '@src/types/utils/IconAsset'; import launchCamera from './launchCamera/launchCamera'; -import type BaseAttachmentPickerProps from './types'; - -type AttachmentPickerProps = BaseAttachmentPickerProps & { - /** If this value is true, then we exclude Camera option. */ - shouldHideCameraOption?: boolean; -}; +import type AttachmentPickerProps from './types'; type Item = { /** The icon associated with the item. */ @@ -112,7 +107,13 @@ const getDataForUpload = (fileData: FileResponse): Promise => { * a callback. This is the ios/android implementation * opening a modal with attachment options */ -function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, shouldHideCameraOption = false, shouldValidateImage = true}: AttachmentPickerProps) { +function AttachmentPicker({ + type = CONST.ATTACHMENT_PICKER_TYPE.FILE, + children, + shouldHideCameraOption = false, + shouldHideGalleryOption = false, + shouldValidateImage = true, +}: AttachmentPickerProps) { const styles = useThemeStyles(); const [isVisible, setIsVisible] = useState(false); @@ -221,17 +222,19 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s const menuItemData: Item[] = useMemo(() => { const data: Item[] = [ - { - icon: Expensicons.Gallery, - textTranslationKey: 'attachmentPicker.chooseFromGallery', - pickAttachment: () => showImagePicker(launchImageLibrary), - }, { icon: Expensicons.Paperclip, textTranslationKey: 'attachmentPicker.chooseDocument', pickAttachment: showDocumentPicker, }, ]; + if (!shouldHideGalleryOption) { + data.unshift({ + icon: Expensicons.Gallery, + textTranslationKey: 'attachmentPicker.chooseFromGallery', + pickAttachment: () => showImagePicker(launchImageLibrary), + }); + } if (!shouldHideCameraOption) { data.unshift({ icon: Expensicons.Camera, @@ -241,7 +244,7 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s } return data; - }, [showDocumentPicker, showImagePicker, shouldHideCameraOption]); + }, [showDocumentPicker, shouldHideGalleryOption, shouldHideCameraOption, showImagePicker]); const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: menuItemData.length - 1, isActive: isVisible}); diff --git a/src/components/AttachmentPicker/types.ts b/src/components/AttachmentPicker/types.ts index 38e8e5c73032..ee9d39aabef3 100644 --- a/src/components/AttachmentPicker/types.ts +++ b/src/components/AttachmentPicker/types.ts @@ -43,6 +43,10 @@ type AttachmentPickerProps = { acceptedFileTypes?: Array>; + shouldHideCameraOption?: boolean; + + shouldHideGalleryOption?: boolean; + /** Whether to validate the image and show the alert or not. */ shouldValidateImage?: boolean; }; diff --git a/src/components/ImportOnyxState/BaseImportOnyxState.tsx b/src/components/ImportOnyxState/BaseImportOnyxState.tsx new file mode 100644 index 000000000000..216a6ddf76e4 --- /dev/null +++ b/src/components/ImportOnyxState/BaseImportOnyxState.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import type {FileObject} from '@components/AttachmentModal'; +import AttachmentPicker from '@components/AttachmentPicker'; +import DecisionModal from '@components/DecisionModal'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +function BaseImportOnyxState({ + onFileRead, + isErrorModalVisible, + setIsErrorModalVisible, +}: { + onFileRead: (file: FileObject) => void; + isErrorModalVisible: boolean; + setIsErrorModalVisible: (value: boolean) => void; +}) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {isSmallScreenWidth} = useResponsiveLayout(); + + return ( + <> + + {({openPicker}) => { + return ( + { + openPicker({ + onPicked: onFileRead, + }); + }} + /> + ); + }} + + setIsErrorModalVisible(false)} + secondOptionText={translate('common.ok')} + isVisible={isErrorModalVisible} + onClose={() => setIsErrorModalVisible(false)} + /> + + ); +} + +export default BaseImportOnyxState; diff --git a/src/components/ImportOnyxState/index.native.tsx b/src/components/ImportOnyxState/index.native.tsx new file mode 100644 index 000000000000..b07f47d3a5de --- /dev/null +++ b/src/components/ImportOnyxState/index.native.tsx @@ -0,0 +1,105 @@ +import React, {useState} from 'react'; +import RNFS from 'react-native-fs'; +import Onyx from 'react-native-onyx'; +import type {FileObject} from '@components/AttachmentModal'; +import {KEYS_TO_PRESERVE, setIsUsingImportedState} from '@libs/actions/App'; +import {setShouldForceOffline} from '@libs/actions/Network'; +import Navigation from '@libs/Navigation/Navigation'; +import type {OnyxValues} from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import BaseImportOnyxState from './BaseImportOnyxState'; +import type ImportOnyxStateProps from './types'; +import {cleanAndTransformState} from './utils'; + +const CHUNK_SIZE = 100; + +function readFileInChunks(fileUri: string, chunkSize = 1024 * 1024) { + const filePath = decodeURIComponent(fileUri.replace('file://', '')); + + return RNFS.exists(filePath) + .then((exists) => { + if (!exists) { + throw new Error('File does not exist'); + } + return RNFS.stat(filePath); + }) + .then((fileStats) => { + const fileSize = fileStats.size; + let fileContent = ''; + const promises = []; + + // Chunk the file into smaller parts to avoid memory issues + for (let i = 0; i < fileSize; i += chunkSize) { + promises.push(RNFS.read(filePath, chunkSize, i, 'utf8').then((chunk) => chunk)); + } + + // After all chunks have been read, join them together + return Promise.all(promises).then((chunks) => { + fileContent = chunks.join(''); + + return fileContent; + }); + }); +} + +function chunkArray(array: T[], size: number): T[][] { + const result = []; + for (let i = 0; i < array.length; i += size) { + result.push(array.slice(i, i + size)); + } + return result; +} + +function applyStateInChunks(state: OnyxValues) { + const entries = Object.entries(state); + const chunks = chunkArray(entries, CHUNK_SIZE); + + let promise = Promise.resolve(); + chunks.forEach((chunk) => { + const partialOnyxState = Object.fromEntries(chunk) as Partial; + promise = promise.then(() => Onyx.multiSet(partialOnyxState)); + }); + + return promise; +} + +export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { + const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + + const handleFileRead = (file: FileObject) => { + if (!file.uri) { + return; + } + + setIsLoading(true); + readFileInChunks(file.uri) + .then((fileContent) => { + const transformedState = cleanAndTransformState(fileContent); + setShouldForceOffline(true); + Onyx.clear(KEYS_TO_PRESERVE).then(() => { + applyStateInChunks(transformedState).then(() => { + setIsUsingImportedState(true); + Navigation.navigate(ROUTES.HOME); + }); + }); + }) + .catch(() => { + setIsErrorModalVisible(true); + }) + .finally(() => { + setIsLoading(false); + }); + + if (isLoading) { + setIsLoading(false); + } + }; + + return ( + + ); +} diff --git a/src/components/ImportOnyxState/index.tsx b/src/components/ImportOnyxState/index.tsx new file mode 100644 index 000000000000..8add2d9172fd --- /dev/null +++ b/src/components/ImportOnyxState/index.tsx @@ -0,0 +1,59 @@ +import React, {useState} from 'react'; +import Onyx from 'react-native-onyx'; +import type {FileObject} from '@components/AttachmentModal'; +import {KEYS_TO_PRESERVE, setIsUsingImportedState} from '@libs/actions/App'; +import {setShouldForceOffline} from '@libs/actions/Network'; +import Navigation from '@libs/Navigation/Navigation'; +import type {OnyxValues} from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import BaseImportOnyxState from './BaseImportOnyxState'; +import type ImportOnyxStateProps from './types'; +import {cleanAndTransformState} from './utils'; + +export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { + const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + + const handleFileRead = (file: FileObject) => { + if (!file.uri) { + return; + } + + setIsLoading(true); + const blob = new Blob([file as BlobPart]); + const response = new Response(blob); + + response + .text() + .then((text) => { + const fileContent = text; + const transformedState = cleanAndTransformState(fileContent); + setShouldForceOffline(true); + Onyx.clear(KEYS_TO_PRESERVE).then(() => { + Onyx.multiSet(transformedState) + .then(() => { + setIsUsingImportedState(true); + Navigation.navigate(ROUTES.HOME); + }) + .finally(() => { + setIsLoading(false); + }); + }); + }) + .catch(() => { + setIsErrorModalVisible(true); + setIsLoading(false); + }); + + if (isLoading) { + setIsLoading(false); + } + }; + + return ( + + ); +} diff --git a/src/components/ImportOnyxState/types.ts b/src/components/ImportOnyxState/types.ts new file mode 100644 index 000000000000..8e504c493529 --- /dev/null +++ b/src/components/ImportOnyxState/types.ts @@ -0,0 +1,6 @@ +type ImportOnyxStateProps = { + isLoading: boolean; + setIsLoading: (isLoading: boolean) => void; +}; + +export default ImportOnyxStateProps; diff --git a/src/components/ImportOnyxState/utils.ts b/src/components/ImportOnyxState/utils.ts new file mode 100644 index 000000000000..a5f24fa80714 --- /dev/null +++ b/src/components/ImportOnyxState/utils.ts @@ -0,0 +1,53 @@ +import cloneDeep from 'lodash/cloneDeep'; +import type {UnknownRecord} from 'type-fest'; +import ONYXKEYS from '@src/ONYXKEYS'; + +// List of Onyx keys from the .txt file we want to keep for the local override +const keysToOmit = [ONYXKEYS.ACTIVE_CLIENTS, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.NETWORK, ONYXKEYS.CREDENTIALS, ONYXKEYS.SESSION, ONYXKEYS.PREFERRED_THEME]; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && !Array.isArray(value) && value !== null; +} + +function transformNumericKeysToArray(data: UnknownRecord): UnknownRecord | unknown[] { + const dataCopy = cloneDeep(data); + if (!isRecord(dataCopy)) { + return Array.isArray(dataCopy) ? (dataCopy as UnknownRecord[]).map(transformNumericKeysToArray) : (dataCopy as UnknownRecord); + } + + const keys = Object.keys(dataCopy); + + if (keys.length === 0) { + return dataCopy; + } + const allKeysAreNumeric = keys.every((key) => !Number.isNaN(Number(key))); + const keysAreSequential = keys.every((key, index) => parseInt(key, 10) === index); + if (allKeysAreNumeric && keysAreSequential) { + return keys.map((key) => transformNumericKeysToArray(dataCopy[key] as UnknownRecord)); + } + + for (const key in dataCopy) { + if (key in dataCopy) { + dataCopy[key] = transformNumericKeysToArray(dataCopy[key] as UnknownRecord); + } + } + + return dataCopy; +} + +function cleanAndTransformState(state: string): T { + const parsedState = JSON.parse(state) as UnknownRecord; + + Object.keys(parsedState).forEach((key) => { + const shouldOmit = keysToOmit.some((onyxKey) => key.startsWith(onyxKey)); + + if (shouldOmit) { + delete parsedState[key]; + } + }); + + const transformedState = transformNumericKeysToArray(parsedState) as T; + return transformedState; +} + +export {transformNumericKeysToArray, cleanAndTransformState}; diff --git a/src/components/ImportedStateIndicator.tsx b/src/components/ImportedStateIndicator.tsx new file mode 100644 index 000000000000..029c0f51cd33 --- /dev/null +++ b/src/components/ImportedStateIndicator.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {clearOnyxAndResetApp} from '@libs/actions/App'; +import ONYXKEYS from '@src/ONYXKEYS'; +import Button from './Button'; + +function ImportedStateIndicator() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [isUsingImportedState] = useOnyx(ONYXKEYS.IS_USING_IMPORTED_STATE); + + if (!isUsingImportedState) { + return null; + } + + return ( + +