[NEW] Show server history (#2421)

* Add dropdown

Signed-off-by: Ezequiel De Oliveira <ezequiel1de1oliveira@gmail.com>

* Adding new table to serverSchema

Signed-off-by: Ezequiel De Oliveira <ezequiel1de1oliveira@gmail.com>

* Saving if not exists

Signed-off-by: Ezequiel De Oliveira <ezequiel1de1oliveira@gmail.com>

* list of visited servers finished

Signed-off-by: Ezequiel De Oliveira <ezequiel1de1oliveira@gmail.com>

* Fix lint

Signed-off-by: Ezequiel De Oliveira <ezequiel1de1oliveira@gmail.com>

* Rename ServerLinks to ServersHistory

* Refactor

* Save username

* Sort servers desc

* ServerInput

* Item

* Refactor

* Layout tweaks

* Layout

* query by text

* Small refactor

* Redirecting to login

* Save username for oauth

* Fix keyboard persist

* Add tests

* Unnecessary yield

* Stop rendering FlatList logic when there's no servers on history

* Dismiss keyboard and autocomplete when tapped outside server TextInput

Co-authored-by: Diego Mello <diegolmello@gmail.com>
This commit is contained in:
Ezequiel de Oliveira 2020-09-11 14:10:16 -03:00 committed by GitHub
parent 334140df0d
commit d37678b354
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 373 additions and 51 deletions

View File

@ -23,11 +23,13 @@ export function selectServerFailure() {
};
}
export function serverRequest(server, certificate = null) {
export function serverRequest(server, certificate = null, username = null, fromServerHistory = false) {
return {
type: SERVER.REQUEST,
server,
certificate
certificate,
username,
fromServerHistory
};
}

View File

@ -23,14 +23,21 @@ export const FormContainerInner = ({ children }) => (
</View>
);
const FormContainer = ({ children, theme, testID }) => (
const FormContainer = ({
children, theme, testID, ...props
}) => (
<KeyboardView
style={{ backgroundColor: themes[theme].backgroundColor }}
contentContainerStyle={sharedStyles.container}
keyboardVerticalOffset={128}
>
<StatusBar theme={theme} />
<ScrollView {...scrollPersistTaps} style={sharedStyles.container} contentContainerStyle={[sharedStyles.containerScrollView, styles.scrollView]}>
<ScrollView
style={sharedStyles.container}
contentContainerStyle={[sharedStyles.containerScrollView, styles.scrollView]}
{...scrollPersistTaps}
{...props}
>
<SafeAreaView testID={testID} theme={theme} style={{ backgroundColor: themes[theme].backgroundColor }}>
{children}
<AppVersion theme={theme} />

View File

@ -16,6 +16,7 @@ import Permission from './model/Permission';
import SlashCommand from './model/SlashCommand';
import User from './model/User';
import Server from './model/Server';
import ServersHistory from './model/ServersHistory';
import serversSchema from './schema/servers';
import appSchema from './schema/app';
@ -71,7 +72,7 @@ class DB {
schema: serversSchema,
migrations: serversMigrations
}),
modelClasses: [Server, User],
modelClasses: [Server, User, ServersHistory],
actionsEnabled: true
})
}

View File

@ -0,0 +1,12 @@
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly } from '@nozbe/watermelondb/decorators';
export default class ServersHistory extends Model {
static table = 'servers_history';
@field('url') url;
@field('username') username;
@readonly @date('updated_at') updatedAt
}

View File

@ -1,4 +1,4 @@
import { schemaMigrations, addColumns } from '@nozbe/watermelondb/Schema/migrations';
import { schemaMigrations, addColumns, createTable } from '@nozbe/watermelondb/Schema/migrations';
export default schemaMigrations({
migrations: [
@ -70,6 +70,19 @@ export default schemaMigrations({
]
})
]
},
{
toVersion: 9,
steps: [
createTable({
name: 'servers_history',
columns: [
{ name: 'url', type: 'string', isIndexed: true },
{ name: 'username', type: 'string', isOptional: true },
{ name: 'updated_at', type: 'number' }
]
})
]
}
]
});

View File

@ -1,7 +1,7 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb';
export default appSchema({
version: 8,
version: 9,
tables: [
tableSchema({
name: 'users',
@ -34,6 +34,14 @@ export default appSchema({
{ name: 'enterprise_modules', type: 'string', isOptional: true },
{ name: 'e2e_enable', type: 'boolean', isOptional: true }
]
}),
tableSchema({
name: 'servers_history',
columns: [
{ name: 'url', type: 'string', isIndexed: true },
{ name: 'username', type: 'string', isOptional: true },
{ name: 'updated_at', type: 'number' }
]
})
]
});

View File

@ -4,6 +4,7 @@ import {
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import moment from 'moment';
import 'moment/min/locales';
import { Q } from '@nozbe/watermelondb';
import * as types from '../actions/actionsTypes';
import {
@ -52,6 +53,26 @@ const handleLoginRequest = function* handleLoginRequest({ credentials, logoutOnE
} else {
const server = yield select(getServer);
yield localAuthenticate(server);
// Saves username on server history
const serversDB = database.servers;
const serversHistoryCollection = serversDB.collections.get('servers_history');
yield serversDB.action(async() => {
try {
const serversHistory = await serversHistoryCollection.query(Q.where('url', server)).fetch();
if (serversHistory?.length) {
const serverHistoryRecord = serversHistory[0];
// this is updating on every login just to save `updated_at`
// keeping this server as the most recent on autocomplete order
await serverHistoryRecord.update((s) => {
s.username = result.username;
});
}
} catch (e) {
log(e);
}
});
yield put(loginSuccess(result));
}
} catch (e) {

View File

@ -1,6 +1,7 @@
import { put, takeLatest } from 'redux-saga/effects';
import { Alert } from 'react-native';
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import { Q } from '@nozbe/watermelondb';
import semver from 'semver';
import Navigation from '../lib/Navigation';
@ -131,18 +132,39 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch
}
};
const handleServerRequest = function* handleServerRequest({ server, certificate }) {
const handleServerRequest = function* handleServerRequest({
server, certificate, username, fromServerHistory
}) {
try {
if (certificate) {
yield UserPreferences.setMapAsync(extractHostname(server), certificate);
}
const serverInfo = yield getServerInfo({ server });
const serversDB = database.servers;
const serversHistoryCollection = serversDB.collections.get('servers_history');
if (serverInfo) {
yield RocketChat.getLoginServices(server);
yield RocketChat.getLoginSettings({ server });
Navigation.navigate('WorkspaceView');
if (fromServerHistory) {
Navigation.navigate('LoginView', { username });
}
yield serversDB.action(async() => {
try {
const serversHistory = await serversHistoryCollection.query(Q.where('url', server)).fetch();
if (!serversHistory?.length) {
await serversHistoryCollection.create((s) => {
s.url = server;
});
}
} catch (e) {
log(e);
}
});
yield put(selectServerRequest(server, serverInfo.version, false));
}
} catch (e) {

View File

@ -56,6 +56,7 @@ class LoginView extends React.Component {
static propTypes = {
navigation: PropTypes.object,
route: PropTypes.object,
Site_Name: PropTypes.string,
Accounts_RegistrationForm: PropTypes.string,
Accounts_RegistrationForm_LinkReplacementText: PropTypes.string,
@ -74,7 +75,7 @@ class LoginView extends React.Component {
constructor(props) {
super(props);
this.state = {
user: '',
user: props.route.params?.username ?? '',
password: ''
};
}
@ -123,6 +124,7 @@ class LoginView extends React.Component {
}
renderUserForm = () => {
const { user } = this.state;
const {
Accounts_EmailOrUsernamePlaceholder, Accounts_PasswordPlaceholder, Accounts_PasswordReset, Accounts_RegistrationForm_LinkReplacementText, isFetching, theme, Accounts_ShowFormLogin
} = this.props;
@ -146,6 +148,7 @@ class LoginView extends React.Component {
textContentType='username'
autoCompleteType='username'
theme={theme}
value={user}
/>
<TextInput
label='Password'

View File

@ -0,0 +1,53 @@
import React from 'react';
import {
View, StyleSheet, Text
} from 'react-native';
import PropTypes from 'prop-types';
import { BorderlessButton } from 'react-native-gesture-handler';
import { themes } from '../../../constants/colors';
import { CustomIcon } from '../../../lib/Icons';
import sharedStyles from '../../Styles';
import Touch from '../../../utils/touch';
const styles = StyleSheet.create({
container: {
height: 56,
paddingHorizontal: 15,
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center'
},
content: {
flex: 1,
flexDirection: 'column'
},
server: {
...sharedStyles.textMedium,
fontSize: 16
}
});
const Item = ({
item, theme, onPress, onDelete
}) => (
<Touch style={styles.container} onPress={() => onPress(item.url)} theme={theme} testID={`server-history-${ item.url }`}>
<View style={styles.content}>
<Text style={[styles.server, { color: themes[theme].bodyText }]}>{item.url}</Text>
<Text style={[styles.username, { color: themes[theme].auxiliaryText }]}>{item.username}</Text>
</View>
<BorderlessButton onPress={() => onDelete(item)} testID={`server-history-delete-${ item.url }`}>
<CustomIcon name='delete' size={24} color={themes[theme].auxiliaryText} />
</BorderlessButton>
</Touch>
);
Item.propTypes = {
item: PropTypes.object,
theme: PropTypes.string,
onPress: PropTypes.func,
onDelete: PropTypes.func
};
export default Item;

View File

@ -0,0 +1,87 @@
import React, { useState } from 'react';
import { View, FlatList, StyleSheet } from 'react-native';
import PropTypes from 'prop-types';
import TextInput from '../../../containers/TextInput';
import { themes } from '../../../constants/colors';
import Item from './Item';
import Separator from '../../../containers/Separator';
const styles = StyleSheet.create({
container: {
zIndex: 1,
marginTop: 24,
marginBottom: 32
},
inputContainer: {
marginTop: 0,
marginBottom: 0
},
serverHistory: {
maxHeight: 180,
width: '100%',
top: '100%',
zIndex: 1,
position: 'absolute',
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 2,
borderTopWidth: 0
}
});
const ServerInput = ({
text,
theme,
serversHistory,
onChangeText,
onSubmit,
onDelete,
onPressServerHistory
}) => {
const [focused, setFocused] = useState(false);
return (
<View style={styles.container}>
<TextInput
label='Enter workspace URL'
placeholder='Ex. your-company.rocket.chat'
containerStyle={styles.inputContainer}
value={text}
returnKeyType='send'
onChangeText={onChangeText}
testID='new-server-view-input'
onSubmitEditing={onSubmit}
clearButtonMode='while-editing'
keyboardType='url'
textContentType='URL'
theme={theme}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
{
focused && serversHistory?.length
? (
<View style={[styles.serverHistory, { backgroundColor: themes[theme].backgroundColor, borderColor: themes[theme].separatorColor }]}>
<FlatList
data={serversHistory}
renderItem={({ item }) => <Item item={item} theme={theme} onPress={() => onPressServerHistory(item)} onDelete={onDelete} />}
ItemSeparatorComponent={() => <Separator theme={theme} />}
keyExtractor={item => item.id}
/>
</View>
) : null
}
</View>
);
};
ServerInput.propTypes = {
text: PropTypes.string,
theme: PropTypes.string,
serversHistory: PropTypes.array,
onChangeText: PropTypes.func,
onSubmit: PropTypes.func,
onDelete: PropTypes.func,
onPressServerHistory: PropTypes.func
};
export default ServerInput;

View File

@ -1,42 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Text, Keyboard, StyleSheet, TouchableOpacity, View, Alert, BackHandler
Text, Keyboard, StyleSheet, View, Alert, BackHandler
} from 'react-native';
import { connect } from 'react-redux';
import * as FileSystem from 'expo-file-system';
import DocumentPicker from 'react-native-document-picker';
import { Base64 } from 'js-base64';
import parse from 'url-parse';
import { Q } from '@nozbe/watermelondb';
import UserPreferences from '../lib/userPreferences';
import EventEmitter from '../utils/events';
import { selectServerRequest, serverRequest } from '../actions/server';
import { inviteLinksClear as inviteLinksClearAction } from '../actions/inviteLinks';
import sharedStyles from './Styles';
import Button from '../containers/Button';
import TextInput from '../containers/TextInput';
import OrSeparator from '../containers/OrSeparator';
import FormContainer, { FormContainerInner } from '../containers/FormContainer';
import I18n from '../i18n';
import { isIOS } from '../utils/deviceInfo';
import { themes } from '../constants/colors';
import log, { logEvent, events } from '../utils/log';
import { animateNextTransition } from '../utils/layoutAnimation';
import { withTheme } from '../theme';
import { setBasicAuth, BASIC_AUTH_KEY } from '../utils/fetch';
import { CloseModalButton } from '../containers/HeaderButton';
import { showConfirmationAlert } from '../utils/info';
import { TouchableOpacity } from 'react-native-gesture-handler';
import UserPreferences from '../../lib/userPreferences';
import EventEmitter from '../../utils/events';
import { selectServerRequest, serverRequest } from '../../actions/server';
import { inviteLinksClear as inviteLinksClearAction } from '../../actions/inviteLinks';
import sharedStyles from '../Styles';
import Button from '../../containers/Button';
import OrSeparator from '../../containers/OrSeparator';
import FormContainer, { FormContainerInner } from '../../containers/FormContainer';
import I18n from '../../i18n';
import { isIOS } from '../../utils/deviceInfo';
import { themes } from '../../constants/colors';
import log, { logEvent, events } from '../../utils/log';
import { animateNextTransition } from '../../utils/layoutAnimation';
import { withTheme } from '../../theme';
import { setBasicAuth, BASIC_AUTH_KEY } from '../../utils/fetch';
import { CloseModalButton } from '../../containers/HeaderButton';
import { showConfirmationAlert } from '../../utils/info';
import database from '../../lib/database';
import ServerInput from './ServerInput';
const styles = StyleSheet.create({
title: {
...sharedStyles.textBold,
fontSize: 22
},
inputContainer: {
marginTop: 24,
marginBottom: 32
},
certificatePicker: {
marginBottom: 32,
alignItems: 'center',
@ -84,12 +83,17 @@ class NewServerView extends React.Component {
this.state = {
text: '',
connectingOpen: false,
certificate: null
certificate: null,
serversHistory: []
};
EventEmitter.addEventListener('NewServer', this.handleNewServerEvent);
BackHandler.addEventListener('hardwareBackPress', this.handleBackPress);
}
componentDidMount() {
this.queryServerHistory();
}
componentDidUpdate(prevProps) {
const { adding } = this.props;
if (prevProps.adding !== adding) {
@ -122,6 +126,29 @@ class NewServerView extends React.Component {
onChangeText = (text) => {
this.setState({ text });
this.queryServerHistory(text);
}
queryServerHistory = async(text) => {
const db = database.servers;
try {
const serversHistoryCollection = db.collections.get('servers_history');
let whereClause = [
Q.where('username', Q.notEq(null)),
Q.experimentalSortBy('updated_at', Q.desc),
Q.experimentalTake(3)
];
if (text) {
whereClause = [
...whereClause,
Q.where('url', Q.like(`%${ Q.sanitizeLikeString(text) }%`))
];
}
const serversHistory = await serversHistoryCollection.query(...whereClause).fetch();
this.setState({ serversHistory });
} catch {
// Do nothing
}
}
close = () => {
@ -138,7 +165,11 @@ class NewServerView extends React.Component {
connectServer(server);
}
submit = async() => {
onPressServerHistory = (serverHistory) => {
this.setState({ text: serverHistory?.url }, () => this.submit({ fromServerHistory: true, username: serverHistory?.username }));
}
submit = async({ fromServerHistory = false, username }) => {
logEvent(events.NEWSERVER_CONNECT_TO_WORKSPACE);
const { text, certificate } = this.state;
const { connectServer } = this.props;
@ -164,9 +195,13 @@ class NewServerView extends React.Component {
Keyboard.dismiss();
const server = this.completeUrl(text);
await this.basicAuth(server, text);
if (fromServerHistory) {
connectServer(server, cert, username, true);
} else {
connectServer(server, cert);
}
}
}
connectOpen = () => {
logEvent(events.NEWSERVER_JOIN_OPEN_WORKSPACE);
@ -251,6 +286,19 @@ class NewServerView extends React.Component {
});
}
deleteServerHistory = async(item) => {
const { serversHistory } = this.state;
const db = database.servers;
try {
await db.action(async() => {
await item.destroyPermanently();
});
this.setState({ serversHistory: serversHistory.filter(server => server.id !== item.id) });
} catch {
// Nothing
}
}
renderCertificatePicker = () => {
const { certificate } = this.state;
const { theme } = this.props;
@ -283,24 +331,25 @@ class NewServerView extends React.Component {
render() {
const { connecting, theme } = this.props;
const { text, connectingOpen } = this.state;
const {
text, connectingOpen, serversHistory
} = this.state;
return (
<FormContainer theme={theme} testID='new-server-view'>
<FormContainer
theme={theme}
testID='new-server-view'
keyboardShouldPersistTaps='never'
>
<FormContainerInner>
<Text style={[styles.title, { color: themes[theme].titleText }]}>{I18n.t('Join_your_workspace')}</Text>
<TextInput
label='Enter workspace URL'
placeholder='Ex. your-company.rocket.chat'
containerStyle={styles.inputContainer}
value={text}
returnKeyType='send'
onChangeText={this.onChangeText}
testID='new-server-view-input'
onSubmitEditing={this.submit}
clearButtonMode='while-editing'
keyboardType='url'
textContentType='URL'
<ServerInput
text={text}
theme={theme}
serversHistory={serversHistory}
onChangeText={this.onChangeText}
onSubmit={this.submit}
onDelete={this.deleteServerHistory}
onPressServerHistory={this.onPressServerHistory}
/>
<Button
title={I18n.t('Connect')}
@ -338,7 +387,7 @@ const mapStateToProps = state => ({
});
const mapDispatchToProps = dispatch => ({
connectServer: (server, certificate) => dispatch(serverRequest(server, certificate)),
connectServer: (server, certificate, username, fromServerHistory) => dispatch(serverRequest(server, certificate, username, fromServerHistory)),
selectServer: server => dispatch(selectServerRequest(server)),
inviteLinksClear: () => dispatch(inviteLinksClearAction())
});

View File

@ -0,0 +1,44 @@
const {
device, expect, element, by, waitFor
} = require('detox');
const { login, navigateToLogin, logout, tapBack } = require('../../helpers/app');
const data = require('../../data');
describe('Server history', () => {
before(async() => {
await device.launchApp({ permissions: { notifications: 'YES' }, delete: true });
});
describe('Usage', () => {
it('should login, save server as history and logout', async() => {
await navigateToLogin();
await login(data.users.regular.username, data.users.regular.password);
await logout();
await element(by.id('join-workspace')).tap();
await waitFor(element(by.id('new-server-view'))).toBeVisible().withTimeout(60000);
})
it('should show servers history', async() => {
await element(by.id('new-server-view-input')).tap();
await waitFor(element(by.id(`server-history-${ data.server }`))).toBeVisible().withTimeout(2000);
});
it('should tap on a server history and navigate to login', async() => {
await element(by.id(`server-history-${ data.server }`)).tap();
await waitFor(element(by.id('login-view'))).toBeVisible().withTimeout(2000);
await expect(element(by.id('login-view-email'))).toHaveText(data.users.regular.username);
});
it('should delete server from history', async() => {
await tapBack();
await waitFor(element(by.id('workspace-view'))).toBeVisible().withTimeout(2000);
await tapBack();
await waitFor(element(by.id('new-server-view'))).toBeVisible().withTimeout(2000);
await element(by.id('new-server-view-input')).tap();
await waitFor(element(by.id(`server-history-${ data.server }`))).toBeVisible().withTimeout(2000);
await element(by.id(`server-history-delete-${ data.server }`)).tap();
await element(by.id('new-server-view-input')).tap();
await waitFor(element(by.id(`server-history-${ data.server }`))).toBeNotVisible().withTimeout(2000);
});
});
});