[IMPROVEMENT] Verify Enterprise status on Omnichannel (#2399)

* Add enterpriseModules on Redux

* Fetch enterprise modules and put on redux

* hasLicense

* Clear modules

* Hide omnichannel rooms

* Minor refactor

* Hide omnichannel toggle

* Check license on user status

* Apply on search

* lint

* Look for 'livechat-enterprise'

* One module is enough to enable the features

* Unhide omnichannel rooms

* Sort tweaks

* Move omnichannel toggle to RoomsListView

* Remove omnichannel toggle from SettingsView

* Fix toggle

* Ask to enable omnichannel

* Lint

* Fix issues found on review
This commit is contained in:
Diego Mello 2020-08-21 10:38:50 -03:00 committed by GitHub
parent 54c4614e2e
commit b06bf7fcb5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 275 additions and 114 deletions

View File

@ -66,3 +66,4 @@ export const INVITE_LINKS = createRequestTypes('INVITE_LINKS', [
]);
export const SETTINGS = createRequestTypes('SETTINGS', ['CLEAR', 'ADD']);
export const APP_STATE = createRequestTypes('APP_STATE', ['FOREGROUND', 'BACKGROUND']);
export const ENTERPRISE_MODULES = createRequestTypes('ENTERPRISE_MODULES', ['CLEAR', 'SET']);

View File

@ -0,0 +1,14 @@
import { ENTERPRISE_MODULES } from './actionsTypes';
export function setEnterpriseModules(modules) {
return {
type: ENTERPRISE_MODULES.SET,
payload: modules
};
}
export function clearEnterpriseModules() {
return {
type: ENTERPRISE_MODULES.CLEAR
};
}

View File

@ -339,6 +339,7 @@ export default {
Offline: 'Offline',
Oops: 'Oops!',
Omnichannel: 'Omnichannel',
Omnichannel_enable_alert: 'You\'re not available on Omnichannel. Would you like to be available?',
Onboarding_description: 'A workspace is your team or organizations space to collaborate. Ask the workspace admin for address to join or create one for your team.',
Onboarding_join_workspace: 'Join a workspace',
Onboarding_subtitle: 'Beyond Team Collaboration',

View File

@ -314,6 +314,8 @@ export default {
Not_RC_Server: 'Este não é um servidor Rocket.Chat.\n{{contact}}',
No_available_agents_to_transfer: 'Nenhum agente disponível para transferência',
Offline: 'Offline',
Omnichannel: 'Omnichannel',
Omnichannel_enable_alert: 'Você não está disponível no Omnichannel. Você quer ficar disponível?',
Oops: 'Ops!',
Onboarding_description: 'Workspace é o espaço de colaboração do seu time ou organização. Peça um convite ou o endereço ao seu administrador ou crie uma workspace para o seu time.',
Onboarding_join_workspace: 'Entre numa workspace',

View File

@ -27,4 +27,6 @@ export default class Server extends Model {
@field('biometry') biometry;
@field('unique_id') uniqueID;
@field('enterprise_modules') enterpriseModules;
}

View File

@ -37,6 +37,17 @@ export default schemaMigrations({
]
})
]
},
{
toVersion: 6,
steps: [
addColumns({
table: 'servers',
columns: [
{ name: 'enterprise_modules', type: 'string', isOptional: true }
]
})
]
}
]
});

View File

@ -1,7 +1,7 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb';
export default appSchema({
version: 5,
version: 6,
tables: [
tableSchema({
name: 'users',
@ -29,7 +29,8 @@ export default appSchema({
{ name: 'auto_lock', type: 'boolean', isOptional: true },
{ name: 'auto_lock_time', type: 'number', isOptional: true },
{ name: 'biometry', type: 'boolean', isOptional: true },
{ name: 'unique_id', type: 'string', isOptional: true }
{ name: 'unique_id', type: 'string', isOptional: true },
{ name: 'enterprise_modules', type: 'string', isOptional: true }
]
})
]

View File

@ -0,0 +1,63 @@
import semver from 'semver';
import reduxStore from '../createStore';
import database from '../database';
import log from '../../utils/log';
import { setEnterpriseModules as setEnterpriseModulesAction, clearEnterpriseModules } from '../../actions/enterpriseModules';
export const LICENSE_OMNICHANNEL_MOBILE_ENTERPRISE = 'omnichannel-mobile-enterprise';
export const LICENSE_LIVECHAT_ENTERPRISE = 'livechat-enterprise';
export async function setEnterpriseModules() {
try {
const { server: serverId } = reduxStore.getState().server;
const serversDB = database.servers;
const serversCollection = serversDB.collections.get('servers');
const server = await serversCollection.find(serverId);
if (server.enterpriseModules) {
reduxStore.dispatch(setEnterpriseModulesAction(server.enterpriseModules.split(',')));
return;
}
reduxStore.dispatch(clearEnterpriseModules());
} catch (e) {
log(e);
}
}
export function getEnterpriseModules() {
return new Promise(async(resolve) => {
try {
const { version: serverVersion, server: serverId } = reduxStore.getState().server;
if (serverVersion && semver.gte(semver.coerce(serverVersion), '3.1.0')) {
// RC 3.1.0
const enterpriseModules = await this.methodCallWrapper('license:getModules');
if (enterpriseModules) {
const serversDB = database.servers;
const serversCollection = serversDB.collections.get('servers');
const server = await serversCollection.find(serverId);
await serversDB.action(async() => {
await server.update((s) => {
s.enterpriseModules = enterpriseModules.join(',');
});
});
reduxStore.dispatch(setEnterpriseModulesAction(enterpriseModules));
return resolve();
}
}
reduxStore.dispatch(clearEnterpriseModules());
} catch (e) {
log(e);
}
return resolve();
});
}
export function hasLicense(module) {
const { enterpriseModules } = reduxStore.getState();
return enterpriseModules.includes(module);
}
export function isOmnichannelModuleAvailable() {
const { enterpriseModules } = reduxStore.getState();
return [LICENSE_OMNICHANNEL_MOBILE_ENTERPRISE, LICENSE_LIVECHAT_ENTERPRISE].some(module => enterpriseModules.includes(module));
}

View File

@ -244,7 +244,9 @@ export default function subscribeRooms() {
const [, ev] = ddpMessage.fields.eventName.split('/');
if (/userData/.test(ev)) {
const [{ diff }] = ddpMessage.fields.args;
store.dispatch(setUser({ statusLivechat: diff?.statusLivechat }));
if (diff?.statusLivechat) {
store.dispatch(setUser({ statusLivechat: diff.statusLivechat }));
}
}
if (/subscriptions/.test(ev)) {
if (type === 'removed') {

View File

@ -29,6 +29,9 @@ import getSettings, { getLoginSettings, setSettings } from './methods/getSetting
import getRooms from './methods/getRooms';
import getPermissions from './methods/getPermissions';
import { getCustomEmojis, setCustomEmojis } from './methods/getCustomEmojis';
import {
getEnterpriseModules, setEnterpriseModules, hasLicense, isOmnichannelModuleAvailable
} from './methods/enterpriseModules';
import getSlashCommands from './methods/getSlashCommands';
import getRoles from './methods/getRoles';
import canOpenRoom from './methods/canOpenRoom';
@ -519,6 +522,7 @@ const RocketChat = {
} else if (!filterUsers && filterRooms) {
data = data.filter(item => item.t !== 'd' || RocketChat.isGroupChat(item));
}
data = data.slice(0, 7);
data = data.map((sub) => {
@ -620,6 +624,10 @@ const RocketChat = {
getPermissions,
getCustomEmojis,
setCustomEmojis,
getEnterpriseModules,
setEnterpriseModules,
hasLicense,
isOmnichannelModuleAvailable,
getSlashCommands,
getRoles,
parseSettings: settings => settings.reduce((ret, item) => {

View File

@ -0,0 +1,14 @@
import { ENTERPRISE_MODULES } from '../actions/actionsTypes';
const initialState = [];
export default (state = initialState, action) => {
switch (action.type) {
case ENTERPRISE_MODULES.SET:
return action.payload;
case ENTERPRISE_MODULES.CLEAR:
return initialState;
default:
return state;
}
};

View File

@ -17,6 +17,7 @@ import usersTyping from './usersTyping';
import inviteLinks from './inviteLinks';
import createDiscussion from './createDiscussion';
import inquiry from './inquiry';
import enterpriseModules from './enterpriseModules';
export default combineReducers({
settings,
@ -36,5 +37,6 @@ export default combineReducers({
usersTyping,
inviteLinks,
createDiscussion,
inquiry
inquiry,
enterpriseModules
});

View File

@ -14,7 +14,7 @@ import {
loginFailure, loginSuccess, setUser, logout
} from '../actions/login';
import { roomsRequest } from '../actions/rooms';
import { inquiryRequest } from '../actions/inquiry';
import { inquiryRequest, inquiryReset } from '../actions/inquiry';
import { toMomentLocale } from '../utils/moment';
import RocketChat from '../lib/rocketchat';
import log, { logEvent, events } from '../utils/log';
@ -85,6 +85,14 @@ const fetchUsersPresence = function* fetchUserPresence() {
RocketChat.subscribeUsersPresence();
};
const fetchEnterpriseModules = function* fetchEnterpriseModules({ user }) {
yield RocketChat.getEnterpriseModules();
if (user && user.statusLivechat === 'available' && RocketChat.isOmnichannelModuleAvailable()) {
yield put(inquiryRequest());
}
};
const handleLoginSuccess = function* handleLoginSuccess({ user }) {
try {
const adding = yield select(state => state.server.adding);
@ -94,13 +102,13 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) {
const server = yield select(getServer);
yield put(roomsRequest());
yield put(inquiryRequest());
yield fork(fetchPermissions);
yield fork(fetchCustomEmojis);
yield fork(fetchRoles);
yield fork(fetchSlashCommands);
yield fork(registerPushToken);
yield fork(fetchUsersPresence);
yield fork(fetchEnterpriseModules, { user });
I18n.locale = user.language;
moment.locale(toMomentLocale(user.language));
@ -210,8 +218,12 @@ const handleSetUser = function* handleSetUser({ user }) {
yield put(setActiveUsers({ [userId]: user }));
}
if (user && user.statusLivechat) {
yield put(inquiryRequest());
if (user?.statusLivechat && RocketChat.isOmnichannelModuleAvailable()) {
if (user.statusLivechat === 'available') {
yield put(inquiryRequest());
} else {
yield put(inquiryReset());
}
}
};

View File

@ -109,6 +109,7 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch
// and block the selectServerSuccess raising multiples errors
RocketChat.setSettings();
RocketChat.setCustomEmojis();
RocketChat.setEnterpriseModules();
let serverInfo;
if (fetchVersion) {

View File

@ -0,0 +1,83 @@
import React, { memo, useState, useEffect } from 'react';
import {
View, Text, StyleSheet, Switch
} from 'react-native';
import PropTypes from 'prop-types';
import Touch from '../../../utils/touch';
import { CustomIcon } from '../../../lib/Icons';
import I18n from '../../../i18n';
import styles from '../styles';
import { themes, SWITCH_TRACK_COLOR } from '../../../constants/colors';
import { withTheme } from '../../../theme';
import UnreadBadge from '../../../presentation/UnreadBadge';
import RocketChat from '../../../lib/rocketchat';
const OmnichannelStatus = memo(({
searching, goQueue, theme, queueSize, inquiryEnabled, user
}) => {
if (searching > 0 || !(RocketChat.isOmnichannelModuleAvailable() && user?.roles?.includes('livechat-agent'))) {
return null;
}
const [status, setStatus] = useState(user?.statusLivechat === 'available');
useEffect(() => {
setStatus(user.statusLivechat === 'available');
}, [user.statusLivechat]);
const toggleLivechat = async() => {
try {
setStatus(v => !v);
await RocketChat.changeLivechatStatus();
} catch {
setStatus(v => !v);
}
};
return (
<Touch
onPress={goQueue}
theme={theme}
style={{ backgroundColor: themes[theme].headerSecondaryBackground }}
>
<View
style={[
styles.dropdownContainerHeader,
{ borderBottomWidth: StyleSheet.hairlineWidth, borderColor: themes[theme].separatorColor }
]}
>
<CustomIcon style={[styles.queueIcon, { color: themes[theme].auxiliaryText }]} size={22} name='omnichannel' />
<Text style={[styles.queueToggleText, { color: themes[theme].auxiliaryText }]}>{I18n.t('Omnichannel')}</Text>
{inquiryEnabled
? (
<UnreadBadge
style={styles.queueIcon}
unread={queueSize}
theme={theme}
/>
)
: null}
<Switch
style={styles.omnichannelToggle}
value={status}
trackColor={SWITCH_TRACK_COLOR}
onValueChange={toggleLivechat}
/>
</View>
</Touch>
);
});
OmnichannelStatus.propTypes = {
searching: PropTypes.bool,
goQueue: PropTypes.func,
queueSize: PropTypes.number,
inquiryEnabled: PropTypes.bool,
theme: PropTypes.string,
user: PropTypes.shape({
roles: PropTypes.array,
statusLivechat: PropTypes.string
})
};
export default withTheme(OmnichannelStatus);

View File

@ -1,49 +0,0 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import PropTypes from 'prop-types';
import Touch from '../../../utils/touch';
import I18n from '../../../i18n';
import styles from '../styles';
import { themes } from '../../../constants/colors';
import { withTheme } from '../../../theme';
import UnreadBadge from '../../../presentation/UnreadBadge';
const Queue = React.memo(({
searching, goQueue, queueSize, inquiryEnabled, theme
}) => {
if (searching > 0 || !inquiryEnabled) {
return null;
}
return (
<Touch
onPress={goQueue}
theme={theme}
style={{ backgroundColor: themes[theme].headerSecondaryBackground }}
>
<View
style={[
styles.dropdownContainerHeader,
{ borderBottomWidth: StyleSheet.hairlineWidth, borderColor: themes[theme].separatorColor }
]}
>
<Text style={[styles.sortToggleText, { color: themes[theme].auxiliaryText }]}>{I18n.t('Queued_chats')}</Text>
<UnreadBadge
style={styles.sortIcon}
unread={queueSize}
theme={theme}
/>
</View>
</Touch>
);
});
Queue.propTypes = {
searching: PropTypes.bool,
goQueue: PropTypes.func,
queueSize: PropTypes.number,
inquiryEnabled: PropTypes.bool,
theme: PropTypes.string
};
export default withTheme(Queue);

View File

@ -28,8 +28,8 @@ const Sort = React.memo(({
{ borderBottomWidth: StyleSheet.hairlineWidth, borderColor: themes[theme].separatorColor }
]}
>
<CustomIcon style={[styles.sortIcon, { color: themes[theme].auxiliaryText }]} size={22} name='sort' />
<Text style={[styles.sortToggleText, { color: themes[theme].auxiliaryText }]}>{I18n.t('Sorting_by', { key: I18n.t(sortBy === 'alphabetical' ? 'name' : 'activity') })}</Text>
<CustomIcon style={[styles.sortIcon, { color: themes[theme].auxiliaryText }]} size={22} name='sort-az' />
</View>
</Touch>
);

View File

@ -1,8 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import Queue from './Queue';
import Sort from './Sort';
import OmnichannelStatus from './OmnichannelStatus';
const ListHeader = React.memo(({
searching,
@ -10,11 +10,12 @@ const ListHeader = React.memo(({
toggleSort,
goQueue,
queueSize,
inquiryEnabled
inquiryEnabled,
user
}) => (
<>
<Sort searching={searching} sortBy={sortBy} toggleSort={toggleSort} />
<Queue searching={searching} goQueue={goQueue} queueSize={queueSize} inquiryEnabled={inquiryEnabled} />
<OmnichannelStatus searching={searching} goQueue={goQueue} inquiryEnabled={inquiryEnabled} queueSize={queueSize} user={user} />
</>
));
@ -24,7 +25,8 @@ ListHeader.propTypes = {
toggleSort: PropTypes.func,
goQueue: PropTypes.func,
queueSize: PropTypes.number,
inquiryEnabled: PropTypes.bool
inquiryEnabled: PropTypes.bool,
user: PropTypes.object
};
export default ListHeader;

View File

@ -156,8 +156,8 @@ class Sort extends PureComponent {
>
<View style={[styles.dropdownContainerHeader, { borderColor: themes[theme].separatorColor }]}>
<View style={styles.sortItemContainer}>
<CustomIcon style={[styles.sortIcon, { color: themes[theme].auxiliaryText }]} size={22} name='sort' />
<Text style={[styles.sortToggleText, { color: themes[theme].auxiliaryText }]}>{I18n.t('Sorting_by', { key: I18n.t(sortBy === 'alphabetical' ? 'name' : 'activity') })}</Text>
<CustomIcon style={[styles.sortIcon, { color: themes[theme].auxiliaryText }]} size={22} name='sort-az' />
</View>
</View>
</Touch>

View File

@ -62,7 +62,7 @@ import { goRoom } from '../../utils/goRoom';
import SafeAreaView from '../../containers/SafeAreaView';
import Header, { getHeaderTitlePosition } from '../../containers/Header';
import { withDimensions } from '../../dimensions';
import { showErrorAlert } from '../../utils/info';
import { showErrorAlert, showConfirmationAlert } from '../../utils/info';
import { getInquiryQueueSelector } from '../../selectors/inquiry';
const INITIAL_NUM_TO_RENDER = isTablet ? 20 : 12;
@ -109,7 +109,8 @@ class RoomsListView extends React.Component {
user: PropTypes.shape({
id: PropTypes.string,
username: PropTypes.string,
token: PropTypes.string
token: PropTypes.string,
statusLivechat: PropTypes.string
}),
server: PropTypes.string,
searchText: PropTypes.string,
@ -450,7 +451,6 @@ class RoomsListView extends React.Component {
.observe();
}
this.querySubscription = observable.subscribe((data) => {
let tempChats = [];
let chats = data;
@ -685,7 +685,28 @@ class RoomsListView extends React.Component {
goQueue = () => {
logEvent(events.RL_GO_QUEUE);
const { navigation, isMasterDetail, queueSize } = this.props;
const {
navigation, isMasterDetail, queueSize, inquiryEnabled, user
} = this.props;
// if not-available, prompt to change to available
if (user?.statusLivechat !== 'available') {
showConfirmationAlert({
message: I18n.t('Omnichannel_enable_alert'),
callToAction: I18n.t('Yes'),
onPress: async() => {
try {
await RocketChat.changeLivechatStatus();
} catch {
// Do nothing
}
}
});
}
if (!inquiryEnabled) {
return;
}
// prevent navigation to empty list
if (!queueSize) {
return showErrorAlert(I18n.t('Queue_is_empty'), I18n.t('Oops'));
@ -813,7 +834,9 @@ class RoomsListView extends React.Component {
renderListHeader = () => {
const { searching } = this.state;
const { sortBy, queueSize, inquiryEnabled } = this.props;
const {
sortBy, queueSize, inquiryEnabled, user
} = this.props;
return (
<ListHeader
searching={searching}
@ -823,6 +846,7 @@ class RoomsListView extends React.Component {
goQueue={this.goQueue}
queueSize={queueSize}
inquiryEnabled={inquiryEnabled}
user={user}
/>
);
};

View File

@ -23,7 +23,11 @@ export default StyleSheet.create({
sortToggleText: {
fontSize: 16,
flex: 1,
marginLeft: 12,
...sharedStyles.textRegular
},
queueToggleText: {
fontSize: 16,
flex: 1,
...sharedStyles.textRegular
},
dropdownContainer: {
@ -58,6 +62,11 @@ export default StyleSheet.create({
height: 22,
marginHorizontal: 12
},
queueIcon: {
width: 22,
height: 22,
marginHorizontal: 12
},
groupTitleContainer: {
paddingHorizontal: 12,
paddingTop: 17,
@ -116,5 +125,8 @@ export default StyleSheet.create({
serverSeparator: {
height: StyleSheet.hairlineWidth,
marginLeft: 72
},
omnichannelToggle: {
marginRight: 12
}
});

View File

@ -36,7 +36,6 @@ import { LISTENER } from '../../containers/Toast';
import EventEmitter from '../../utils/events';
import { appStart as appStartAction, ROOT_LOADING } from '../../actions/app';
import { onReviewPress } from '../../utils/review';
import { getUserSelector } from '../../selectors/login';
import SafeAreaView from '../../containers/SafeAreaView';
const SectionSeparator = React.memo(({ theme }) => (
@ -73,20 +72,9 @@ class SettingsView extends React.Component {
isMasterDetail: PropTypes.bool,
logout: PropTypes.func.isRequired,
selectServerRequest: PropTypes.func,
user: PropTypes.shape({
roles: PropTypes.array,
statusLivechat: PropTypes.string
}),
appStart: PropTypes.func
}
get showLivechat() {
const { user } = this.props;
const { roles } = user;
return roles?.includes('livechat-agent');
}
handleLogout = () => {
logEvent(events.SE_LOG_OUT);
showConfirmationAlert({
@ -131,14 +119,6 @@ class SettingsView extends React.Component {
}
}
toggleLivechat = async() => {
try {
await RocketChat.changeLivechatStatus();
} catch {
// Do nothing
}
}
navigateToScreen = (screen) => {
logEvent(events[`SE_GO_${ screen.replace('View', '').toUpperCase() }`]);
const { navigation } = this.props;
@ -204,18 +184,6 @@ class SettingsView extends React.Component {
);
}
renderLivechatSwitch = () => {
const { user } = this.props;
const { statusLivechat } = user;
return (
<Switch
value={statusLivechat === 'available'}
trackColor={SWITCH_TRACK_COLOR}
onValueChange={this.toggleLivechat}
/>
);
}
render() {
const { server, isMasterDetail, theme } = this.props;
return (
@ -336,18 +304,6 @@ class SettingsView extends React.Component {
<SectionSeparator theme={theme} />
{this.showLivechat ? (
<>
<ListItem
title={I18n.t('Omnichannel')}
testID='settings-view-livechat'
right={() => this.renderLivechatSwitch()}
theme={theme}
/>
<SectionSeparator theme={theme} />
</>
) : null}
<ListItem
title={I18n.t('Send_crash_report')}
testID='settings-view-crash-report'
@ -387,7 +343,6 @@ class SettingsView extends React.Component {
const mapStateToProps = state => ({
server: state.server,
user: getUserSelector(state),
allowCrashReport: state.crashReport.allowCrashReport,
isMasterDetail: state.app.isMasterDetail
});