feat: 🎸 Use custom async storage (#461)

Service and hooks for storing temp-data, settings and personal data from the app

Also:
* Remove npm package use-async-storage
* Changed naming to use personal identity number instead of social security number
This commit is contained in:
Andreas Eriksson 2021-09-23 10:40:36 +02:00 committed by GitHub
parent 4c6868fc90
commit 98f7c04f1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 743 additions and 1019 deletions

View File

@ -26,6 +26,36 @@ const reporter = __DEV__
error: () => {},
}
if (__DEV__) {
const DevMenu = require('react-native-dev-menu')
DevMenu.addItem('Log AsyncStorage contents', () => logAsyncStorage())
}
const safeJsonParse = (maybeJson) => {
if (maybeJson) {
try {
return JSON.parse(maybeJson)
} catch (error) {
return maybeJson
}
}
return 'null'
}
const logAsyncStorage = async () => {
const allKeys = await AsyncStorage.getAllKeys()
const keysAndValues = await AsyncStorage.multiGet(allKeys)
console.log('*** AsyncStorage contents:')
keysAndValues.forEach((keyAndValue) => {
console.log(
keyAndValue[0],
'=>',
keyAndValue[1] ? safeJsonParse(keyAndValue[1]) : 'null'
)
})
console.log('***')
}
export default () => {
const colorScheme = useColorScheme()

View File

@ -0,0 +1,4 @@
export {
default,
useAsyncStorage,
} from '@react-native-async-storage/async-storage/jest/async-storage-mock'

View File

@ -1 +0,0 @@
export { default } from '@react-native-async-storage/async-storage/jest/async-storage-mock'

View File

@ -1,4 +1,3 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { useRoute } from '@react-navigation/native'
import { fireEvent, waitFor } from '@testing-library/react-native'
import Mockdate from 'mockdate'
@ -6,15 +5,15 @@ import React from 'react'
import { useSMS } from '../../utils/SMS'
import { render } from '../../utils/testHelpers'
import Absence from '../absence.component'
import { useUser } from '@skolplattformen/api-hooks'
import AsyncStorage from '@react-native-async-storage/async-storage'
jest.mock('@react-navigation/native')
jest.mock('@react-native-async-storage/async-storage')
jest.mock('@skolplattformen/api-hooks')
jest.mock('../../utils/SMS')
let sendSMS
// needed to skip tests due to bug in RN 0.65.1
// https://github.com/facebook/react-native/issues/29849#issuecomment-734533635
let user = { personalNumber: '201701092395' }
const setup = (customProps = {}) => {
sendSMS = jest.fn()
@ -35,18 +34,21 @@ beforeAll(() => {
jest.spyOn(console, 'error').mockImplementation(() => {})
})
beforeEach(() => {
jest.useFakeTimers()
beforeEach(async () => {
jest.clearAllMocks()
AsyncStorage.clear()
useUser.mockReturnValue({
data: user,
status: 'loaded',
})
await AsyncStorage.clear()
})
test.skip('can fill out the form with full day absence', async () => {
test('can fill out the form with full day absence', async () => {
const screen = setup()
await waitFor(() =>
fireEvent.changeText(
screen.getByTestId('socialSecurityNumberInput'),
screen.getByTestId('personalIdentityNumberInput'),
'1212121212'
)
)
@ -56,10 +58,9 @@ test.skip('can fill out the form with full day absence', async () => {
expect(screen.queryByText(/sluttid/i)).toBeFalsy()
expect(sendSMS).toHaveBeenCalledWith('121212-1212')
expect(AsyncStorage.setItem).toHaveBeenCalledWith('@childssn.1', '1212121212')
})
test.skip('handles missing social security number', async () => {
test('handles missing social security number', async () => {
const screen = setup()
await waitFor(() => fireEvent.press(screen.getByText('Skicka')))
@ -68,12 +69,12 @@ test.skip('handles missing social security number', async () => {
expect(sendSMS).not.toHaveBeenCalled()
})
test.skip('validates social security number', async () => {
test('validates social security number', async () => {
const screen = setup()
await waitFor(() =>
fireEvent.changeText(
screen.getByTestId('socialSecurityNumberInput'),
screen.getByTestId('personalIdentityNumberInput'),
'12121212'
)
)
@ -83,14 +84,14 @@ test.skip('validates social security number', async () => {
expect(sendSMS).not.toHaveBeenCalled()
})
test.skip('can fill out the form with part of day absence', async () => {
test('can fill out the form with part of day absence', async () => {
Mockdate.set('2021-02-18 15:30')
const screen = setup()
await waitFor(() =>
fireEvent.changeText(
screen.getByTestId('socialSecurityNumberInput'),
screen.getByTestId('personalIdentityNumberInput'),
'1212121212'
)
)

View File

@ -2,10 +2,8 @@ import { useApi } from '@skolplattformen/api-hooks'
import { render } from '../../utils/testHelpers'
import React from 'react'
import { Auth } from '../auth.component'
import { useAsyncStorage } from 'use-async-storage'
jest.mock('@skolplattformen/api-hooks')
jest.mock('use-async-storage')
jest.mock('react-native-localize')
const setup = () => {
@ -18,8 +16,6 @@ const setup = () => {
navigate: jest.fn(),
}
useAsyncStorage.mockReturnValue(['ssn', jest.fn()])
return render(<Auth navigation={navigation} />)
}

View File

@ -1,4 +1,3 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { RouteProp, useRoute } from '@react-navigation/native'
import {
Button,
@ -24,13 +23,15 @@ import { translate } from '../utils/translation'
import { AlertIcon } from './icon.component'
import { RootStackParamList } from './navigation.component'
import { NavigationTitle } from './navigationTitle.component'
import { useUser } from '@skolplattformen/api-hooks'
import usePersonalStorage from '../hooks/usePersonalStorage'
type AbsenceRouteProps = RouteProp<RootStackParamList, 'Absence'>
interface AbsenceFormValues {
displayStartTimePicker: boolean
displayEndTimePicker: boolean
socialSecurityNumber: string
personalIdentityNumber: string
isFullDay: boolean
startTime: moment.Moment
endTime: moment.Moment
@ -57,7 +58,7 @@ export const absenceRouteOptions =
const Absence = () => {
const AbsenceSchema = Yup.object().shape({
socialSecurityNumber: Yup.string()
personalIdentityNumber: Yup.string()
.required(translate('abscense.personalNumberMissing'))
.test('is-valid', translate('abscense.invalidPersonalNumber'), (value) =>
value ? Personnummer.valid(value) : true
@ -65,49 +66,50 @@ const Absence = () => {
isFullDay: Yup.bool().required(),
})
const { data: user } = useUser()
const route = useRoute<AbsenceRouteProps>()
const { sendSMS } = useSMS()
const { child } = route.params
const [socialSecurityNumber, setSocialSecurityNumber] = React.useState('')
const [personalIdFromStorage, setPersonalIdInStorage] = usePersonalStorage(
user,
`@childssn.${child.id}`,
''
)
const [personalIdentityNumber, setPersonalIdentityNumber] = React.useState('')
const minumumDate = moment().hours(8).minute(0)
const maximumDate = moment().hours(17).minute(0)
const styles = useStyleSheet(themedStyles)
const submit = useCallback(
async (values: AbsenceFormValues) => {
const ssn = Personnummer.parse(values.socialSecurityNumber).format()
const personalIdNumber = Personnummer.parse(
values.personalIdentityNumber
).format()
if (values.isFullDay) {
sendSMS(ssn)
sendSMS(personalIdNumber)
} else {
sendSMS(
`${ssn} ${moment(values.startTime).format('HHmm')}-${moment(
values.endTime
).format('HHmm')}`
`${personalIdNumber} ${moment(values.startTime).format(
'HHmm'
)}-${moment(values.endTime).format('HHmm')}`
)
}
await AsyncStorage.setItem(
`@childssn.${child.id}`,
values.socialSecurityNumber
)
setPersonalIdInStorage(values.personalIdentityNumber)
setPersonalIdentityNumber(values.personalIdentityNumber)
},
[child.id, sendSMS]
[sendSMS, setPersonalIdInStorage]
)
React.useEffect(() => {
const getSocialSecurityNumber = async () => {
const ssn = await AsyncStorage.getItem(`@childssn.${child.id}`)
setSocialSecurityNumber(ssn || '')
}
getSocialSecurityNumber()
}, [child])
setPersonalIdentityNumber(personalIdFromStorage || '')
}, [child, personalIdFromStorage, user])
const initialValues: AbsenceFormValues = {
displayStartTimePicker: false,
displayEndTimePicker: false,
socialSecurityNumber: socialSecurityNumber || '',
personalIdentityNumber: personalIdentityNumber || '',
isFullDay: true,
startTime: moment().hours(Math.max(8, new Date().getHours())).minute(0),
endTime: maximumDate,
@ -139,20 +141,22 @@ const Absence = () => {
{translate('general.socialSecurityNumber')}
</Text>
<Input
testID="socialSecurityNumberInput"
testID="personalIdentityNumberInput"
keyboardType="number-pad"
onChangeText={handleChange('socialSecurityNumber')}
onBlur={handleBlur('socialSecurityNumber')}
status={hasError('socialSecurityNumber') ? 'danger' : 'basic'}
value={values.socialSecurityNumber}
onChangeText={handleChange('personalIdentityNumber')}
onBlur={handleBlur('personalIdentityNumber')}
status={hasError('personalIdentityNumber') ? 'danger' : 'basic'}
value={values.personalIdentityNumber}
style={styles.input}
placeholder="YYYYMMDD-XXXX"
accessoryRight={
hasError('socialSecurityNumber') ? AlertIcon : undefined
hasError('personalIdentityNumber') ? AlertIcon : undefined
}
/>
{hasError('socialSecurityNumber') && (
<Text style={styles.error}>{errors.socialSecurityNumber}</Text>
{hasError('personalIdentityNumber') && (
<Text style={styles.error}>
{errors.personalIdentityNumber}
</Text>
)}
</View>
<View style={styles.field}>

View File

@ -1,4 +1,4 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import AppStorage from '../services/appStorage'
import { useNavigation } from '@react-navigation/core'
import { useApi, useChildList } from '@skolplattformen/api-hooks'
import { Child } from '@skolplattformen/embedded-api'
@ -51,24 +51,41 @@ export const Children = () => {
}
const logout = useCallback(() => {
api.logout()
AsyncStorage.clear()
AppStorage.clearTemporaryItems().then(() => api.logout())
}, [api])
const logoutAndClearPersonalData = useCallback(() => {
api
.getUser()
.then((user) => AppStorage.clearPersonalData(user))
.then(() => AppStorage.clearTemporaryItems().then(() => api.logout()))
}, [api])
const logoutAndClearAll = useCallback(() => {
AppStorage.nukeAllStorage().then(() => api.logout())
}, [api])
const settingsOptions = useMemo(() => {
return [translate('general.logout'), translate('general.cancel')]
return [
translate('general.logout'),
translate('general.logoutAndClearPersonalData'),
translate('general.logoutAndClearAllDataInclSettings'),
translate('general.cancel'),
]
}, [])
const handleSettingSelection = useCallback(
(index: number) => {
if (index === 0) logout()
if (index === 1) logoutAndClearPersonalData()
if (index === 2) logoutAndClearAll()
},
[logout]
[logout, logoutAndClearAll, logoutAndClearPersonalData]
)
const settings = useCallback(() => {
const options = {
cancelButtonIndex: 1,
cancelButtonIndex: settingsOptions.length - 1,
title: translate('general.settings'),
optionsIOS: settingsOptions,
optionsAndroid: settingsOptions,

View File

@ -21,7 +21,8 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native'
import { useAsyncStorage } from 'use-async-storage'
import useSettingsStorage from '../hooks/useSettingsStorage'
import AppStorage from '../services/appStorage'
import { schema } from '../app.json'
import { Layout } from '../styles'
import { translate } from '../utils/translation'
@ -48,14 +49,11 @@ export const Login = () => {
const [visible, showModal] = useState(false)
const [showLoginMethod, setShowLoginMethod] = useState(false)
const [error, setError] = useState<string | null>(null)
const [cachedSsn, setCachedSsn] = useAsyncStorage('socialSecurityNumber', '')
const [socialSecurityNumber, setSocialSecurityNumber] = useState('')
const [personalIdNumber, setPersonalIdNumber] = useState('')
const [valid, setValid] = useState(false)
const [loginMethodIndex, setLoginMethodIndex] = useState(0)
const [cachedLoginMethodIndex, setCachedLoginMethodIndex] = useAsyncStorage(
'loginMethodIndex',
'0'
)
const [cachedLoginMethodIndex, setCachedLoginMethodIndex] =
useSettingsStorage('loginMethodIndex', '0')
const loginMethods = [
translate('auth.bankid.OpenOnThisDevice'),
@ -69,6 +67,7 @@ export const Login = () => {
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loginMethodIndex])
useEffect(() => {
if (loginMethodIndex !== parseInt(cachedLoginMethodIndex, 10)) {
setLoginMethodIndex(parseInt(cachedLoginMethodIndex, 10))
@ -76,17 +75,36 @@ export const Login = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cachedLoginMethodIndex])
/* Initial load functions */
useEffect(() => {
setValid(Personnummer.valid(socialSecurityNumber))
}, [socialSecurityNumber])
setValid(Personnummer.valid(personalIdNumber))
}, [personalIdNumber])
useEffect(() => {
if (cachedSsn && socialSecurityNumber !== cachedSsn) {
setSocialSecurityNumber(cachedSsn)
async function SetPersonalIdNumberIfSaved() {
const storedPersonalIdNumber = await AppStorage.getSetting<string>(
'cachedPersonalIdentityNumber'
)
if (storedPersonalIdNumber) {
setPersonalIdNumber(storedPersonalIdNumber)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cachedSsn])
SetPersonalIdNumberIfSaved()
}, [])
useEffect(() => {
async function SavePersonalIdNumber(numberToSave: string) {
if (numberToSave) {
await AppStorage.setSetting(
'cachedPersonalIdentityNumber',
numberToSave
)
}
}
SavePersonalIdNumber(personalIdNumber)
}, [personalIdNumber])
const loginHandler = async () => {
showModal(false)
@ -97,14 +115,12 @@ export const Login = () => {
return () => {
api.off('login', loginHandler)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [api])
/* Helpers */
const handleInput = (text: string) => {
setValid(Personnummer.valid(text))
setCachedSsn(text)
setSocialSecurityNumber(text)
setPersonalIdNumber(text)
}
const openBankId = (token: string) => {
@ -127,8 +143,7 @@ export const Login = () => {
let ssn
if (loginMethodIndex === 1) {
ssn = Personnummer.parse(text).format(true)
setCachedSsn(ssn)
setSocialSecurityNumber(ssn)
setPersonalIdNumber(ssn)
}
const status = await api.login(ssn)
@ -158,7 +173,7 @@ export const Login = () => {
accessible={true}
label={translate('general.socialSecurityNumber')}
autoFocus
value={socialSecurityNumber}
value={personalIdNumber}
style={styles.pnrInput}
accessoryLeft={PersonIcon}
accessoryRight={(props) => (
@ -185,7 +200,7 @@ export const Login = () => {
<ButtonGroup style={styles.loginButtonGroup} status="primary">
<Button
accessible={true}
onPress={() => startLogin(socialSecurityNumber)}
onPress={() => startLogin(personalIdNumber)}
style={styles.loginButton}
appearance="ghost"
disabled={loginMethodIndex === 1 && !valid}

View File

@ -3,9 +3,10 @@ import * as RNLocalize from 'react-native-localize'
import { LoadingComponent } from '../../components/loading.component'
import { LanguageService } from '../../services/languageService'
import { LanguageStorage } from '../../services/languageStorage'
import { translations } from '../../utils/translation'
import AppStorage from '../../services/appStorage'
interface LanguageContextProps {
Strings: Record<string, any>
languageCode?: string
@ -67,7 +68,7 @@ export const LanguageProvider: React.FC<Props> = ({
setLanguageCode(langCode)
setStrings(data[langCode])
if (cache) {
LanguageStorage.save(langCode)
AppStorage.setSetting('langCode', langCode)
}
}
}
@ -77,7 +78,7 @@ export const LanguageProvider: React.FC<Props> = ({
// Saved language
if (cache) {
// Get cached lang
const cachedLang = await LanguageStorage.get()
const cachedLang = await AppStorage.getSetting<string>('langCode')
// Try to find best suited language
const { languageTag } =

View File

@ -0,0 +1,87 @@
import { renderHook, act } from '@testing-library/react-hooks'
import AsyncStorage from '@react-native-async-storage/async-storage'
import usePersonalStorage from '../usePersonalStorage'
import { User } from '@skolplattformen/embedded-api'
beforeEach(async () => {
jest.clearAllMocks()
await AsyncStorage.clear()
})
const user: User = { personalNumber: '201701012393' }
const prefix = user.personalNumber + '_'
test('use key prefix on set', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
usePersonalStorage(user, 'key', '')
)
act(() => {
const [, setValue] = result.current
setValue('foo')
})
await waitForNextUpdate()
expect(await AsyncStorage.getItem(prefix + 'key')).toEqual(
JSON.stringify('foo')
)
})
test('return inital value if no set', async () => {
const { result } = renderHook(() =>
usePersonalStorage(user, 'key', 'initialValue')
)
const [value] = result.current
expect(value).toEqual('initialValue')
expect(await AsyncStorage.getItem(prefix + 'key')).toEqual(null)
})
test('update value', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
usePersonalStorage(user, 'key', 'initialValue')
)
const [initValue, setValue] = result.current
setValue('update')
await waitForNextUpdate()
const [updateValue] = result.current
expect(initValue).toEqual('initialValue')
expect(updateValue).toEqual('update')
expect(await AsyncStorage.getItem(prefix + 'key')).toEqual(
JSON.stringify('update')
)
})
test('do nothing if personalId is empty', async () => {
const emptyUser: User = { personalNumber: '' }
let hookUser = emptyUser
const { result, rerender, waitForNextUpdate } = renderHook(() =>
usePersonalStorage(hookUser, 'key', '')
)
act(() => {
const [, setValue] = result.current
setValue('foo')
})
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
hookUser = user
rerender()
act(() => {
const [, setValue] = result.current
setValue('foo')
})
await waitForNextUpdate()
expect(AsyncStorage.setItem).toHaveBeenCalled()
})

View File

@ -0,0 +1,59 @@
import { renderHook, act } from '@testing-library/react-hooks'
import AsyncStorage from '@react-native-async-storage/async-storage'
import useSettingsStorage from '../useSettingsStorage'
import AppStorage from '../../services/appStorage'
beforeEach(() => {
AsyncStorage.clear()
})
const prefix = AppStorage.settingsStorageKeyPrefix
test('use key prefix on set', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useSettingsStorage('key', '')
)
act(() => {
const [, setValue] = result.current
setValue('foo')
})
await waitForNextUpdate()
expect(await AsyncStorage.getItem(prefix + 'key')).toEqual(
JSON.stringify('foo')
)
})
test('return inital value if no set', async () => {
const { result } = renderHook(() => useSettingsStorage('key', 'initialValue'))
const [value] = result.current
expect(value).toEqual('initialValue')
expect(await AsyncStorage.getItem(prefix + 'key')).toEqual(null)
})
test('update value', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useSettingsStorage('key', 'initialValue')
)
const [initValue, setValue] = result.current
act(() => {
setValue('update')
})
await waitForNextUpdate()
const [updateValue] = result.current
expect(initValue).toEqual('initialValue')
expect(updateValue).toEqual('update')
expect(await AsyncStorage.getItem(prefix + 'key')).toEqual(
JSON.stringify('update')
)
})

View File

@ -0,0 +1,59 @@
import { renderHook, act } from '@testing-library/react-hooks'
import AsyncStorage from '@react-native-async-storage/async-storage'
import useTempStorage from '../useTempStorage'
import AppStorage from '../../services/appStorage'
beforeEach(() => {
AsyncStorage.clear()
})
const prefix = AppStorage.tempStorageKeyPrefix
test('use key prefix on set', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useTempStorage('key', '')
)
act(() => {
const [, setValue] = result.current
setValue('foo')
})
await waitForNextUpdate()
expect(await AsyncStorage.getItem(prefix + 'key')).toEqual(
JSON.stringify('foo')
)
})
test('return inital value if no set', async () => {
const { result } = renderHook(() => useTempStorage('key', 'initialValue'))
const [value] = result.current
expect(value).toEqual('initialValue')
expect(await AsyncStorage.getItem(prefix + 'key')).toEqual(null)
})
test('update value', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useTempStorage('key', 'initialValue')
)
const [initValue, setValue] = result.current
act(() => {
setValue('update')
})
await waitForNextUpdate()
const [updateValue] = result.current
expect(initValue).toEqual('initialValue')
expect(updateValue).toEqual('update')
expect(await AsyncStorage.getItem(prefix + 'key')).toEqual(
JSON.stringify('update')
)
})

View File

@ -0,0 +1,33 @@
import { useEffect, useState } from 'react'
import AsyncStorage from '@react-native-async-storage/async-storage'
export default function useAsyncStorage<T>(
storageKey: string,
defaultValue: T
): [T, (val: T) => void] {
const [storageItem, setStorageItem] = useState(defaultValue)
async function setStoredValue(value: T) {
try {
if (!storageKey) return
await AsyncStorage.setItem(storageKey, JSON.stringify(value))
setStorageItem(value)
} catch (e) {}
}
useEffect(() => {
async function getStoredValue() {
try {
const data = await AsyncStorage.getItem(storageKey)
if (typeof data === 'string') setStorageItem(JSON.parse(data))
} catch (e) {}
}
getStoredValue()
}, [storageKey])
return [
storageItem !== undefined ? storageItem : defaultValue,
setStoredValue,
]
}

View File

@ -0,0 +1,12 @@
import { User } from '@skolplattformen/embedded-api'
import useAsyncStorage from './useAsyncStorage'
export default function usePersonalStorage<T>(
user: User,
storageKey: string,
defaultValue: T
): [T, (val: T) => void] {
const internalKey =
user && user.personalNumber ? user.personalNumber + '_' + storageKey : ''
return useAsyncStorage(internalKey, defaultValue)
}

View File

@ -0,0 +1,10 @@
import useAsyncStorage from './useAsyncStorage'
import AppStorage from '../services/appStorage'
export default function useSettingsStorage<T>(
storageKey: string,
defaultValue: T
): [T, (val: T) => void] {
const settingsKey = AppStorage.settingsStorageKeyPrefix + storageKey
return useAsyncStorage(settingsKey, defaultValue)
}

View File

@ -0,0 +1,10 @@
import useAsyncStorage from './useAsyncStorage'
import AppStorage from '../services/appStorage'
export default function useTempStorage<T>(
storageKey: string,
defaultValue: T
): [T, (val: T) => void] {
const tempKey = AppStorage.tempStorageKeyPrefix + storageKey
return useAsyncStorage(tempKey, defaultValue)
}

View File

@ -372,6 +372,10 @@ PODS:
- React
- RNDateTimePicker (3.4.3):
- React-Core
- RNDevMenu (4.0.2):
- React-Core
- React-Core/DevSupport
- React-RCTNetwork
- RNGestureHandler (1.10.3):
- React-Core
- RNLocalize (2.1.4):
@ -479,6 +483,7 @@ DEPENDENCIES:
- "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- "RNCMaskedView (from `../node_modules/@react-native-community/masked-view`)"
- "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)"
- RNDevMenu (from `../node_modules/react-native-dev-menu`)
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNLocalize (from `../node_modules/react-native-localize`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
@ -583,6 +588,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-community/masked-view"
RNDateTimePicker:
:path: "../node_modules/@react-native-community/datetimepicker"
RNDevMenu:
:path: "../node_modules/react-native-dev-menu"
RNGestureHandler:
:path: "../node_modules/react-native-gesture-handler"
RNLocalize:
@ -650,6 +657,7 @@ SPEC CHECKSUMS:
RNCAsyncStorage: 9b7605e899f9acb2fba33e87952c529731265453
RNCMaskedView: 0e1bc4bfa8365eba5fbbb71e07fbdc0555249489
RNDateTimePicker: d943800c936fb01c352fcfb70439550d2cb57092
RNDevMenu: fd325b5554b61fe7f48d9205a3877cf5ee88cd7c
RNGestureHandler: a479ebd5ed4221a810967000735517df0d2db211
RNLocalize: 7f1e5792b65a839af55a9552d05b3558b66d017e
RNReanimated: ad24db8af24e3fe1b5c462785bc3db8d5baae2ee

View File

@ -44,6 +44,7 @@
"react-native-animatable": "^1.3.3",
"react-native-appearance": "^0.3.4",
"react-native-calendar-events": "2.2.0",
"react-native-dev-menu": "^4.0.2",
"react-native-fix-image": "2.1.0",
"react-native-gesture-handler": "^1.10.3",
"react-native-localize": "^2.0.2",
@ -61,7 +62,6 @@
"react-native-webview": "11.4.2",
"react-native-weekly-calendar": "^0.2.0",
"rn-actionsheet-module": "https://github.com/kolplattformen/rn-actionsheet-module.git",
"use-async-storage": "1.2.0",
"yup": "0.32.9"
},
"devDependencies": {

View File

@ -0,0 +1,159 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import AppStorage from '../appStorage'
import { User } from '@skolplattformen/embedded-api'
beforeEach(() => {
jest.clearAllMocks()
AsyncStorage.clear()
})
const prefix = AppStorage.settingsStorageKeyPrefix
const temp = AppStorage.tempStorageKeyPrefix
test('Sets setting with prefix in storage', async () => {
await AppStorage.setSetting('key', 'value')
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
prefix + 'key',
JSON.stringify('value')
)
})
test('Can get setting from storage', async () => {
await AppStorage.setSetting('key', 'value')
const result = await AppStorage.getSetting<string>('key')
expect(result).toEqual('value')
expect(AsyncStorage.getItem).toHaveBeenCalledWith(prefix + 'key')
})
test('Clear only settings', async () => {
const user: User = { personalNumber: '201701012393' }
await AppStorage.setSetting('key', 'value')
await AppStorage.setSetting('key2', 'value2')
await AppStorage.setSetting('key3', 'value3')
await AppStorage.setTemporaryItem('nonSetting', 'nonSettingValue')
await AppStorage.setPersonalData(user, 'personalData', 'personal id value')
await AppStorage.clearAllSettings()
const allKeys = await AsyncStorage.getAllKeys()
expect(allKeys).toHaveLength(2)
expect(allKeys[0]).toEqual(temp + 'nonSetting')
expect(allKeys[1]).toEqual(user.personalNumber + '_' + 'personalData')
})
test('Clear temporary items', async () => {
const user: User = { personalNumber: '201701012393' }
await AppStorage.setSetting('settingKey1', 'settingValue1')
await AppStorage.setSetting('settingKey2', 'settingValue2')
await AppStorage.setSetting('settingKey3', 'settingValue3')
await AppStorage.setTemporaryItem('tempKey1', 'tempValue1')
await AppStorage.setTemporaryItem('tempKey2', 'tempValue2')
await AppStorage.setTemporaryItem('tempKey3', 'tempValue3')
await AppStorage.setPersonalData(user, 'personalData', 'personal id value')
await AppStorage.clearTemporaryItems()
const allKeys = await AsyncStorage.getAllKeys()
expect(allKeys).toHaveLength(4)
expect(allKeys[0]).toEqual(prefix + 'settingKey1')
expect(allKeys[3]).toEqual(user.personalNumber + '_' + 'personalData')
})
test('Store temporary string in AsyncStorage', async () => {
await AppStorage.setTemporaryItem('tempkey', 'tempvalue')
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
temp + 'tempkey',
JSON.stringify('tempvalue')
)
})
test('Get temporary string from AsyncStorage', async () => {
await AppStorage.getTemporaryItem('tempkey')
expect(AsyncStorage.getItem).toHaveBeenCalledWith(temp + 'tempkey')
})
test('Store temporary object in AsyncStorage', async () => {
const obj = { a: 'foo', b: 5 }
await AppStorage.setTemporaryItem('tempkey', obj)
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
temp + 'tempkey',
JSON.stringify(obj)
)
})
test('Get temporary object from AsyncStorage', async () => {
await AppStorage.getTemporaryItem('tempkey')
expect(AsyncStorage.getItem).toHaveBeenCalledWith(temp + 'tempkey')
})
test('Set personal data with personal number prefix', async () => {
const obj = { a: 'gdpr', b: 'is fun' }
const user: User = { personalNumber: '201701012393' }
await AppStorage.setPersonalData(user, 'key', obj)
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
user.personalNumber + '_' + 'key',
JSON.stringify(obj)
)
})
test('Set personal data does nothing if personal number missing', async () => {
const obj = { a: 'gdpr', b: 'is fun' }
const user: User = { personalNumber: '' }
await AppStorage.setPersonalData(user, 'key', obj)
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
})
test('Get personal data gets data if personal number matches', async () => {
const data = 'personal data'
const user: User = { personalNumber: '201701012393' }
await AppStorage.setPersonalData(user, 'key', data)
const storedData = await AppStorage.getPersonalData(user, 'key')
expect(storedData).toEqual(data)
})
test('Get no personal data gets data if personal number does not match', async () => {
const data = 'personal data'
const user: User = { personalNumber: '201701012393' }
const anotherAser: User = { personalNumber: '202112312380' }
await AppStorage.setPersonalData(user, 'key', data)
const storedData = await AppStorage.getPersonalData(anotherAser, 'key')
expect(user).not.toEqual(anotherAser)
expect(storedData).toEqual(null)
})
test('Clear only PersonalData', async () => {
await AppStorage.setSetting('settingKey1', 'settingValue1')
await AppStorage.setTemporaryItem('tempKey1', 'tempValue1')
const data = 'personal data'
const user: User = { personalNumber: '201701012393' }
await AppStorage.setPersonalData(user, 'key', data)
await AppStorage.clearPersonalData(user)
const allKeys = await AsyncStorage.getAllKeys()
expect(allKeys).toHaveLength(2)
expect(allKeys).not.toContain(user.personalNumber + '_key')
})
test('Clear PersonalData does nothing if personalnumber is empty', async () => {
const user: User = { personalNumber: '' }
await AppStorage.clearPersonalData(user)
expect(AsyncStorage.multiRemove).not.toHaveBeenCalled()
})

View File

@ -0,0 +1,122 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { User } from '@skolplattformen/embedded-api'
export default class AppStorage {
static settingsStorageKeyPrefix = 'appsetting_'
static tempStorageKeyPrefix = 'tempItem_'
/**
* Stores a setting
* @param key
* @param value
*/
static async setSetting<T>(key: string, value: T) {
const jsonValue = JSON.stringify(value)
await AsyncStorage.setItem(this.settingsStorageKeyPrefix + key, jsonValue)
}
/**
* Gets a stored setting
* @param key
* @returns
*/
static async getSetting<T>(key: string): Promise<T | null> {
const value = await AsyncStorage.getItem(
this.settingsStorageKeyPrefix + key
)
return value ? (JSON.parse(value) as T) : null
}
/**
* Stores a personal data item
* @param user
* @param key
* @param value
*/
static async setPersonalData<T>(user: User, key: string, value: T) {
const jsonValue = JSON.stringify(value)
if (user.personalNumber) {
const storageKey = user.personalNumber + '_' + key
await AsyncStorage.setItem(storageKey, jsonValue)
}
}
/**
* Get stored personal data
* @param user
* @param key
* @returns
*/
static async getPersonalData<T>(user: User, key: string): Promise<T | null> {
if (user.personalNumber) {
const value = await AsyncStorage.getItem(user.personalNumber + '_' + key)
return value ? (JSON.parse(value) as T) : null
}
return null
}
/**
* Stores a temporary items. The items are cleared at logout.
* Think of this as a session storage
* @param key
* @param value
*/
static async setTemporaryItem<T>(key: string, value: T) {
const jsonValue = JSON.stringify(value)
await AsyncStorage.setItem(this.tempStorageKeyPrefix + key, jsonValue)
}
/**
* Gets a temporary stored item
* @param key
* @returns
*/
static async getTemporaryItem<T>(key: string): Promise<T | null> {
const value = await AsyncStorage.getItem(this.tempStorageKeyPrefix + key)
return value ? (JSON.parse(value) as T) : null
}
/**
* Clears all settings
*/
static async clearAllSettings(): Promise<void> {
const allKeys = await AsyncStorage.getAllKeys()
const settingsKeys = allKeys.filter((x) =>
x.startsWith(this.settingsStorageKeyPrefix)
)
await AsyncStorage.multiRemove(settingsKeys)
}
/**
* Clear all temporary items
*/
static async clearTemporaryItems() {
const allKeys = await AsyncStorage.getAllKeys()
const notSettingsKeys = allKeys.filter((x) =>
x.startsWith(this.tempStorageKeyPrefix)
)
await AsyncStorage.multiRemove(notSettingsKeys)
}
/**
* Clears all personal identififieble data (GDPR)
* @param user
* @returns
*/
static async clearPersonalData(user: User): Promise<void> {
if (!user.personalNumber) return
const allKeys = await AsyncStorage.getAllKeys()
const personalDataKeys = allKeys.filter((x) =>
x.startsWith(user.personalNumber ?? '')
)
await AsyncStorage.multiRemove(personalDataKeys)
}
/**
* Clears all async storage for this app and all libs that it uses
*/
static async nukeAllStorage() {
await AsyncStorage.clear()
}
}

View File

@ -1,22 +0,0 @@
import AsyncStorage from '@react-native-community/async-storage'
const AsyncStoreKey = { language: 'local-language-async' }
export const LanguageStorage = {
save: async (languageCode: string) => {
try {
await AsyncStorage.setItem(AsyncStoreKey.language, languageCode)
} catch (error) {}
},
remove: () => {
AsyncStorage.removeItem(AsyncStoreKey.language)
},
get: async () => {
try {
const result = await AsyncStorage.getItem(AsyncStoreKey.language)
return result
} catch (error) {
return false
}
},
}

View File

@ -1,12 +1,9 @@
import mockAsyncStorage from '@react-native-async-storage/async-storage/jest/async-storage-mock'
import moment from 'moment'
import 'moment/locale/sv'
import 'react-native-gesture-handler/jestSetup'
moment.locale('sv')
jest.mock('@react-native-async-storage/async-storage', () => mockAsyncStorage)
// Silence useNativeDriver error
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper')

View File

@ -73,6 +73,8 @@
"confirm": "Confirm",
"loading": "Loading…",
"logout": "Log out",
"logoutAndClearPersonalData": "Log out and clear personal data",
"logoutAndClearAllDataInclSettings": "Log out and clear all data including settings",
"send": "Send",
"settings": "Settings",
"socialSecurityNumber": "Personal identity number",

File diff suppressed because it is too large Load Diff