Skip to content

Commit aa3071b

Browse files
committed
Implementing the UI for group invitations (both management and student interface).
1 parent 699e9a1 commit aa3071b

File tree

24 files changed

+1832
-829
lines changed

24 files changed

+1832
-829
lines changed

.yarn/releases/yarn-3.2.1.cjs

Lines changed: 0 additions & 786 deletions
This file was deleted.

.yarn/releases/yarn-3.2.2.cjs

Lines changed: 783 additions & 0 deletions
Large diffs are not rendered by default.

.yarnrc.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ plugins:
44
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
55
spec: "@yarnpkg/plugin-interactive-tools"
66

7-
yarnPath: .yarn/releases/yarn-3.2.1.cjs
7+
yarnPath: .yarn/releases/yarn-3.2.2.cjs
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React, { useState } from 'react';
2+
import PropTypes from 'prop-types';
3+
import { Table, OverlayTrigger, Tooltip } from 'react-bootstrap';
4+
import { FormattedMessage } from 'react-intl';
5+
import { CopyToClipboard } from 'react-copy-to-clipboard';
6+
import classnames from 'classnames';
7+
8+
import UsersNameContainer from '../../../containers/UsersNameContainer';
9+
import DateTime from '../../widgets/DateTime';
10+
import Button, { TheButtonGroup } from '../../widgets/TheButton';
11+
import Icon, { CopyIcon, DeleteIcon, EditIcon } from '../../icons';
12+
import withLinks from '../../../helpers/withLinks';
13+
14+
const GroupInvitations = ({
15+
invitations,
16+
editInvitation = null,
17+
deleteInvitation = null,
18+
selected = null,
19+
links: { ACCEPT_GROUP_INVITATION_URI_FACTORY },
20+
}) => {
21+
const [copiedInvitation, setCopiedInvitation] = useState(null);
22+
23+
return (
24+
<>
25+
{invitations && invitations.length ? (
26+
<Table className="tbody-hover mb-0" size="sm">
27+
<thead>
28+
<tr>
29+
<th>
30+
<FormattedMessage id="app.groupInvitations.createdBy" defaultMessage="Created by" />
31+
</th>
32+
<th>
33+
<FormattedMessage id="generic.createdAt" defaultMessage="Created at" />
34+
</th>
35+
<th>
36+
<FormattedMessage id="app.groupInvitations.expire at" defaultMessage="Expire at" />
37+
</th>
38+
<th />
39+
{(editInvitation || deleteInvitation) && <th />}
40+
</tr>
41+
</thead>
42+
43+
{invitations.map(invitation => {
44+
const uri = `${window && window.location.origin}${ACCEPT_GROUP_INVITATION_URI_FACTORY(invitation.id)}`;
45+
const hasExpired = invitation.expireAt && invitation.expireAt <= Date.now() / 1000;
46+
return (
47+
<tbody
48+
key={invitation.id}
49+
className={classnames({
50+
'text-muted': hasExpired,
51+
'table-warning': selected === invitation.id,
52+
})}>
53+
<tr>
54+
<td colSpan={editInvitation || deleteInvitation ? 5 : 4}>
55+
<code className={hasExpired ? 'text-muted small' : 'small'}>{uri}</code>
56+
{!hasExpired &&
57+
(copiedInvitation === invitation.id ? (
58+
<Icon
59+
icon="clipboard-check"
60+
gapLeft
61+
className="text-success"
62+
onClick={() => setCopiedInvitation(null)}
63+
/>
64+
) : (
65+
<OverlayTrigger
66+
placement="bottom"
67+
overlay={
68+
<Tooltip id={invitation.id}>
69+
<FormattedMessage id="generic.copyToClipboard" defaultMessage="Copy to clipboard" />
70+
</Tooltip>
71+
}>
72+
<CopyToClipboard text={uri} onCopy={() => setCopiedInvitation(invitation.id)}>
73+
<CopyIcon timid gapLeft className="clickable" />
74+
</CopyToClipboard>
75+
</OverlayTrigger>
76+
))}
77+
</td>
78+
</tr>
79+
<tr>
80+
<td>{invitation.hostId && <UsersNameContainer userId={invitation.hostId} isSimple />}</td>
81+
<td>
82+
<DateTime unixts={invitation.createdAt} />
83+
</td>
84+
<td>
85+
<DateTime unixts={invitation.expireAt} isDeadline />
86+
</td>
87+
<td>
88+
{invitation.note && (
89+
<OverlayTrigger
90+
placement="bottom"
91+
overlay={
92+
<Tooltip id={`note-${invitation.id}`}>
93+
<FormattedMessage id="app.groupInvitationForm.note" defaultMessage="Note:" />{' '}
94+
<strong>{invitation.note}</strong>
95+
</Tooltip>
96+
}>
97+
<Icon icon={['far', 'comment-dots']} gapLeft gapRight />
98+
</OverlayTrigger>
99+
)}
100+
</td>
101+
102+
{(editInvitation || deleteInvitation) && (
103+
<td className="text-nowrap text-right shrink-col">
104+
<TheButtonGroup>
105+
{editInvitation && (
106+
<Button variant="warning" size="xs" onClick={() => editInvitation(invitation.id)}>
107+
<EditIcon gapRight />
108+
<FormattedMessage id="generic.edit" defaultMessage="Edit" />
109+
</Button>
110+
)}
111+
112+
{deleteInvitation && (
113+
<Button
114+
variant="danger"
115+
size="xs"
116+
confirm={
117+
<FormattedMessage
118+
id="app.groupInvitations.confirmDelete"
119+
defaultMessage="The invitation will be completely removed. This action cannot be taken back. Do you wish to proceed?"
120+
/>
121+
}
122+
confirmId={`delete-${invitation.id}`}
123+
onClick={() => deleteInvitation(invitation.id)}>
124+
<DeleteIcon gapRight />
125+
<FormattedMessage id="generic.delete" defaultMessage="Delete" />
126+
</Button>
127+
)}
128+
</TheButtonGroup>
129+
</td>
130+
)}
131+
</tr>
132+
</tbody>
133+
);
134+
})}
135+
</Table>
136+
) : (
137+
<div className="text-center text-muted mb-3">
138+
<FormattedMessage
139+
id="app.groupInvitations.noInvitations"
140+
defaultMessage="There are currently no invitations."
141+
/>
142+
</div>
143+
)}
144+
</>
145+
);
146+
};
147+
148+
GroupInvitations.propTypes = {
149+
invitations: PropTypes.array.isRequired,
150+
editInvitation: PropTypes.func,
151+
deleteInvitation: PropTypes.func,
152+
selected: PropTypes.string,
153+
links: PropTypes.object,
154+
};
155+
156+
export default withLinks(GroupInvitations);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import GroupInvitations from './GroupInvitations';
2+
export default GroupInvitations;

src/components/Users/CalendarTokens/CalendarTokens.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,7 @@ class CalendarTokens extends Component {
9999
placement="right"
100100
overlay={
101101
<Tooltip id={calendar.id}>
102-
<FormattedMessage
103-
id="app.calendarTokens.copyToClipboard"
104-
defaultMessage="Copy the URL into clipboard"
105-
/>
102+
<FormattedMessage id="generic.copyToClipboard" defaultMessage="Copy to clipboard" />
106103
</Tooltip>
107104
}>
108105
<CopyToClipboard

src/components/forms/Fields/DatetimeField.js

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import React, { Component } from 'react';
22
import PropTypes from 'prop-types';
33
import Datetime from 'react-datetime';
44
import 'react-datetime/css/react-datetime.css';
5-
65
import { Form, FormGroup, FormLabel, InputGroup } from 'react-bootstrap';
76
import classnames from 'classnames';
87

98
import withLinks from '../../../helpers/withLinks';
9+
import { UserUIDataContext } from '../../../helpers/contexts';
1010

1111
import styles from './commonStyles.less';
1212

@@ -39,31 +39,33 @@ class DatetimeField extends Component {
3939
...props
4040
} = this.props;
4141

42-
const { lang } = this.context;
43-
4442
return (
4543
<FormGroup controlId={input.name}>
4644
{Boolean(label) && (
4745
<FormLabel className={error ? 'text-danger' : warning ? 'text-warning' : undefined}>{label}</FormLabel>
4846
)}
4947
<InputGroup>
50-
<Datetime
51-
{...input}
52-
{...props}
53-
locale={lang}
54-
timeFormat={onlyDate ? false : 'H:mm'}
55-
onFocus={() => this.onFocus()}
56-
inputProps={{
57-
disabled,
58-
className: classnames({
59-
'form-control': true,
60-
[styles.dirty]: dirty && !ignoreDirty && !error && !warning,
61-
[styles.active]: active,
62-
'border-danger': error,
63-
'border-warning': !error && warning,
64-
}),
65-
}}
66-
/>
48+
<UserUIDataContext.Consumer>
49+
{({ dateFormatOverride = null }) => (
50+
<Datetime
51+
{...input}
52+
{...props}
53+
locale={dateFormatOverride}
54+
timeFormat={onlyDate ? false : 'H:mm'}
55+
onFocus={() => this.onFocus()}
56+
inputProps={{
57+
disabled,
58+
className: classnames({
59+
'form-control': true,
60+
[styles.dirty]: dirty && !ignoreDirty && !error && !warning,
61+
[styles.active]: active,
62+
'border-danger': error,
63+
'border-warning': !error && warning,
64+
}),
65+
}}
66+
/>
67+
)}
68+
</UserUIDataContext.Consumer>
6769
{prepend && <InputGroup.Prepend>{prepend}</InputGroup.Prepend>}
6870
{append && <InputGroup.Append>{append}</InputGroup.Append>}
6971
</InputGroup>
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import { FormattedMessage, injectIntl } from 'react-intl';
4+
import { reduxForm, Field } from 'redux-form';
5+
import { Container, Row, Col } from 'react-bootstrap';
6+
import moment from 'moment';
7+
8+
import SubmitButton from '../SubmitButton';
9+
import Callout from '../../widgets/Callout';
10+
import Explanation from '../../widgets/Explanation';
11+
import { CheckboxField, TextField, DatetimeField } from '../Fields';
12+
13+
export const createNewGroupInvitationInitialData = () => ({
14+
hasExpiration: false,
15+
expireAt: moment().add(1, 'months').endOf('day'),
16+
note: '',
17+
});
18+
19+
export const createEditGroupInvitationInitialData = ({ expireAt = null, note = '' }) => ({
20+
hasExpiration: expireAt !== null,
21+
expireAt: expireAt !== null ? moment.unix(expireAt) : moment().add(1, 'months').endOf('day'),
22+
note,
23+
});
24+
25+
const GroupInvitationForm = ({
26+
hasExpiration,
27+
submitting,
28+
handleSubmit,
29+
dirty,
30+
submitFailed = false,
31+
submitSucceeded = false,
32+
asyncValidating,
33+
invalid,
34+
intl: { locale },
35+
}) => (
36+
<Container fluid>
37+
<Row>
38+
<Col md={6}>
39+
<Field
40+
name="hasExpiration"
41+
component={CheckboxField}
42+
onOff
43+
label={
44+
<FormattedMessage id="app.groupInvitationForm.hasExpirationDate" defaultMessage="Has expiration date" />
45+
}
46+
/>
47+
</Col>
48+
{hasExpiration && (
49+
<Col md={6}>
50+
<Field
51+
name="expireAt"
52+
component={DatetimeField}
53+
label={
54+
<>
55+
<FormattedMessage id="app.groupInvitationForm.expireAt" defaultMessage="Expire at:" />
56+
<Explanation id="expire-at">
57+
<FormattedMessage
58+
id="app.groupInvitationForm.expireAtExplanation"
59+
defaultMessage="An invitation link will be still recognized by ReCodEx after the expiration date, but the students will not be allowed to use it."
60+
/>
61+
</Explanation>
62+
</>
63+
}
64+
/>
65+
</Col>
66+
)}
67+
</Row>
68+
69+
<Row>
70+
<Col xs={12}>
71+
<Field
72+
name="note"
73+
component={TextField}
74+
maxLength={255}
75+
label={
76+
<>
77+
<FormattedMessage id="app.groupInvitationForm.note" defaultMessage="Note:" />
78+
<Explanation id="note">
79+
<FormattedMessage
80+
id="app.groupInvitationForm.noteExplanation"
81+
defaultMessage="The note is displayed to the students before they accept the invitation and join the group."
82+
/>
83+
</Explanation>
84+
</>
85+
}
86+
/>
87+
</Col>
88+
</Row>
89+
90+
<Row>
91+
<Col xs={12}>
92+
{submitFailed && (
93+
<Callout variant="danger">
94+
<FormattedMessage id="generic.operationFailed" defaultMessage="Operation failed. Please try again later." />
95+
</Callout>
96+
)}
97+
98+
<div className="text-center">
99+
<SubmitButton
100+
id="groupInvitationSubmit"
101+
handleSubmit={handleSubmit}
102+
submitting={submitting}
103+
dirty={dirty}
104+
invalid={invalid}
105+
hasSucceeded={submitSucceeded}
106+
hasFailed={submitFailed}
107+
asyncValidating={asyncValidating}
108+
messages={{
109+
submit: <FormattedMessage id="generic.save" defaultMessage="Save" />,
110+
submitting: <FormattedMessage id="generic.saving" defaultMessage="Saving..." />,
111+
success: <FormattedMessage id="generic.saved" defaultMessage="Saved" />,
112+
}}
113+
/>
114+
</div>
115+
</Col>
116+
</Row>
117+
</Container>
118+
);
119+
120+
GroupInvitationForm.propTypes = {
121+
hasExpiration: PropTypes.bool,
122+
handleSubmit: PropTypes.func.isRequired,
123+
asyncValidate: PropTypes.func.isRequired,
124+
submitFailed: PropTypes.bool,
125+
submitSucceeded: PropTypes.bool,
126+
dirty: PropTypes.bool,
127+
submitting: PropTypes.bool,
128+
asyncValidating: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]),
129+
invalid: PropTypes.bool,
130+
intl: PropTypes.object.isRequired,
131+
};
132+
133+
export default reduxForm({
134+
form: 'group-invitation',
135+
enableReinitialize: true,
136+
keepDirtyOnReinitialize: false,
137+
})(injectIntl(GroupInvitationForm));
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import GroupInvitationForm, {
2+
createNewGroupInvitationInitialData,
3+
createEditGroupInvitationInitialData,
4+
} from './GroupInvitationForm';
5+
export default GroupInvitationForm;
6+
export { createNewGroupInvitationInitialData, createEditGroupInvitationInitialData };

0 commit comments

Comments
 (0)