diff --git a/assets/images/hashtag.svg b/assets/images/hashtag.svg new file mode 100644 index 000000000000..a9d4286599b1 --- /dev/null +++ b/assets/images/hashtag.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/src/CONST.js b/src/CONST.js index 83922e707316..ee0fd890e486 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -219,6 +219,7 @@ const CONST = { POLICY_ANNOUNCE: 'policyAnnounce', POLICY_ADMINS: 'policyAdmins', DOMAIN_ALL: 'domainAll', + POLICY_ROOM: 'policyRoom', }, STATE_NUM: { OPEN: 0, @@ -230,7 +231,12 @@ const CONST = { DAILY: 'daily', ALWAYS: 'always', }, + VISIBILITY: { + RESTRICTED: 'restricted', + PRIVATE: 'private', + }, MAX_PREVIEW_AVATARS: 4, + MAX_ROOM_NAME_LENGTH: 80, }, MODAL: { MODAL_TYPE: { diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index 928d3b3f5eb2..c97e2e24b1ec 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -156,4 +156,7 @@ export default { // Is report data loading? IS_LOADING_REPORT_DATA: 'isLoadingReportData', + + // Are we loading the create policy room command + IS_LOADING_CREATE_POLICY_ROOM: 'isLoadingCratePolicyRoom', }; diff --git a/src/ROUTES.js b/src/ROUTES.js index f3b856a469de..adf0b319b77b 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -85,6 +85,7 @@ export default { WORKSPACE_TRAVEL: 'workspace/:policyID/travel', WORKSPACE_MEMBERS: 'workspace/:policyID/members', WORKSPACE_BANK_ACCOUNT: 'workspace/:policyID/bank-account', + WORKSPACE_NEW_ROOM: 'workspace/new-room', getWorkspaceInitialRoute: policyID => `workspace/${policyID}`, getWorkspaceInviteRoute: policyID => `workspace/${policyID}/invite`, getWorkspaceSettingsRoute: policyID => `workspace/${policyID}/settings`, diff --git a/src/components/ExpensiPicker.js b/src/components/ExpensiPicker.js index 4a6f4fc69649..bd4401f352b0 100644 --- a/src/components/ExpensiPicker.js +++ b/src/components/ExpensiPicker.js @@ -19,6 +19,9 @@ const propTypes = { /** Error text to display */ errorText: PropTypes.string, + + /** Customize the ExpensiPicker container */ + containerStyles: PropTypes.arrayOf(PropTypes.object), }; const defaultProps = { @@ -26,6 +29,7 @@ const defaultProps = { isDisabled: false, hasError: false, errorText: '', + containerStyles: [], }; class ExpensiPicker extends PureComponent { diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js index d3f1f9be19bc..b0e7d9d2726b 100644 --- a/src/components/Icon/Expensicons.js +++ b/src/components/Icon/Expensicons.js @@ -25,6 +25,7 @@ import EyeDisabled from '../../../assets/images/eye-disabled.svg'; import ExpensifyCard from '../../../assets/images/expensifycard.svg'; import Gallery from '../../../assets/images/gallery.svg'; import Gear from '../../../assets/images/gear.svg'; +import Hashtag from '../../../assets/images/hashtag.svg'; import Info from '../../../assets/images/info.svg'; import Invoice from '../../../assets/images/invoice.svg'; import Link from '../../../assets/images/link.svg'; @@ -92,6 +93,7 @@ export { ExpensifyCard, Gallery, Gear, + Hashtag, Info, Invoice, Link, diff --git a/src/components/TextInputWithLabel.js b/src/components/TextInputWithLabel.js index 43030b397341..9eea98fd4c7c 100644 --- a/src/components/TextInputWithLabel.js +++ b/src/components/TextInputWithLabel.js @@ -1,11 +1,11 @@ import _ from 'underscore'; import React from 'react'; -// eslint-disable-next-line no-restricted-imports -import {View, TextInput} from 'react-native'; +import {View} from 'react-native'; import PropTypes from 'prop-types'; import styles from '../styles/styles'; import ExpensifyText from './ExpensifyText'; import TextLink from './TextLink'; +import TextInputWithPrefix from './TextInputWithPrefix'; const propTypes = { /** Label text */ @@ -14,6 +14,12 @@ const propTypes = { /** Text to show if there is an error */ errorText: PropTypes.string, + /** Prefix character prepended to the text input */ + prefixCharacter: PropTypes.string, + + /** Placeholder to display when the text input is empty */ + placeholder: PropTypes.string, + /** Styles for the outermost container for this component. */ containerStyles: PropTypes.arrayOf(PropTypes.object), @@ -25,15 +31,21 @@ const propTypes = { /** Whether to disable the field and style */ disabled: PropTypes.bool, + + /** Callback to execute when the text input is modified */ + onChangeText: PropTypes.func, }; const defaultProps = { label: '', errorText: '', + prefixCharacter: '', containerStyles: [], helpLinkText: '', helpLinkURL: '', disabled: false, + placeholder: '', + onChangeText: () => {}, }; const TextInputWithLabel = props => ( @@ -54,16 +66,8 @@ const TextInputWithLabel = props => ( )} - + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + {props.errorText !== '' && ( {props.errorText} )} diff --git a/src/components/TextInputWithPrefix/index.android.js b/src/components/TextInputWithPrefix/index.android.js new file mode 100644 index 000000000000..87e7f84e9ec8 --- /dev/null +++ b/src/components/TextInputWithPrefix/index.android.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +// eslint-disable-next-line no-restricted-imports +import {TextInput, View} from 'react-native'; +import _ from 'underscore'; +import React from 'react'; +import ExpensifyText from '../ExpensifyText'; +import styles from '../../styles/styles'; + +const propTypes = { + /** Prefix character */ + prefixCharacter: PropTypes.string, + + /** Text to show if there is an error */ + errorText: PropTypes.string, + + /** Whether to disable the field and style */ + disabled: PropTypes.bool, + + /** Callback to execute the text input is modified */ + onChangeText: PropTypes.func, +}; + +const defaultProps = { + errorText: '', + prefixCharacter: '', + disabled: false, + onChangeText: () => {}, +}; + +const TextInputWithPrefix = props => (_.isEmpty(props.prefixCharacter) + // eslint-disable-next-line react/jsx-props-no-spreading + ? + : ( + + {props.prefixCharacter} + props.onChangeText(`${props.prefixCharacter}${text}`)} + // eslint-disable-next-line react/jsx-props-no-spreading + {..._.omit(props, ['prefixCharacter', 'errorText', 'onChangeText'])} + /> + + )); + +TextInputWithPrefix.propTypes = propTypes; +TextInputWithPrefix.defaultProps = defaultProps; +export default TextInputWithPrefix; diff --git a/src/components/TextInputWithPrefix/index.js b/src/components/TextInputWithPrefix/index.js new file mode 100644 index 000000000000..50d3793f41af --- /dev/null +++ b/src/components/TextInputWithPrefix/index.js @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +// eslint-disable-next-line no-restricted-imports +import {TextInput, View} from 'react-native'; +import _ from 'underscore'; +import React from 'react'; +import ExpensifyText from '../ExpensifyText'; +import styles from '../../styles/styles'; + +const propTypes = { + /** Prefix character */ + prefixCharacter: PropTypes.string, + + /** Text to show if there is an error */ + errorText: PropTypes.string, + + /** Whether to disable the field and style */ + disabled: PropTypes.bool, + + /** Callback to execute the text input is modified */ + onChangeText: PropTypes.func, +}; + +const defaultProps = { + errorText: '', + prefixCharacter: '', + disabled: false, + onChangeText: () => {}, +}; + +const TextInputWithPrefix = props => (_.isEmpty(props.prefixCharacter) + // eslint-disable-next-line react/jsx-props-no-spreading + ? + : ( + + {props.prefixCharacter} + props.onChangeText(`${props.prefixCharacter}${text}`)} + // eslint-disable-next-line react/jsx-props-no-spreading + {..._.omit(props, ['prefixCharacter', 'errorText', 'onChangeText'])} + /> + + )); + +TextInputWithPrefix.propTypes = propTypes; +TextInputWithPrefix.defaultProps = defaultProps; +export default TextInputWithPrefix; diff --git a/src/languages/en.js b/src/languages/en.js index 23c5029b2dab..add267154958 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -176,6 +176,7 @@ export default { fabAction: 'New chat', newChat: 'New chat', newGroup: 'New group', + newRoom: 'New Room', headerChat: 'Chats', buttonSearch: 'Search', buttonMySettings: 'My settings', @@ -793,6 +794,18 @@ export default { emojiPicker: { skinTonePickerLabel: 'Change default skin tone', }, + newRoomPage: { + newRoom: 'New Room', + roomName: 'Room Name', + visibility: 'Visibility', + restrictedDescription: 'People in your workspace are able to find this room using Search', + privateDescription: 'Only people invited to this room are able to find it', + createRoom: 'Create Room', + roomAlreadyExists: 'A room with this name already exists', + social: 'social', + selectAWorkspace: 'Select a workspace', + growlMessageOnError: 'Unable to create policy room, please check your connection and try again.', + }, keyboardShortcutModal: { title: 'Keyboard Shortcuts', subtitle: 'Save time with these handy keyboard shortcuts:', diff --git a/src/languages/es.js b/src/languages/es.js index 3ce7f2a9bd35..6a6b8e5d3a4c 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -176,6 +176,7 @@ export default { fabAction: 'Nuevo chat', newChat: 'Nuevo chat', newGroup: 'Nuevo grupo', + newRoom: 'Nueva sala de chat', headerChat: 'Chats', buttonSearch: 'Buscar', buttonMySettings: 'Mi configuración', @@ -795,6 +796,18 @@ export default { emojiPicker: { skinTonePickerLabel: 'Elige el tono de piel por defecto', }, + newRoomPage: { + newRoom: 'Nueva sala de chat', + roomName: 'Nombre de la sala', + visibility: 'Visibilidad', + restrictedDescription: 'Sólo las personas en tu espacio de trabajo pueden encontrar esta sala a través de "Buscar"', + privateDescription: 'Sólo las personas que están invitadas a esta sala pueden encontrarla', + createRoom: 'Crea una sala de chat', + roomAlreadyExists: 'Ya existe una sala con este nombre', + social: 'social', + selectAWorkspace: 'Seleccionar un espacio de trabajo', + growlMessageOnError: 'No ha sido posible crear el espacio de trabajo, por favor comprueba tu conexión e inténtalo de nuevo.', + }, keyboardShortcutModal: { title: 'Atajos de teclado', subtitle: 'Ahorra tiempo con estos atajos de teclado:', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 4b998a632ee4..1f2ec68439d0 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -37,6 +37,7 @@ import WorkspaceBillsPage from '../../../pages/workspace/bills/WorkspaceBillsPag import WorkspaceTravelPage from '../../../pages/workspace/travel/WorkspaceTravelPage'; import WorkspaceMembersPage from '../../../pages/workspace/WorkspaceMembersPage'; import WorkspaceBankAccountPage from '../../../pages/workspace/WorkspaceBankAccountPage'; +import WorkspaceNewRoomPage from '../../../pages/workspace/WorkspaceNewRoomPage'; import CONST from '../../../CONST'; const defaultSubRouteOptions = { @@ -222,6 +223,10 @@ const SettingsModalStackNavigator = createModalStackNavigator([ Component: WorkspaceInvitePage, name: 'Workspace_Invite', }, + { + Component: WorkspaceNewRoomPage, + name: 'Workspace_NewRoom', + }, { Component: ReimbursementAccountPage, name: 'ReimbursementAccount', diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 4e89c80ec813..d195bd886da2 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -102,6 +102,9 @@ export default { Workspace_Invite: { path: ROUTES.WORKSPACE_INVITE, }, + Workspace_NewRoom: { + path: ROUTES.WORKSPACE_NEW_ROOM, + }, ReimbursementAccount: { path: ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN, exact: true, diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index f8b51506a25b..9c5546ba3a74 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -1451,28 +1451,33 @@ function handleInaccessibleReport() { } /** - * Creates a policy room and fetches it + * Creates a policy room, fetches it, and navigates to it. * @param {String} policyID * @param {String} reportName * @param {String} visibility * @return {Promise} */ function createPolicyRoom(policyID, reportName, visibility) { + Onyx.set(ONYXKEYS.IS_LOADING_CREATE_POLICY_ROOM, true); return API.CreatePolicyRoom({policyID, reportName, visibility}) .then((response) => { if (response.jsonCode !== 200) { - Log.hmmm(response.message); + Growl.error(response.message); return; } return fetchChatReportsByIDs([response.reportID]); }) .then(([{reportID}]) => { if (!reportID) { - Log.hmmm('Unable to grab policy room after creation'); + Log.error('Unable to grab policy room after creation', reportID); return; } Navigation.navigate(ROUTES.getReportRoute(reportID)); - }); + }) + .catch(() => { + Growl.error(Localize.translateLocal('newRoomPage.growlMessageOnError')); + }) + .finally(() => Onyx.set(ONYXKEYS.IS_LOADING_CREATE_POLICY_ROOM, false)); } export { diff --git a/src/libs/reportUtils.js b/src/libs/reportUtils.js index d6a4f1d56717..1431fdac402b 100644 --- a/src/libs/reportUtils.js +++ b/src/libs/reportUtils.js @@ -92,6 +92,16 @@ function isDefaultRoom(report) { ], lodashGet(report, ['chatType'], '')); } +/** + * Whether the provided report is a policy room + * @param {Object} report + * @param {String} report.chatType + * @returns {Boolean} + */ +function isPolicyRoom(report) { + return lodashGet(report, ['chatType'], '') === CONST.REPORT.CHAT_TYPE.POLICY_ROOM; +} + /** * Given a collection of reports returns the most recently accessed one * @@ -198,6 +208,7 @@ export { canDeleteReportAction, sortReportsByLastVisited, isDefaultRoom, + isPolicyRoom, getDefaultRoomSubtitle, isArchivedRoom, isConciergeChatReport, diff --git a/src/pages/home/sidebar/SidebarScreen.js b/src/pages/home/sidebar/SidebarScreen.js index d678eeb81fba..5f3f1b12471e 100755 --- a/src/pages/home/sidebar/SidebarScreen.js +++ b/src/pages/home/sidebar/SidebarScreen.js @@ -159,6 +159,13 @@ class SidebarScreen extends Component { text: this.props.translate('sidebarScreen.newGroup'), onSelected: () => Navigation.navigate(ROUTES.NEW_GROUP), }, + ...(Permissions.canUseDefaultRooms(this.props.betas) ? [ + { + icon: Expensicons.Hashtag, + text: this.props.translate('sidebarScreen.newRoom'), + onSelected: () => Navigation.navigate(ROUTES.WORKSPACE_NEW_ROOM), + }, + ] : []), ...(Permissions.canUseIOUSend(this.props.betas) ? [ { icon: Expensicons.Send, diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js new file mode 100644 index 000000000000..d4f2e715cc8c --- /dev/null +++ b/src/pages/workspace/WorkspaceNewRoomPage.js @@ -0,0 +1,202 @@ +import React from 'react'; +import {View} from 'react-native'; +import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import withFullPolicy, {fullPolicyDefaultProps, fullPolicyPropTypes} from './withFullPolicy'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import compose from '../../libs/compose'; +import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; +import Navigation from '../../libs/Navigation/Navigation'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import styles from '../../styles/styles'; +import TextInputWithLabel from '../../components/TextInputWithLabel'; +import ExpensiPicker from '../../components/ExpensiPicker'; +import ONYXKEYS from '../../ONYXKEYS'; +import CONST from '../../CONST'; +import ExpensifyButton from '../../components/ExpensifyButton'; +import FixedFooter from '../../components/FixedFooter'; +import * as Report from '../../libs/actions/Report'; +import Permissions from '../../libs/Permissions'; +import Log from '../../libs/Log'; + +const propTypes = { + /** All reports shared with the user */ + reports: PropTypes.shape({ + reportName: PropTypes.string, + type: PropTypes.string, + policyID: PropTypes.string, + }).isRequired, + + /** Are we loading the createPolicyRoom command */ + isLoadingCreatePolicyRoom: PropTypes.bool, + + ...fullPolicyPropTypes, + + ...withLocalizePropTypes, +}; +const defaultProps = { + betas: [], + isLoadingCreatePolicyRoom: false, + ...fullPolicyDefaultProps, +}; + +class WorkspaceNewRoomPage extends React.Component { + constructor(props) { + super(props); + + this.state = { + roomName: '', + policyID: '', + visibility: CONST.REPORT.VISIBILITY.RESTRICTED, + error: '', + workspaceOptions: [], + }; + this.onWorkspaceSelect = this.onWorkspaceSelect.bind(this); + this.onSubmit = this.onSubmit.bind(this); + this.checkAndModifyRoomName = this.checkAndModifyRoomName.bind(this); + } + + componentDidMount() { + // Workspaces are policies with type === 'free' + const workspaces = _.filter(this.props.policies, policy => policy && policy.type === CONST.POLICY.TYPE.FREE); + this.setState({workspaceOptions: _.map(workspaces, policy => ({label: policy.name, key: policy.id, value: policy.id}))}); + } + + componentDidUpdate(prevProps) { + if (this.props.policies.length === prevProps.policies.length) { + return; + } + + // Workspaces are policies with type === 'free' + const workspaces = _.filter(this.props.policies, policy => policy && policy.type === CONST.POLICY.TYPE.FREE); + + // eslint-disable-next-line react/no-did-update-set-state + this.setState({workspaceOptions: _.map(workspaces, policy => ({label: policy.name, key: policy.id, value: policy.id}))}); + } + + /** + * Called when a workspace is selected. Also calls checkAndModifyRoomName, + * which displays an error if the given roomName exists on the newly selected workspace. + * @param {String} policyID + */ + onWorkspaceSelect(policyID) { + this.setState({policyID}); + this.checkAndModifyRoomName(this.state.roomName); + } + + /** + * Called when the "Create Room" button is pressed. + */ + onSubmit() { + Report.createPolicyRoom(this.state.policyID, this.state.roomName, this.state.visibility); + } + + /** + * Modifies the room name to follow our conventions: + * - Max length 80 characters + * - Cannot not include space or special characters, and we automatically apply an underscore for spaces + * - Must be lowercase + * Also checks to see if this room name already exists, and displays an error message if so. + * @param {String} roomName + * + * @returns {String} + */ + checkAndModifyRoomName(roomName) { + const modifiedRoomNameWithoutHash = roomName.substr(1) + .replace(/ /g, '_') + .replace(/[^a-zA-Z\d_]/g, '') + .substr(0, CONST.REPORT.MAX_ROOM_NAME_LENGTH) + .toLowerCase(); + const finalRoomName = `#${modifiedRoomNameWithoutHash}`; + + const isExistingRoomName = _.some( + _.values(this.props.reports), + report => report && report.policyID === this.state.policyID && report.reportName === finalRoomName, + ); + if (isExistingRoomName) { + this.setState({error: this.props.translate('newRoomPage.roomAlreadyExists')}); + } else { + this.setState({error: ''}); + } + return finalRoomName; + } + + render() { + if (!Permissions.canUseDefaultRooms(this.props.betas)) { + Log.info('Not showing create Policy Room page since user is not on default rooms beta'); + Navigation.dismissModal(); + return null; + } + const shouldDisableSubmit = Boolean(!this.state.roomName || !this.state.policyID || this.state.error); + return ( + + Navigation.dismissModal()} + /> + + this.setState({roomName: this.checkAndModifyRoomName(roomName)})} + value={this.state.roomName.substr(1)} + errorText={this.state.error} + /> + + + + this.setState({visibility})} + /> + + + + + + ); + } +} + +WorkspaceNewRoomPage.propTypes = propTypes; +WorkspaceNewRoomPage.defaultProps = defaultProps; + +export default compose( + withFullPolicy, + withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, + policies: { + key: ONYXKEYS.COLLECTION.POLICY, + }, + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + isLoadingCreatePolicyRoom: { + key: ONYXKEYS.IS_LOADING_CREATE_POLICY_ROOM, + }, + }), + withLocalize, +)(WorkspaceNewRoomPage); diff --git a/src/styles/styles.js b/src/styles/styles.js index c0ec2df5f495..abdb11847b5e 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -676,6 +676,42 @@ const styles = { textAlignVertical: 'center', }, + textInputWithPrefix: { + container: { + backgroundColor: themeColors.componentBG, + borderColor: themeColors.border, + borderWidth: 1, + borderRadius: variables.componentBorderRadiusNormal, + color: themeColors.text, + display: 'flex', + flexDirection: 'row', + fontFamily: fontFamily.GTA, + fontSize: variables.fontSizeNormal, + height: variables.inputComponentSizeNormal, + marginBottom: 4, + paddingBottom: 10, + paddingLeft: 12, + paddingRight: 12, + paddingTop: 10, + textAlignVertical: 'center', + }, + textInput: { + outlineStyle: 'none', + color: themeColors.text, + fontFamily: fontFamily.GTA, + fontSize: variables.fontSizeNormal, + textAlignVertical: 'center', + flex: 1, + }, + prefix: { + paddingRight: 10, + color: themeColors.text, + fontFamily: fontFamily.GTA, + fontSize: variables.fontSizeNormal, + textAlignVertical: 'center', + }, + }, + expensiPickerContainer: { borderWidth: 0, borderRadius: variables.componentBorderRadiusNormal,