refactor(app): transfer last components to typescript
This commit is contained in:
parent
203a0a302b
commit
7573e642b4
|
@ -1,4 +1,5 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { NavigationProp, RouteProp } from '@react-navigation/native'
|
||||
import {
|
||||
Button,
|
||||
CheckBox,
|
||||
|
@ -10,7 +11,7 @@ import {
|
|||
TopNavigationAction,
|
||||
useTheme,
|
||||
} from '@ui-kitten/components'
|
||||
import { Formik } from 'formik'
|
||||
import { ErrorMessage, Formik } from 'formik'
|
||||
import moment from 'moment'
|
||||
import Personnummer from 'personnummer'
|
||||
import React from 'react'
|
||||
|
@ -20,23 +21,32 @@ import * as Yup from 'yup'
|
|||
import { studentName } from '../utils/peopleHelpers'
|
||||
import { useSMS } from '../utils/SMS'
|
||||
import { BackIcon } from './icon.component'
|
||||
import { RootStackParamList } from './navigation.component'
|
||||
|
||||
interface AbsenceProps {
|
||||
navigation: NavigationProp<RootStackParamList, 'Absence'>
|
||||
route: RouteProp<RootStackParamList, 'Absence'>
|
||||
}
|
||||
|
||||
interface AbsenceFormValues {
|
||||
displayStartTimePicker: boolean
|
||||
displayEndTimePicker: boolean
|
||||
socialSecurityNumber: string
|
||||
isFullDay: boolean
|
||||
startTime: moment.Moment
|
||||
endTime: moment.Moment
|
||||
}
|
||||
|
||||
const AbsenceSchema = Yup.object().shape({
|
||||
socialSecurityNumber: Yup.string()
|
||||
.required('Personnummer saknas')
|
||||
.test('is-valid', 'Personnumret är ogiltigt', (value) =>
|
||||
Personnummer.valid(value)
|
||||
value ? Personnummer.valid(value) : true
|
||||
),
|
||||
isFullDay: Yup.bool().required(),
|
||||
startTime: Yup.string().when('isFullDay', (isFullDay, schema) =>
|
||||
isFullDay ? schema : schema.required('Starttid saknas')
|
||||
),
|
||||
endTime: Yup.string().when('isFullDay', (isFullDay, schema) =>
|
||||
isFullDay ? schema : schema.required('Sluttid saknas')
|
||||
),
|
||||
})
|
||||
|
||||
const Absence = ({ route, navigation }) => {
|
||||
const Absence = ({ route, navigation }: AbsenceProps) => {
|
||||
const { sendSMS } = useSMS()
|
||||
const { child } = route.params
|
||||
const theme = useTheme()
|
||||
|
@ -47,12 +57,21 @@ const Absence = ({ route, navigation }) => {
|
|||
React.useEffect(() => {
|
||||
const getSocialSecurityNumber = async () => {
|
||||
const ssn = await AsyncStorage.getItem(`@childssn.${child.id}`)
|
||||
setSocialSecurityNumber(ssn)
|
||||
setSocialSecurityNumber(ssn || '')
|
||||
}
|
||||
|
||||
getSocialSecurityNumber()
|
||||
}, [child])
|
||||
|
||||
const initialValues: AbsenceFormValues = {
|
||||
displayStartTimePicker: false,
|
||||
displayEndTimePicker: false,
|
||||
socialSecurityNumber: socialSecurityNumber || '',
|
||||
isFullDay: true,
|
||||
startTime: moment().hours(Math.max(8, new Date().getHours())).minute(0),
|
||||
endTime: maximumDate,
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<TopNavigation
|
||||
|
@ -72,16 +91,7 @@ const Absence = ({ route, navigation }) => {
|
|||
<Formik
|
||||
enableReinitialize
|
||||
validationSchema={AbsenceSchema}
|
||||
initialValues={{
|
||||
displayStartTimePicker: false,
|
||||
displayEndTimePicker: false,
|
||||
socialSecurityNumber: socialSecurityNumber || '',
|
||||
isFullDay: true,
|
||||
startTime: moment()
|
||||
.hours(Math.max(8, new Date().getHours()))
|
||||
.minute(0),
|
||||
endTime: maximumDate,
|
||||
}}
|
||||
initialValues={initialValues}
|
||||
onSubmit={async (values) => {
|
||||
const ssn = Personnummer.parse(values.socialSecurityNumber).format()
|
||||
|
||||
|
@ -110,7 +120,8 @@ const Absence = ({ route, navigation }) => {
|
|||
touched,
|
||||
errors,
|
||||
}) => {
|
||||
const hasError = (field) => errors[field] && touched[field]
|
||||
const hasError = (field: keyof typeof values) =>
|
||||
errors[field] && touched[field]
|
||||
|
||||
return (
|
||||
<View>
|
||||
|
@ -171,11 +182,6 @@ const Absence = ({ route, navigation }) => {
|
|||
setFieldValue('displayStartTimePicker', false)
|
||||
}
|
||||
/>
|
||||
{hasError('startTime') && (
|
||||
<Text style={{ color: theme['color-danger-700'] }}>
|
||||
{errors.startTime}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.spacer} />
|
||||
<View style={styles.inputHalf}>
|
||||
|
@ -207,11 +213,6 @@ const Absence = ({ route, navigation }) => {
|
|||
setFieldValue('displayEndTimePicker', false)
|
||||
}
|
||||
/>
|
||||
{hasError('endTime') && (
|
||||
<Text style={{ color: theme['color-danger-700'] }}>
|
||||
{errors.endTime}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
|
@ -1,4 +1,3 @@
|
|||
import { NavigationProp } from '@react-navigation/native'
|
||||
import { Layout, Text } from '@ui-kitten/components'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
|
@ -11,11 +10,6 @@ import {
|
|||
View,
|
||||
} from 'react-native'
|
||||
import { Login } from './login.component'
|
||||
import { SigninStackParamList } from './navigation.component'
|
||||
|
||||
interface AuthProps {
|
||||
navigation: NavigationProp<SigninStackParamList, 'Login'>
|
||||
}
|
||||
|
||||
const funArguments = [
|
||||
'agila',
|
||||
|
@ -37,7 +31,7 @@ const funArguments = [
|
|||
'öppna',
|
||||
]
|
||||
|
||||
export const Auth = ({ navigation }: AuthProps) => {
|
||||
export const Auth = () => {
|
||||
const [argument] = useState(() => {
|
||||
const argNum = Math.floor(Math.random() * funArguments.length)
|
||||
return funArguments[argNum]
|
||||
|
@ -58,7 +52,7 @@ export const Auth = ({ navigation }: AuthProps) => {
|
|||
<Text category="h6" style={styles.subtitle}>
|
||||
Det {argument} alternativet
|
||||
</Text>
|
||||
<Login navigation={navigation} />
|
||||
<Login />
|
||||
</Layout>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
|
|
@ -1,151 +0,0 @@
|
|||
import { useApi, useChildList } from '@skolplattformen/api-hooks'
|
||||
import {
|
||||
Divider,
|
||||
Layout,
|
||||
List,
|
||||
Spinner,
|
||||
Text,
|
||||
TopNavigation,
|
||||
TopNavigationAction,
|
||||
} from '@ui-kitten/components'
|
||||
import React from 'react'
|
||||
import { Dimensions, Image, SafeAreaView, StyleSheet, View } from 'react-native'
|
||||
import ActionSheet from 'rn-actionsheet-module'
|
||||
import { ChildListItem } from './childListItem.component'
|
||||
import { SettingsIcon } from './icon.component'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
const { width } = Dimensions.get('window')
|
||||
|
||||
const colors = ['primary', 'success', 'info', 'warning', 'danger']
|
||||
const settingsOptions = ['Logga ut', 'Avbryt']
|
||||
|
||||
export const Children = ({ navigation }) => {
|
||||
const { api } = useApi()
|
||||
const { data: childList, status } = useChildList()
|
||||
|
||||
const handleSettingSelection = (index) => {
|
||||
switch (index) {
|
||||
case 0:
|
||||
api.logout()
|
||||
AsyncStorage.clear()
|
||||
navigation.navigate('Login')
|
||||
}
|
||||
}
|
||||
|
||||
const settings = () => {
|
||||
const options = {
|
||||
cancelButtonIndex: 1,
|
||||
title: 'Inställningar',
|
||||
optionsIOS: settingsOptions,
|
||||
optionsAndroid: settingsOptions,
|
||||
onCancelAndroidIndex: handleSettingSelection,
|
||||
}
|
||||
|
||||
ActionSheet(options, handleSettingSelection)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.topContainer}>
|
||||
{status === 'loaded' ? (
|
||||
<>
|
||||
<TopNavigation
|
||||
title="Dina barn"
|
||||
alignment="center"
|
||||
accessoryRight={() => (
|
||||
<TopNavigationAction icon={SettingsIcon} onPress={settings} />
|
||||
)}
|
||||
/>
|
||||
<Divider />
|
||||
<List
|
||||
contentContainerStyle={styles.childListContainer}
|
||||
data={childList}
|
||||
style={styles.childList}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyState}>
|
||||
<Text category="h2">Inga barn</Text>
|
||||
<Text style={styles.emptyStateDescription}>
|
||||
Det finns inga barn registrerade för ditt personnummer i
|
||||
Stockholms Stad
|
||||
</Text>
|
||||
<Image
|
||||
source={require('../assets/children.png')}
|
||||
style={styles.emptyStateImage}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item: child, index }) => (
|
||||
<ChildListItem
|
||||
child={child}
|
||||
color={colors[index % colors.length]}
|
||||
key={child.id}
|
||||
navigation={navigation}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Layout style={styles.loading}>
|
||||
<Image
|
||||
source={require('../assets/girls.png')}
|
||||
style={styles.loadingImage}
|
||||
/>
|
||||
<View style={styles.loadingMessage}>
|
||||
<Spinner size="large" status="warning" />
|
||||
<Text category="h1" style={styles.loadingText}>
|
||||
Laddar...
|
||||
</Text>
|
||||
</View>
|
||||
</Layout>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
topContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
loading: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingImage: {
|
||||
height: (width / 16) * 9,
|
||||
width: width,
|
||||
},
|
||||
loadingMessage: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
marginTop: 8,
|
||||
},
|
||||
loadingText: {
|
||||
marginLeft: 20,
|
||||
},
|
||||
childList: {
|
||||
flex: 1,
|
||||
},
|
||||
childListContainer: {
|
||||
padding: 20,
|
||||
},
|
||||
emptyState: {
|
||||
backgroundColor: '#fff',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
emptyStateDescription: {
|
||||
lineHeight: 21,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyStateImage: {
|
||||
// 80% size and 16:9 aspect ratio
|
||||
height: ((width * 0.8) / 16) * 9,
|
||||
marginTop: 20,
|
||||
width: width * 0.8,
|
||||
},
|
||||
})
|
|
@ -23,6 +23,7 @@ import ActionSheet from 'rn-actionsheet-module'
|
|||
import { ChildListItem } from './childListItem.component'
|
||||
import { SettingsIcon } from './icon.component'
|
||||
import { RootStackParamList } from './navigation.component'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
interface ChildrenProps {
|
||||
navigation: NavigationProp<RootStackParamList, 'Children'>
|
||||
|
@ -41,7 +42,7 @@ export const Children = ({ navigation }: ChildrenProps) => {
|
|||
switch (index) {
|
||||
case 0:
|
||||
api.logout()
|
||||
navigation.navigate('Login')
|
||||
AsyncStorage.clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { Classmate } from '@skolplattformen/embedded-api'
|
||||
import {
|
||||
Button,
|
||||
MenuGroup,
|
||||
|
@ -15,7 +16,17 @@ import {
|
|||
SMSIcon,
|
||||
} from './icon.component'
|
||||
|
||||
export const ContactMenu = ({ contact, selected, setSelected }) => {
|
||||
interface ContactMenuProps {
|
||||
contact: Classmate
|
||||
selected: boolean
|
||||
setSelected: (value?: number | null) => void
|
||||
}
|
||||
|
||||
export const ContactMenu = ({
|
||||
contact,
|
||||
selected,
|
||||
setSelected,
|
||||
}: ContactMenuProps) => {
|
||||
const [visible, setVisible] = React.useState(selected)
|
||||
|
||||
const renderToggleButton = () => (
|
||||
|
@ -32,7 +43,7 @@ export const ContactMenu = ({ contact, selected, setSelected }) => {
|
|||
setSelected(null)
|
||||
}
|
||||
|
||||
const shouldDisplay = (option) => (option ? 'flex' : 'none')
|
||||
const shouldDisplay = (option?: string) => (option ? 'flex' : 'none')
|
||||
|
||||
return (
|
||||
<OverflowMenu
|
|
@ -10,14 +10,15 @@ import {
|
|||
import Personnummer from 'personnummer'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
Dimensions,
|
||||
Image,
|
||||
Linking,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
Dimensions,
|
||||
} from 'react-native'
|
||||
import ActionSheet from 'rn-actionsheet-module'
|
||||
import { useAsyncStorage } from 'use-async-storage'
|
||||
import { schema } from '../app.json'
|
||||
import {
|
||||
|
@ -26,15 +27,16 @@ import {
|
|||
SecureIcon,
|
||||
SelectIcon,
|
||||
} from './icon.component'
|
||||
import ActionSheet from 'rn-actionsheet-module'
|
||||
|
||||
const { width } = Dimensions.get('window')
|
||||
|
||||
export const Login = ({ navigation }) => {
|
||||
const { api, isLoggedIn } = useApi()
|
||||
const [cancelLoginRequest, setCancelLoginRequest] = useState(() => () => null)
|
||||
export const Login = () => {
|
||||
const { api } = useApi()
|
||||
const [cancelLoginRequest, setCancelLoginRequest] = useState<
|
||||
(() => Promise<void>) | (() => null)
|
||||
>(() => () => null)
|
||||
const [visible, showModal] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [cachedSsn, setCachedSsn] = useAsyncStorage('socialSecurityNumber', '')
|
||||
const [socialSecurityNumber, setSocialSecurityNumber] = useState('')
|
||||
const [valid, setValid] = useState(false)
|
||||
|
@ -55,11 +57,11 @@ export const Login = ({ navigation }) => {
|
|||
optionsAndroid: loginMethods,
|
||||
onCancelAndroidIndex: loginMethodIndex,
|
||||
}
|
||||
ActionSheet(options, (index) => setLoginMethodIndex(index))
|
||||
ActionSheet(options, (index: number) => setLoginMethodIndex(index))
|
||||
}
|
||||
useEffect(() => {
|
||||
if (loginMethodIndex !== parseInt(cachedLoginMethodIndex, 10)) {
|
||||
setCachedLoginMethodIndex(loginMethodIndex)
|
||||
setCachedLoginMethodIndex(loginMethodIndex.toString())
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loginMethodIndex])
|
||||
|
@ -88,18 +90,20 @@ export const Login = ({ navigation }) => {
|
|||
|
||||
useEffect(() => {
|
||||
api.on('login', loginHandler)
|
||||
return () => api.off('login', loginHandler)
|
||||
return () => {
|
||||
api.off('login', loginHandler)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
/* Helpers */
|
||||
const handleInput = (text) => {
|
||||
const handleInput = (text: string) => {
|
||||
setValid(Personnummer.valid(text))
|
||||
setCachedSsn(text)
|
||||
setSocialSecurityNumber(text)
|
||||
}
|
||||
|
||||
const openBankId = (token) => {
|
||||
const openBankId = (token: string) => {
|
||||
try {
|
||||
const redirect = loginMethodIndex === 0 ? encodeURIComponent(schema) : ''
|
||||
const bankIdUrl =
|
||||
|
@ -112,7 +116,7 @@ export const Login = ({ navigation }) => {
|
|||
}
|
||||
}
|
||||
|
||||
const startLogin = async (text) => {
|
||||
const startLogin = async (text: string) => {
|
||||
if (loginMethodIndex < 2) {
|
||||
showModal(true)
|
||||
const ssn = Personnummer.parse(text).format(true)
|
||||
|
@ -135,12 +139,6 @@ export const Login = ({ navigation }) => {
|
|||
}
|
||||
}
|
||||
|
||||
const clearInput = (props) => (
|
||||
<TouchableWithoutFeedback onPress={() => handleInput('')}>
|
||||
<CloseOutlineIcon {...props} />
|
||||
</TouchableWithoutFeedback>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image source={require('../assets/boys.png')} style={styles.image} />
|
||||
|
@ -152,10 +150,14 @@ export const Login = ({ navigation }) => {
|
|||
value={socialSecurityNumber}
|
||||
style={styles.pnrInput}
|
||||
accessoryLeft={PersonIcon}
|
||||
accessoryRight={clearInput}
|
||||
accessoryRight={(props) => (
|
||||
<TouchableWithoutFeedback onPress={() => handleInput('')}>
|
||||
<CloseOutlineIcon {...props} />
|
||||
</TouchableWithoutFeedback>
|
||||
)}
|
||||
keyboardType="numeric"
|
||||
onSubmitEditing={(event) => startLogin(event.nativeEvent.text)}
|
||||
caption={error?.message || ''}
|
||||
caption={error || ''}
|
||||
onChangeText={(text) => handleInput(text)}
|
||||
placeholder="Ditt personnr"
|
||||
/>
|
||||
|
@ -164,20 +166,18 @@ export const Login = ({ navigation }) => {
|
|||
<Button
|
||||
onPress={() => startLogin(socialSecurityNumber)}
|
||||
style={styles.loginButton}
|
||||
appearence="ghost"
|
||||
appearance="ghost"
|
||||
disabled={loginMethodIndex !== 2 && !valid}
|
||||
status="primary"
|
||||
accessoryLeft={SecureIcon}
|
||||
size="medium"
|
||||
>
|
||||
<Text adjustsFontSizeToFit style={styles.loginButtonText}>
|
||||
{loginMethods[loginMethodIndex]}
|
||||
</Text>
|
||||
{loginMethods[loginMethodIndex]}
|
||||
</Button>
|
||||
<Button
|
||||
onPress={selectLoginMethod}
|
||||
style={styles.loginMethodButton}
|
||||
appearence="ghost"
|
||||
appearance="ghost"
|
||||
status="primary"
|
||||
accessoryLeft={SelectIcon}
|
||||
size="medium"
|
||||
|
@ -194,7 +194,6 @@ export const Login = ({ navigation }) => {
|
|||
<Text style={styles.bankIdLoading}>Väntar på BankID...</Text>
|
||||
|
||||
<Button
|
||||
visible={!isLoggedIn}
|
||||
onPress={() => {
|
||||
cancelLoginRequest()
|
||||
showModal(false)
|
|
@ -1,43 +0,0 @@
|
|||
import { useApi } from '@skolplattformen/api-hooks'
|
||||
import { NavigationContainer } from '@react-navigation/native'
|
||||
import { createStackNavigator } from '@react-navigation/stack'
|
||||
import React from 'react'
|
||||
import { StatusBar } from 'react-native'
|
||||
import { schema } from '../app.json'
|
||||
import Absence from './absence.component'
|
||||
import { Child } from './child.component'
|
||||
import { Children } from './children.component'
|
||||
import { Auth } from './auth.component'
|
||||
import { NewsItem } from './newsItem.component'
|
||||
|
||||
const { Navigator, Screen } = createStackNavigator()
|
||||
|
||||
const linking = {
|
||||
prefixes: [schema],
|
||||
config: {
|
||||
screens: {
|
||||
Login: 'login',
|
||||
},
|
||||
},
|
||||
}
|
||||
export const AppNavigator = () => {
|
||||
const { isLoggedIn } = useApi()
|
||||
|
||||
return (
|
||||
<NavigationContainer linking={linking}>
|
||||
<StatusBar />
|
||||
<Navigator headerMode="none">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Screen name="Children" component={Children} />
|
||||
<Screen name="Child" component={Child} />
|
||||
<Screen name="NewsItem" component={NewsItem} />
|
||||
<Screen name="Absence" component={Absence} />
|
||||
</>
|
||||
) : (
|
||||
<Screen name="Login" component={Auth} />
|
||||
)}
|
||||
</Navigator>
|
||||
</NavigationContainer>
|
||||
)
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
import { useApi } from '@skolplattformen/api-hooks'
|
||||
import { NavigationContainer } from '@react-navigation/native'
|
||||
import { createStackNavigator } from '@react-navigation/stack'
|
||||
import React from 'react'
|
||||
|
@ -25,22 +26,8 @@ export type RootStackParamList = {
|
|||
Absence: { child: ChildType }
|
||||
}
|
||||
|
||||
export type SigninStackParamList = {
|
||||
Login: undefined
|
||||
}
|
||||
|
||||
const { Navigator, Screen } = createStackNavigator()
|
||||
|
||||
const HomeNavigator = () => (
|
||||
<Navigator headerMode="none">
|
||||
<Screen name="Login" component={Auth} />
|
||||
<Screen name="Children" component={Children} />
|
||||
<Screen name="Child" component={Child} />
|
||||
<Screen name="NewsItem" component={NewsItem} />
|
||||
<Screen name="Absence" component={Absence} />
|
||||
</Navigator>
|
||||
)
|
||||
|
||||
const linking = {
|
||||
prefixes: [schema],
|
||||
config: {
|
||||
|
@ -50,10 +37,23 @@ const linking = {
|
|||
},
|
||||
}
|
||||
export const AppNavigator = () => {
|
||||
const { isLoggedIn } = useApi()
|
||||
|
||||
return (
|
||||
<NavigationContainer linking={linking}>
|
||||
<StatusBar />
|
||||
<HomeNavigator />
|
||||
<Navigator headerMode="none">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Screen name="Children" component={Children} />
|
||||
<Screen name="Child" component={Child} />
|
||||
<Screen name="NewsItem" component={NewsItem} />
|
||||
<Screen name="Absence" component={Absence} />
|
||||
</>
|
||||
) : (
|
||||
<Screen name="Login" component={Auth} />
|
||||
)}
|
||||
</Navigator>
|
||||
</NavigationContainer>
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue