-
Notifications
You must be signed in to change notification settings - Fork 2.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Import onyx state #49255
feat: Import onyx state #49255
Changes from 33 commits
f1d2200
6381821
f8e758b
3b33ef2
69b1767
caa2573
1fbe276
b9e7373
7d403da
5c4d6ab
28937eb
0fe5895
47926c0
024b4a2
02059bf
df69d08
cc26c13
f2c80c8
1588340
fb3666b
179bdf3
71db305
291402a
077432c
65983c0
3397526
b6f375a
21205ae
ce1ae7e
dbc414a
fc84004
d8102f9
2dd1e91
e756a8b
9a7a330
eba5ae0
35fd5f1
072afdd
e55113b
62be7da
778934a
c3853c5
29e7857
8d91eb8
10bbee3
80d11b3
6f7ce2a
aeb02c3
601085b
8ed35d9
ca55510
619414d
7265c23
f41d5c5
b28e3f2
e152883
c6ff8e3
6bdc95f
3e305c3
2790ba4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
import React from 'react'; | ||
import RNFS from 'react-native-fs'; | ||
import Onyx from 'react-native-onyx'; | ||
import type {FileObject} from '@components/AttachmentModal'; | ||
import AttachmentPicker from '@components/AttachmentPicker'; | ||
import * as Expensicons from '@components/Icon/Expensicons'; | ||
import MenuItem from '@components/MenuItem'; | ||
import useLocalize from '@hooks/useLocalize'; | ||
import useThemeStyles from '@hooks/useThemeStyles'; | ||
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 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; | ||
}); | ||
}) | ||
.catch((error) => { | ||
throw error; | ||
}); | ||
} | ||
|
||
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; | ||
Comment on lines
+57
to
+63
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct me if I am wrong but it looks like we are overriding the promise from previous chunk with a promise from next chunk. Is this desired behaviour? What happens with promises generated from chunks before the very last one? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it creates a new promise that waits for the previous promise to resolve before applying the next chunk, creating a chain of promises. This ensures that the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh right, that's nice! Thanks for explanation ❤️ |
||
} | ||
|
||
export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { | ||
const {translate} = useLocalize(); | ||
const styles = useThemeStyles(); | ||
|
||
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); | ||
}); | ||
}); | ||
}) | ||
.finally(() => { | ||
setIsLoading(false); | ||
}); | ||
|
||
if (isLoading) { | ||
setIsLoading(false); | ||
} | ||
}; | ||
|
||
return ( | ||
<AttachmentPicker | ||
acceptedFileTypes={['text']} | ||
shouldHideCameraOption | ||
shouldHideGalleryOption | ||
> | ||
{({openPicker}) => { | ||
return ( | ||
<MenuItem | ||
icon={Expensicons.Upload} | ||
title={translate('initialSettingsPage.troubleshoot.importOnyxState')} | ||
wrapperStyle={[styles.sectionMenuItemTopDescription]} | ||
onPress={() => { | ||
openPicker({ | ||
onPicked: handleFileRead, | ||
}); | ||
}} | ||
/> | ||
); | ||
}} | ||
</AttachmentPicker> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
@@ -0,0 +1,69 @@ | ||||
import React from 'react'; | ||||
import Onyx from 'react-native-onyx'; | ||||
import type {FileObject} from '@components/AttachmentModal'; | ||||
import AttachmentPicker from '@components/AttachmentPicker'; | ||||
import * as Expensicons from '@components/Icon/Expensicons'; | ||||
import MenuItem from '@components/MenuItem'; | ||||
import useLocalize from '@hooks/useLocalize'; | ||||
import useThemeStyles from '@hooks/useThemeStyles'; | ||||
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 type ImportOnyxStateProps from './types'; | ||||
import {cleanAndTransformState} from './utils'; | ||||
|
||||
export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { | ||||
const {translate} = useLocalize(); | ||||
const styles = useThemeStyles(); | ||||
|
||||
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); | ||||
}); | ||||
}); | ||||
}); | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's add a catch block to handle exceptional (same to native file) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In case users upload wrong files, we can display a modal to announce that the upload failed: Similar to :
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that's a good idea, I have added the modal 👍 |
||||
|
||||
if (isLoading) { | ||||
setIsLoading(false); | ||||
} | ||||
}; | ||||
|
||||
return ( | ||||
<AttachmentPicker acceptedFileTypes={['text']}> | ||||
{({openPicker}) => { | ||||
return ( | ||||
<MenuItem | ||||
icon={Expensicons.Upload} | ||||
title={translate('initialSettingsPage.troubleshoot.importOnyxState')} | ||||
wrapperStyle={[styles.sectionMenuItemTopDescription]} | ||||
onPress={() => { | ||||
openPicker({ | ||||
onPicked: handleFileRead, | ||||
}); | ||||
}} | ||||
/> | ||||
); | ||||
}} | ||||
</AttachmentPicker> | ||||
); | ||||
} |
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; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
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.BETAS, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.NETWORK, ONYXKEYS.CREDENTIALS, ONYXKEYS.SESSION]; | ||
|
||
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 = data; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It looks like it's missing copying the |
||
if (typeof dataCopy !== 'object' || dataCopy === null) { | ||
return dataCopy; | ||
} | ||
|
||
const keys = Object.keys(dataCopy); | ||
const allKeysAreNumeric = keys.every((key) => !Number.isNaN(key)); | ||
const keysAreSequential = keys.every((key, index) => parseInt(key, 10) === index); | ||
|
||
if (allKeysAreNumeric && keysAreSequential) { | ||
return keys.map((key) => { | ||
if (isRecord(dataCopy)) { | ||
return transformNumericKeysToArray(dataCopy[key] as UnknownRecord); | ||
} | ||
return dataCopy; | ||
}); | ||
} | ||
|
||
if (isRecord(dataCopy)) { | ||
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}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<View style={[styles.buttonDanger]}> | ||
<Button | ||
danger | ||
small | ||
shouldRemoveLeftBorderRadius | ||
shouldRemoveRightBorderRadius | ||
text={translate('initialSettingsPage.troubleshoot.usingImportedState')} | ||
onPress={clearOnyxAndResetApp} | ||
textStyles={[styles.fontWeightNormal]} | ||
/> | ||
</View> | ||
); | ||
} | ||
|
||
export default ImportedStateIndicator; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the catch clause needed if we re-throw the error?