Skip to content

Commit

Permalink
Merge pull request #49255 from callstack-internal/feat/import-onyx-state
Browse files Browse the repository at this point in the history
feat: Import onyx state
  • Loading branch information
rlinoz authored Oct 3, 2024
2 parents e1d1d57 + 2790ba4 commit 9e72084
Show file tree
Hide file tree
Showing 20 changed files with 470 additions and 89 deletions.
5 changes: 4 additions & 1 deletion src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down Expand Up @@ -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;
Expand Down
29 changes: 16 additions & 13 deletions src/components/AttachmentPicker/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -112,7 +107,13 @@ const getDataForUpload = (fileData: FileResponse): Promise<FileObject> => {
* 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);

Expand Down Expand Up @@ -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,
Expand All @@ -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});

Expand Down
4 changes: 4 additions & 0 deletions src/components/AttachmentPicker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ type AttachmentPickerProps = {

acceptedFileTypes?: Array<ValueOf<typeof CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS>>;

shouldHideCameraOption?: boolean;

shouldHideGalleryOption?: boolean;

/** Whether to validate the image and show the alert or not. */
shouldValidateImage?: boolean;
};
Expand Down
59 changes: 59 additions & 0 deletions src/components/ImportOnyxState/BaseImportOnyxState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<AttachmentPicker
acceptedFileTypes={['text']}
shouldHideCameraOption
shouldHideGalleryOption
>
{({openPicker}) => {
return (
<MenuItem
icon={Expensicons.Upload}
title={translate('initialSettingsPage.troubleshoot.importOnyxState')}
wrapperStyle={[styles.sectionMenuItemTopDescription]}
onPress={() => {
openPicker({
onPicked: onFileRead,
});
}}
/>
);
}}
</AttachmentPicker>
<DecisionModal
title={translate('initialSettingsPage.troubleshoot.invalidFile')}
prompt={translate('initialSettingsPage.troubleshoot.invalidFileDescription')}
isSmallScreenWidth={isSmallScreenWidth}
onSecondOptionSubmit={() => setIsErrorModalVisible(false)}
secondOptionText={translate('common.ok')}
isVisible={isErrorModalVisible}
onClose={() => setIsErrorModalVisible(false)}
/>
</>
);
}

export default BaseImportOnyxState;
105 changes: 105 additions & 0 deletions src/components/ImportOnyxState/index.native.tsx
Original file line number Diff line number Diff line change
@@ -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<T>(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<OnyxValues>;
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<OnyxValues>(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 (
<BaseImportOnyxState
onFileRead={handleFileRead}
isErrorModalVisible={isErrorModalVisible}
setIsErrorModalVisible={setIsErrorModalVisible}
/>
);
}
59 changes: 59 additions & 0 deletions src/components/ImportOnyxState/index.tsx
Original file line number Diff line number Diff line change
@@ -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<OnyxValues>(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 (
<BaseImportOnyxState
onFileRead={handleFileRead}
isErrorModalVisible={isErrorModalVisible}
setIsErrorModalVisible={setIsErrorModalVisible}
/>
);
}
6 changes: 6 additions & 0 deletions src/components/ImportOnyxState/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type ImportOnyxStateProps = {
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
};

export default ImportOnyxStateProps;
53 changes: 53 additions & 0 deletions src/components/ImportOnyxState/utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<T>(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};
Loading

0 comments on commit 9e72084

Please sign in to comment.