feat: allow for moving users among breakouts + modal design updates

This commit is contained in:
Joao Victor 2022-03-24 10:56:07 -03:00
parent 5778306626
commit 0ea405b67f
12 changed files with 303 additions and 37 deletions

View File

@ -0,0 +1,57 @@
import Breakouts from '/imports/api/breakouts';
import Logger from '/imports/startup/server/logger';
import { check } from 'meteor/check';
export default function userBreakoutChanged({ body }) {
check(body, Object);
const {
parentId,
userId,
fromBreakoutId,
toBreakoutId,
redirectToHtml5JoinUrl,
} = body;
check(parentId, String);
check(userId, String);
check(fromBreakoutId, String);
check(toBreakoutId, String);
check(redirectToHtml5JoinUrl, String);
const oldBreakoutSelector = {
parentMeetingId: parentId,
breakoutId: fromBreakoutId,
};
const newBreakoutSelector = {
parentMeetingId: parentId,
breakoutId: toBreakoutId,
};
const oldModifier = {
$unset: {
[`url_${userId}`]: '',
},
};
const newModifier = {
$set: {
[`url_${userId}`]: {
redirectToHtml5JoinUrl,
insertedTime: new Date().getTime(),
},
},
};
try {
const numberAffectedOld = Breakouts.update(oldBreakoutSelector, oldModifier);
const numberAffectedNew = Breakouts.update(newBreakoutSelector, newModifier);
if (numberAffectedOld && numberAffectedNew) {
Logger.info(`Updated user breakout for userId=${userId}`);
}
} catch (err) {
Logger.error(`Updating user breakout: ${err}`);
}
}

View File

@ -4,6 +4,7 @@ import requestJoinURL from './methods/requestJoinURL';
import endAllBreakouts from './methods/endAllBreakouts';
import setBreakoutsTime from '/imports/api/breakouts/server/methods/setBreakoutsTime';
import sendMessageToAllBreakouts from './methods/sendMessageToAllBreakouts';
import moveUser from '/imports/api/breakouts/server/methods/moveUser';
Meteor.methods({
requestJoinURL,
@ -11,4 +12,5 @@ Meteor.methods({
endAllBreakouts,
setBreakoutsTime,
sendMessageToAllBreakouts,
moveUser,
});

View File

@ -0,0 +1,32 @@
import { Meteor } from 'meteor/meteor';
import RedisPubSub from '/imports/startup/server/redis';
import { extractCredentials } from '/imports/api/common/server/helpers';
import { check } from 'meteor/check';
import Logger from '/imports/startup/server/logger';
export default function moveUser(fromBreakoutId, toBreakoutId, userIdToMove) {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'ChangeUserBreakoutReqMsg';
try {
const { meetingId, requesterUserId } = extractCredentials(this.userId);
check(meetingId, String);
check(requesterUserId, String);
const userId = userIdToMove || requesterUserId;
return RedisPubSub.publishUserMessage(
CHANNEL, EVENT_NAME, meetingId, requesterUserId,
{
meetingId,
fromBreakoutId,
toBreakoutId,
userId,
},
);
} catch (err) {
Logger.error(`Exception while invoking method moveUser ${err.stack}`);
}
}

View File

@ -9,6 +9,7 @@ import Modal from '/imports/ui/components/common/modal/fullscreen/component';
import { withModalMounter } from '/imports/ui/components/common/modal/service';
import SortList from './sort-user-list/component';
import Styled from './styles';
import Icon from '/imports/ui/components/common/icon/component.jsx';
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
@ -21,6 +22,26 @@ const intlMessages = defineMessages({
id: 'app.createBreakoutRoom.modalDesc',
description: 'modal description',
},
breakoutRoomUpdateDesc: {
id: 'app.updateBreakoutRoom.modalDesc',
description: 'update modal description',
},
cancelLabel: {
id: 'app.updateBreakoutRoom.cancelLabel',
description: 'used in the button that close update modal',
},
updateTitle: {
id: 'app.updateBreakoutRoom.title',
description: 'update breakout title',
},
updateConfirm: {
id: 'app.updateBreakoutRoom.confirm',
description: 'Update to breakout confirm button label',
},
resetUserRoom: {
id: 'app.update.resetRoom',
description: 'Reset user room button label',
},
confirmButton: {
id: 'app.createBreakoutRoom.confirm',
description: 'confirm button label',
@ -153,7 +174,7 @@ const propTypes = {
meetingName: PropTypes.string.isRequired,
users: PropTypes.arrayOf(PropTypes.object).isRequired,
createBreakoutRoom: PropTypes.func.isRequired,
getUsersNotAssigned: PropTypes.func.isRequired,
getUsersNotJoined: PropTypes.func.isRequired,
getBreakouts: PropTypes.func.isRequired,
sendInvitation: PropTypes.func.isRequired,
mountModal: PropTypes.func.isRequired,
@ -192,10 +213,11 @@ class BreakoutRoom extends PureComponent {
this.removeRoomUsers = this.removeRoomUsers.bind(this);
this.renderErrorMessages = this.renderErrorMessages.bind(this);
this.renderJoinedUsers = this.renderJoinedUsers.bind(this);
this.onUpdateBreakouts = this.onUpdateBreakouts.bind(this);
this.state = {
numberOfRooms: MIN_BREAKOUT_ROOMS,
seletedId: '',
selectedId: '',
users: [],
durationTime: 15,
freeJoin: false,
@ -220,7 +242,7 @@ class BreakoutRoom extends PureComponent {
componentDidMount() {
const {
isInvitation, breakoutJoinedUsers, getLastBreakouts, groups,
isInvitation, breakoutJoinedUsers, getLastBreakouts, groups, isUpdate,
} = this.props;
this.setRoomUsers();
if (isInvitation) {
@ -231,6 +253,30 @@ class BreakoutRoom extends PureComponent {
});
}
if (isUpdate) {
const usersToMerge = []
breakoutJoinedUsers.forEach((breakout) => {
breakout.joinedUsers.forEach((user) => {
usersToMerge.push({
userId: user.userId,
userName: user.name,
from: breakout.sequence,
room: breakout.sequence,
isModerator: user.role === ROLE_MODERATOR,
joined: true,
});
});
});
this.setState((prevState) => {
return {
users: [
...prevState.users,
...usersToMerge,
],
};
});
}
const lastBreakouts = getLastBreakouts();
if (lastBreakouts.length > 0) {
this.populateWithLastBreakouts(lastBreakouts);
@ -424,6 +470,51 @@ class BreakoutRoom extends PureComponent {
this.handleDismiss();
}
getBreakoutBySequence(sequence) {
const { getBreakouts } = this.props;
const breakouts = getBreakouts();
return breakouts.find((breakout) => breakout.sequence === sequence);
}
changeUserBreakout(fromBreakoutId, toBreakoutId, userId) {
const { moveUser } = this.props;
moveUser(fromBreakoutId, toBreakoutId, userId);
}
onUpdateBreakouts() {
const { users } = this.state;
const { sendInvitation } = this.props;
const leastOneUserIsValid = users.some((user) => user.from !== user.room);
if (!leastOneUserIsValid) {
this.setState({ leastOneUserIsValid });
}
users.forEach((user) => {
const { from, room } = user;
let { userId } = user;
if (from === room || room === 0) return;
const toBreakout = this.getBreakoutBySequence(room);
const { breakoutId: toBreakoutId } = toBreakout;
if (!user.joined) return sendInvitation(toBreakoutId, userId);
userId = userId.split('-')[0];
const fromBreakout = this.getBreakoutBySequence(from);
const { breakoutId: fromBreakoutId } = fromBreakout;
if (toBreakout.freeJoin) return sendInvitation(toBreakoutId, userId);
this.changeUserBreakout(fromBreakoutId, toBreakoutId, userId);
});
this.handleDismiss();
}
onAssignRandomly() {
const { numberOfRooms } = this.state;
const { users } = this.state;
@ -463,15 +554,16 @@ class BreakoutRoom extends PureComponent {
}
setRoomUsers() {
const { users, getUsersNotAssigned } = this.props;
const { users, getUsersNotJoined } = this.props;
const { users: stateUsers } = this.state;
const stateUsersId = stateUsers.map((user) => user.userId);
const roomUsers = getUsersNotAssigned(users)
const roomUsers = getUsersNotJoined(users)
.filter((user) => !stateUsersId.includes(user.userId))
.map((user) => ({
userId: user.userId,
userName: user.name,
isModerator: user.role === ROLE_MODERATOR,
from: 0,
room: 0,
}));
@ -532,7 +624,7 @@ class BreakoutRoom extends PureComponent {
const usersCopy = [...users];
usersCopy[idxUser].room = room;
if (idxUser >= 0) usersCopy[idxUser].room = room;
this.setState({
users: usersCopy,
@ -691,7 +783,7 @@ class BreakoutRoom extends PureComponent {
ev.preventDefault();
const data = ev.dataTransfer.getData('text');
this.changeUserRoom(data, room);
this.setState({ seletedId: '' });
this.setState({ selectedId: '' });
};
const changeRoomName = (position) => (ev) => {
@ -767,6 +859,7 @@ class BreakoutRoom extends PureComponent {
const {
intl,
isInvitation,
isUpdate,
} = this.props;
const {
numberOfRooms,
@ -774,7 +867,7 @@ class BreakoutRoom extends PureComponent {
numberOfRoomsIsValid,
durationIsValid,
} = this.state;
if (isInvitation) return null;
if (isInvitation || isUpdate) return null;
return (
<React.Fragment key="breakout-form">
@ -905,8 +998,8 @@ class BreakoutRoom extends PureComponent {
}
renderCheckboxes() {
const { intl, isInvitation, isBreakoutRecordable } = this.props;
if (isInvitation) return null;
const { intl, isInvitation, isUpdate, isBreakoutRecordable } = this.props;
if (isInvitation || isUpdate) return null;
const {
freeJoin,
record,
@ -946,14 +1039,14 @@ class BreakoutRoom extends PureComponent {
renderUserItemByRoom(room) {
const {
leastOneUserIsValid,
seletedId,
selectedId,
} = this.state;
const { intl, isMe } = this.props;
const dragStart = (ev) => {
ev.dataTransfer.setData('text', ev.target.id);
this.setState({ seletedId: ev.target.id });
this.setState({ selectedId: ev.target.id });
if (!leastOneUserIsValid) {
this.setState({ leastOneUserIsValid: true });
@ -961,7 +1054,7 @@ class BreakoutRoom extends PureComponent {
};
const dragEnd = () => {
this.setState({ seletedId: '' });
this.setState({ selectedId: '' });
};
return this.getUserByRoom(room)
@ -970,14 +1063,27 @@ class BreakoutRoom extends PureComponent {
tabIndex={-1}
id={`roomUserItem-${user.userId}`}
key={user.userId}
selected={seletedId === user.userId}
selected={selectedId.replace('roomUserItem-', '') === user.userId.replace('roomUserItem-', '')}
disabled={false}
highlight={room !== user.from}
draggable
onDragStart={dragStart}
onDragEnd={dragEnd}
>
{user.userName}
<span>
<span>{user.userName}</span>
<i>{(isMe(user.userId)) ? ` (${intl.formatMessage(intlMessages.you)})` : ''}</i>
</span>
{ room !== user.from ? (
<span
className="close"
role="button"
aria-label={intl.formatMessage(intlMessages.resetUserRoom)}
onClick={() => this.changeUserRoom(user.userId, user.from)}
>
<Icon iconName="close" />
</span>
) : null }
</Styled.RoomUserItem>
));
}
@ -1112,16 +1218,18 @@ class BreakoutRoom extends PureComponent {
}
renderTitle() {
const { intl } = this.props;
const { intl, isUpdate } = this.props;
return (
<Styled.SubTitle>
{intl.formatMessage(intlMessages.breakoutRoomDesc)}
{ isUpdate
? intl.formatMessage(intlMessages.breakoutRoomUpdateDesc)
: intl.formatMessage(intlMessages.breakoutRoomDesc) }
</Styled.SubTitle>
);
}
render() {
const { intl, isInvitation } = this.props;
const { intl, isInvitation, isUpdate } = this.props;
const {
preventClosing,
leastOneUserIsValid,
@ -1138,14 +1246,18 @@ class BreakoutRoom extends PureComponent {
title={
isInvitation
? intl.formatMessage(intlMessages.invitationTitle)
: isUpdate
? intl.formatMessage(intlMessages.updateTitle)
: intl.formatMessage(intlMessages.breakoutRoomTitle)
}
confirm={
{
label: isInvitation
? intl.formatMessage(intlMessages.invitationConfirm)
: isUpdate
? intl.formatMessage(intlMessages.updateConfirm)
: intl.formatMessage(intlMessages.confirmButton),
callback: isInvitation ? this.onInviteBreakout : this.onCreateBreakouts,
callback: isInvitation ? this.onInviteBreakout : isUpdate ? this.onUpdateBreakouts : this.onCreateBreakouts,
disabled: !leastOneUserIsValid
|| !numberOfRoomsIsValid
|| !roomNameDuplicatedIsValid
@ -1156,7 +1268,9 @@ class BreakoutRoom extends PureComponent {
}
dismiss={{
callback: this.handleDismiss,
label: intl.formatMessage(intlMessages.dismissLabel),
label: isUpdate
? intl.formatMessage(intlMessages.cancelLabel)
: intl.formatMessage(intlMessages.dismissLabel),
}}
preventClosing={preventClosing}
>

View File

@ -20,7 +20,7 @@ export default withTracker(() => ({
getBreakouts: ActionsBarService.getBreakouts,
getLastBreakouts: ActionsBarService.getLastBreakouts,
getBreakoutUserWasIn: BreakoutRoomService.getBreakoutUserWasIn,
getUsersNotAssigned: ActionsBarService.getUsersNotAssigned,
getUsersNotJoined: ActionsBarService.getUsersNotJoined,
sendInvitation: ActionsBarService.sendInvitation,
breakoutJoinedUsers: ActionsBarService.breakoutJoinedUsers(),
users: ActionsBarService.users(),
@ -28,4 +28,5 @@ export default withTracker(() => ({
isMe: ActionsBarService.isMe,
meetingName: ActionsBarService.meetingName(),
amIModerator: ActionsBarService.amIModerator(),
moveUser: ActionsBarService.moveUser,
}))(CreateBreakoutRoomContainer);

View File

@ -12,12 +12,15 @@ import {
colorWhite,
colorPrimary,
colorBlueLight,
colorBlueLightest,
colorGrayLightest,
} from '/imports/ui/stylesheets/styled-components/palette';
import { fontSizeSmall, fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
import { fontSizeSmall, fontSizeBase, fontSizeSmaller } from '/imports/ui/stylesheets/styled-components/typography';
import {
borderRadius,
borderSize,
lgPaddingX,
lgPaddingY,
} from '/imports/ui/stylesheets/styled-components/general';
const BoxContainer = styled.div`
@ -66,9 +69,10 @@ const FreeJoinLabel = styled.label`
const BreakoutNameInput = styled.input`
width: 100%;
text-align: left;
font-weight: normal;
padding: .25rem;
font-weight: 600;
padding: .25rem .25rem .25rem 0;
margin: 0;
border: none;
&::placeholder {
color: ${colorGray};
opacity: 1;
@ -79,8 +83,9 @@ const BreakoutBox = styled(ScrollboxVertical)`
width: 100%;
min-height: 6rem;
max-height: 8rem;
border: 1px solid ${colorGrayLighter};
border: 1px solid ${colorGrayLightest};
border-radius: ${borderRadius};
padding: ${lgPaddingY} 0;
`;
const SpanWarn = styled.span`
@ -228,12 +233,21 @@ const RoomUserItem = styled.p`
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
border-bottom: solid .5px ${colorGrayLighter};
display: flex;
justify-content: space-between;
[dir="rtl"] & {
padding: .25rem .25rem .25rem 0;
}
span.close {
display: flex;
flex-direction: column;
justify-content: center;
margin-right: 5px;
font-size: ${fontSizeSmaller};
}
${({ selected }) => selected && `
background-color: ${colorPrimary};
color: ${colorWhite};
@ -243,6 +257,10 @@ const RoomUserItem = styled.p`
cursor: not-allowed;
color: ${colorGrayLighter};
`}
${({ highlight }) => highlight && `
background-color: ${colorBlueLightest};
`}
`;
const LockIcon = styled.span`

View File

@ -30,7 +30,7 @@ const currentBreakoutUsers = (user) => !Breakouts.findOne({
const filterBreakoutUsers = (filter) => (users) => users.filter(filter);
const getUsersNotAssigned = filterBreakoutUsers(currentBreakoutUsers);
const getUsersNotJoined = filterBreakoutUsers(currentBreakoutUsers);
const takePresenterRole = () => makeCall('assignPresenter', Auth.userID);
@ -70,9 +70,10 @@ export default {
breakoutJoinedUsers: () => Breakouts.find({
joinedUsers: { $exists: true },
}, { fields: { joinedUsers: 1, breakoutId: 1, sequence: 1 }, sort: { sequence: 1 } }).fetch(),
moveUser: (fromBreakoutId, toBreakoutId, userId) => makeCall('moveUser', fromBreakoutId, toBreakoutId, userId),
getBreakouts,
getLastBreakouts,
getUsersNotAssigned,
getUsersNotJoined,
takePresenterRole,
isSharingVideo: () => getVideoUrl(),
};

View File

@ -1,7 +1,9 @@
import React, { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { withModalMounter } from '/imports/ui/components/common/modal/service';
import BBBMenu from "/imports/ui/components/common/menu/component";
import Button from '/imports/ui/components/common/button/component';
import CreateBreakoutRoomModal from '/imports/ui/components/actions-bar/create-breakout-room/container';
const intlMessages = defineMessages({
options: {
@ -12,6 +14,11 @@ const intlMessages = defineMessages({
id: 'app.breakout.dropdown.manageDuration',
description: 'Manage duration label',
},
manageUsers: {
id: 'app.breakout.dropdown.manageUsers',
description: 'Manage users label',
defaultMessage: 'Manage Users',
},
destroy: {
id: 'app.breakout.dropdown.destroyAll',
description: 'Destroy breakouts label',
@ -30,6 +37,7 @@ class BreakoutDropdown extends PureComponent {
endAllBreakouts,
isMeteorConnected,
amIModerator,
mountModal,
} = this.props;
this.menuItems = [];
@ -45,6 +53,19 @@ class BreakoutDropdown extends PureComponent {
}
);
this.menuItems.push(
{
key: 'updateBreakoutUsers',
dataTest: 'openUpdateBreakoutUsersModal',
label: intl.formatMessage(intlMessages.manageUsers),
onClick: () => {
mountModal(
<CreateBreakoutRoomModal isUpdate />
);
}
}
);
if (amIModerator) {
this.menuItems.push(
{
@ -101,4 +122,4 @@ class BreakoutDropdown extends PureComponent {
}
}
export default injectIntl(BreakoutDropdown);
export default withModalMounter(injectIntl(BreakoutDropdown));

View File

@ -2,16 +2,17 @@ import styled from 'styled-components';
import Styled from '../base/styles';
import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
import Button from '/imports/ui/components/common/button/component';
import { borderSize, smPaddingX } from '/imports/ui/stylesheets/styled-components/general';
import { borderSize, smPaddingX, borderRadius } from '/imports/ui/stylesheets/styled-components/general';
import {
lineHeightComputed,
modalTitleFw,
} from '/imports/ui/stylesheets/styled-components/typography';
import {
colorGrayLighter,
colorGrayLightest,
colorText,
colorWhite,
colorLink,
colorBlueLight,
} from '/imports/ui/stylesheets/styled-components/palette';
const FullscreenModal = styled(Styled.BaseModal)`
@ -32,7 +33,7 @@ const FullscreenModal = styled(Styled.BaseModal)`
const Header = styled.header`
display: flex;
padding: ${lineHeightComputed} 0;
border-bottom: ${borderSize} solid ${colorGrayLighter};
border-bottom: ${borderSize} solid ${colorGrayLightest};
`;
const Title = styled.h1`
@ -61,12 +62,26 @@ const Content = styled.div`
const DismissButton = styled(Button)`
flex: 0 1 48%;
border: 1px solid ${colorBlueLight};
border-radius: ${borderRadius};
color: ${colorBlueLight};
&:focus,
.buttonWrapper:focus:not([aria-disabled="true"]) & {
color: ${colorBlueLight};
}
&:hover,
.buttonWrapper:hover & {
color: ${colorBlueLight};
}
`;
const ConfirmButton = styled(Button)`
flex: 0 1 48%;
color: ${colorWhite} !important;
background-color: ${colorLink} !important;
border-width: 1px;
${({ popout }) => popout === 'popout' && `
& > i {

View File

@ -213,7 +213,7 @@ class UserOptions extends PureComponent {
meetingIsBreakout,
hasBreakoutRoom,
isBreakoutEnabled,
getUsersNotAssigned,
getUsersNotJoined,
openLearningDashboardUrl,
amIModerator,
users,
@ -229,7 +229,7 @@ class UserOptions extends PureComponent {
const canInviteUsers = amIModerator
&& !meetingIsBreakout
&& hasBreakoutRoom
&& getUsersNotAssigned(users).length;
&& getUsersNotJoined(users).length;
const { locale } = intl;

View File

@ -85,7 +85,7 @@ const UserOptionsContainer = withTracker((props) => {
toggleStatus,
isMeetingMuted: isMeetingMuteOnStart(),
amIModerator: ActionsBarService.amIModerator(),
getUsersNotAssigned: ActionsBarService.getUsersNotAssigned,
getUsersNotJoined: ActionsBarService.getUsersNotJoined,
hasBreakoutRoom: UserListService.hasBreakoutRoom(),
isBreakoutEnabled: ActionsBarService.isBreakoutEnabled(),
isBreakoutRecordable: ActionsBarService.isBreakoutRecordable(),

View File

@ -913,6 +913,11 @@
"app.createBreakoutRoom.setTimeCancel": "Cancel",
"app.createBreakoutRoom.setTimeHigherThanMeetingTimeError": "The breakout rooms duration can't exceed the meeting remaining time.",
"app.createBreakoutRoom.roomNameInputDesc": "Updates breakout room name",
"app.updateBreakoutRoom.modalDesc": "To update or invite a user, simply drag them into the desired room.",
"app.updateBreakoutRoom.cancelLabel": "Cancel",
"app.updateBreakoutRoom.title": "Update Breakout Rooms",
"app.updateBreakoutRoom.confirm": "Apply",
"app.update.resetRoom": "Reset user room",
"app.externalVideo.start": "Share a new video",
"app.externalVideo.title": "Share an external video",
"app.externalVideo.input": "External Video URL",