(): Promise => Promise.resolve(),
-);
-
beforeEach(() => {
mock.reset();
diff --git a/client/app/bundles/course/assessment/submission/components/ScribingView/__test__/ScribingToolbar.test.jsx b/client/app/bundles/course/assessment/submission/components/ScribingView/__test__/ScribingToolbar.test.jsx
index 2e3fa8d411e..06f4f9681d1 100644
--- a/client/app/bundles/course/assessment/submission/components/ScribingView/__test__/ScribingToolbar.test.jsx
+++ b/client/app/bundles/course/assessment/submission/components/ScribingView/__test__/ScribingToolbar.test.jsx
@@ -1,4 +1,4 @@
-import MockAdapter from 'axios-mock-adapter';
+import { createMockAdapter } from 'mocks/axiosMock';
import { dispatch } from 'store';
import { act, fireEvent, render } from 'test-utils';
@@ -15,7 +15,7 @@ import actionTypes, {
} from '../../../constants';
const client = CourseAPI.assessment.answer.scribing.client;
-const mock = new MockAdapter(client);
+const mock = createMockAdapter(client);
const assessmentId = 1;
const submissionId = 2;
@@ -138,12 +138,6 @@ const props = {
setRedo: jest.fn(),
};
-// stub import function
-jest.mock(
- 'course/assessment/submission/loaders/ScribingViewLoader',
- () => () => Promise.resolve(),
-);
-
beforeEach(() => {
mock.reset();
diff --git a/client/app/bundles/course/assessment/submission/components/ScribingView/__test__/index.test.tsx b/client/app/bundles/course/assessment/submission/components/ScribingView/__test__/index.test.tsx
index 32039fefb3e..daa91fd1627 100644
--- a/client/app/bundles/course/assessment/submission/components/ScribingView/__test__/index.test.tsx
+++ b/client/app/bundles/course/assessment/submission/components/ScribingView/__test__/index.test.tsx
@@ -50,13 +50,8 @@ const mockSubmission = {
],
};
-jest.mock(
- 'course/assessment/submission/loaders/ScribingViewLoader',
- () => (): Promise => Promise.resolve(),
-);
-
describe('ScribingView', () => {
- it('renders canvas', () => {
+ it('renders canvas', async () => {
dispatch({
type: actionTypes.FETCH_SUBMISSION_SUCCESS,
payload: mockSubmission,
@@ -69,6 +64,6 @@ describe('ScribingView', () => {
const page = render( , { at: [url] });
- expect(page.getByTestId(`canvas-${answerId}`)).toBeVisible();
+ expect(await page.findByTestId(`canvas-${answerId}`)).toBeVisible();
});
});
diff --git a/client/app/bundles/course/assessment/submission/components/ScribingView/index.jsx b/client/app/bundles/course/assessment/submission/components/ScribingView/index.jsx
index e28e2ee30c6..5fff8029796 100644
--- a/client/app/bundles/course/assessment/submission/components/ScribingView/index.jsx
+++ b/client/app/bundles/course/assessment/submission/components/ScribingView/index.jsx
@@ -1,46 +1,38 @@
-import { Component } from 'react';
+import { lazy, Suspense } from 'react';
import PropTypes from 'prop-types';
-import scribingViewLoader from 'course/assessment/submission/loaders/ScribingViewLoader';
+import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { submissionShape } from '../../propTypes';
-import ScribingCanvas from './ScribingCanvas';
-import ScribingToolbar from './ScribingToolbar';
+const ScribingCanvas = lazy(() =>
+ import(/* webpackChunkName: "ScribingCanvas" */ './ScribingCanvas'),
+);
-const propTypes = {
- answerId: PropTypes.number.isRequired,
- submission: submissionShape,
-};
+const ScribingToolbar = lazy(() =>
+ import(/* webpackChunkName: "ScribingToolbar" */ './ScribingToolbar'),
+);
-const styles = {
- canvasDiv: {
- alignItems: 'center',
- marginBottom: 8,
- },
-};
+const ScribingViewComponent = (props) => {
+ const { answerId, submission } = props;
+ if (!answerId) return null;
+
+ return (
+ }>
+
+ {submission.canUpdate && (
+
+ )}
-export default class ScribingViewComponent extends Component {
- UNSAFE_componentWillMount() {
- scribingViewLoader().then(() => {
- this.forceUpdate();
- });
- }
-
- render() {
- const { answerId, submission } = this.props;
- return answerId ? (
-
- {submission.canUpdate ? (
-
- ) : null}
-
+
- ) : null;
- }
-}
+
+ );
+};
+
+ScribingViewComponent.propTypes = {
+ answerId: PropTypes.number.isRequired,
+ submission: submissionShape,
+};
-ScribingViewComponent.propTypes = propTypes;
+export default ScribingViewComponent;
diff --git a/client/app/bundles/course/assessment/submission/components/SubmissionAnswer.jsx b/client/app/bundles/course/assessment/submission/components/SubmissionAnswer.jsx
index b4c1a998bb0..3b519605860 100644
--- a/client/app/bundles/course/assessment/submission/components/SubmissionAnswer.jsx
+++ b/client/app/bundles/course/assessment/submission/components/SubmissionAnswer.jsx
@@ -1,12 +1,14 @@
import { Component } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import {
+ Alert,
Card,
CardContent,
CircularProgress,
FormControlLabel,
Switch,
Tooltip,
+ Typography,
} from '@mui/material';
import { yellow } from '@mui/material/colors';
import PropTypes from 'prop-types';
@@ -39,7 +41,7 @@ const translations = defineMessages({
},
viewPastAnswers: {
id: 'course.assessment.submission.SubmissionAnswer.viewPastAnswers',
- defaultMessage: 'View Past Answers',
+ defaultMessage: 'Past Answers',
},
});
@@ -167,11 +169,9 @@ class SubmissionAnswer extends Component {
renderMissingAnswerPanel() {
const { intl } = this.props;
return (
-
-
- {intl.formatMessage(translations.missingAnswer)}
-
-
+
+ {intl.formatMessage(translations.missingAnswer)}
+
);
}
@@ -192,11 +192,18 @@ class SubmissionAnswer extends Component {
const renderer = this.getRenderer(question);
return (
- <>
-
{question.displayTitle}
+
+
+ {question.displayTitle}
+
+
{this.renderHistoryToggle(question)}
-
-
+
+
+
{answerId
? renderer({
question,
@@ -206,7 +213,7 @@ class SubmissionAnswer extends Component {
showMcqMrqSolution,
})
: this.renderMissingAnswerPanel()}
- >
+
);
}
}
diff --git a/client/app/bundles/course/assessment/submission/components/TextResponseSolutions.jsx b/client/app/bundles/course/assessment/submission/components/TextResponseSolutions.jsx
index 7db29652e31..3a095d04886 100644
--- a/client/app/bundles/course/assessment/submission/components/TextResponseSolutions.jsx
+++ b/client/app/bundles/course/assessment/submission/components/TextResponseSolutions.jsx
@@ -5,6 +5,7 @@ import {
TableCell,
TableHead,
TableRow,
+ Typography,
} from '@mui/material';
import { questionShape } from '../propTypes';
@@ -14,9 +15,9 @@ function renderTextResponseSolutions(question) {
return (
<>
-
+
-
+
@@ -51,9 +52,9 @@ function renderTextResponseComprehensionPoint(point) {
return (
<>
-
+
-
+
@@ -104,9 +105,9 @@ function renderTextResponseComprehensionGroup(group) {
return (
<>
-
+
-
+
@@ -133,13 +134,12 @@ function renderTextResponseComprehensionGroup(group) {
function renderTextResponseComprehension(question) {
return (
<>
-
-
+
-
+
{question.groups.map((group) => (
{renderTextResponseComprehensionGroup(group)}
))}
diff --git a/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/ForumCard.jsx b/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/ForumCard.jsx
index 822c997685d..76f115dd746 100644
--- a/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/ForumCard.jsx
+++ b/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/ForumCard.jsx
@@ -1,6 +1,5 @@
import { Component } from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
-import { OpenInNew } from '@mui/icons-material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import {
Accordion,
@@ -16,6 +15,7 @@ import {
forumTopicPostPackShape,
postPackShape,
} from 'course/assessment/submission/propTypes';
+import Link from 'lib/components/core/Link';
import { getForumURL } from 'lib/helpers/url-builders';
import CardTitle from './CardTitle';
@@ -114,17 +114,17 @@ export default class ForumCard extends Component {
- }
- href={getForumURL(
+
-
-
+
+
+
+
diff --git a/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/ForumPost.jsx b/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/ForumPost.jsx
index 11162e1ee5b..65d3d72f7d5 100644
--- a/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/ForumPost.jsx
+++ b/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/ForumPost.jsx
@@ -7,6 +7,7 @@ import {
CardContent,
CardHeader,
Divider,
+ Typography,
} from '@mui/material';
import PropTypes from 'prop-types';
@@ -64,7 +65,7 @@ export default class ForumPost extends Component {
/>
- {
this.divElement = divElement;
}}
@@ -76,6 +77,7 @@ export default class ForumPost extends Component {
: MAX_POST_HEIGHT,
overflow: 'hidden',
}}
+ variant="body2"
/>
{this.state.isExpandable && (
Select {maxPosts} forum {maxPosts, plural, one {post} other {posts}}. ' +
'You have selected {numPosts} {numPosts, plural, one {post} other {posts}}.',
},
selectPostsButton: {
@@ -47,11 +42,6 @@ const styles = {
root: {
marginBottom: 16,
},
- instruction: {
- color: grey[700],
- fontSize: 14,
- marginBottom: 12,
- },
};
export default class ForumPostSelect extends Component {
@@ -112,31 +102,24 @@ export default class ForumPostSelect extends Component {
}
renderInstruction(postPacks, maxPosts) {
- if (this.props.readOnly) {
- return (
-
+ return (
+
+ {this.props.readOnly ? (
-
- );
- }
- return (
-
- {/* TODO: Refactor the below into a single FormattedMessage once react-intl is upgraded:
- https://formatjs.io/docs/react-intl/components/#rich-text-formatting */}
-
+ ) : (
{chunk} ,
+ }}
{...translations.selectInstructions}
/>
- {' '}
-
-
+ )}
+
);
}
diff --git a/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/Labels.jsx b/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/Labels.jsx
index 4e4781cfa9b..3c779561cc1 100644
--- a/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/Labels.jsx
+++ b/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/Labels.jsx
@@ -1,5 +1,6 @@
import { defineMessages, FormattedMessage } from 'react-intl';
import { Cached, Delete } from '@mui/icons-material';
+import { Typography } from '@mui/material';
import { orange, red } from '@mui/material/colors';
import PropTypes from 'prop-types';
@@ -43,16 +44,22 @@ const Labels = ({ post }) => {
{/* Actually, a post that has been deleted will have its isUpdated as null,
but we are checking here just to be sure. */}
{isPostUpdated && !isPostDeleted && (
-
+
-
+
)}
{isPostDeleted && (
-
+
-
+
)}
>
);
diff --git a/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/SelectedPostCard.jsx b/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/SelectedPostCard.jsx
index 61415073808..0ed2e2a6ff0 100644
--- a/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/SelectedPostCard.jsx
+++ b/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/SelectedPostCard.jsx
@@ -1,7 +1,7 @@
import { Component } from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import { ChevronRight, Delete, ExpandMore } from '@mui/icons-material';
-import { IconButton } from '@mui/material';
+import { IconButton, Typography } from '@mui/material';
import { green, red } from '@mui/material/colors';
import PropTypes from 'prop-types';
@@ -95,12 +95,10 @@ export default class SelectedPostCard extends Component {
) : (
)}
- {topic.isDeleted ? (
-
+
+ {topic.isDeleted ? (
-
- ) : (
-
+ ) : (
-
- )}
+ )}
+
);
}
diff --git a/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/TopicCard.jsx b/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/TopicCard.jsx
index 9dcf9d6e540..b26a612ff61 100644
--- a/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/TopicCard.jsx
+++ b/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/TopicCard.jsx
@@ -1,6 +1,5 @@
import { Component } from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
-import { OpenInNew } from '@mui/icons-material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import {
Accordion,
@@ -16,6 +15,7 @@ import {
postPackShape,
topicOverviewShape,
} from 'course/assessment/submission/propTypes';
+import Link from 'lib/components/core/Link';
import { getForumTopicURL } from 'lib/helpers/url-builders';
import CardTitle from './CardTitle';
@@ -108,14 +108,14 @@ export default class TopicCard extends Component {
-
-
-
-
+
+
+
+
diff --git a/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/index.jsx b/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/index.jsx
index d6c88dabc54..a1879074a7e 100644
--- a/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/index.jsx
+++ b/client/app/bundles/course/assessment/submission/components/answers/ForumPostResponse/index.jsx
@@ -1,11 +1,11 @@
import { useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
-import { toast } from 'react-toastify';
import PropTypes from 'prop-types';
import { questionShape } from 'course/assessment/submission/propTypes';
import Error from 'lib/components/core/ErrorCard';
import FormRichTextField from 'lib/components/form/fields/RichTextField';
+import toast from 'lib/hooks/toast';
import ForumPostSelect from './ForumPostSelect';
diff --git a/client/app/bundles/course/assessment/submission/components/answers/MultipleChoice.jsx b/client/app/bundles/course/assessment/submission/components/answers/MultipleChoice.jsx
index d12f0cf194f..5eebdf30a78 100644
--- a/client/app/bundles/course/assessment/submission/components/answers/MultipleChoice.jsx
+++ b/client/app/bundles/course/assessment/submission/components/answers/MultipleChoice.jsx
@@ -1,6 +1,6 @@
import { memo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
-import { FormControlLabel, Radio } from '@mui/material';
+import { FormControlLabel, Radio, Typography } from '@mui/material';
import { green } from '@mui/material/colors';
import PropTypes from 'prop-types';
@@ -20,21 +20,21 @@ const MultipleChoiceOptions = ({
0 && option.id === value[0]}
- control={ }
+ control={ }
disabled={readOnly}
label={
-
}
diff --git a/client/app/bundles/course/assessment/submission/components/answers/MultipleResponse.jsx b/client/app/bundles/course/assessment/submission/components/answers/MultipleResponse.jsx
index 6bbe40fcf0a..a93a474e06d 100644
--- a/client/app/bundles/course/assessment/submission/components/answers/MultipleResponse.jsx
+++ b/client/app/bundles/course/assessment/submission/components/answers/MultipleResponse.jsx
@@ -1,6 +1,6 @@
import { memo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
-import { Checkbox, FormControlLabel } from '@mui/material';
+import { Checkbox, FormControlLabel, Typography } from '@mui/material';
import { green } from '@mui/material/colors';
import PropTypes from 'prop-types';
@@ -20,21 +20,21 @@ const MultipleResponseOptions = ({
}
+ control={ }
disabled={readOnly}
label={
-
}
diff --git a/client/app/bundles/course/assessment/submission/components/answers/Programming/ProgrammingFile.jsx b/client/app/bundles/course/assessment/submission/components/answers/Programming/ProgrammingFile.jsx
index a37df45a3e1..dc9e9e12e76 100644
--- a/client/app/bundles/course/assessment/submission/components/answers/Programming/ProgrammingFile.jsx
+++ b/client/app/bundles/course/assessment/submission/components/answers/Programming/ProgrammingFile.jsx
@@ -1,7 +1,7 @@
import { Component } from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import Warning from '@mui/icons-material/Warning';
-import { Paper } from '@mui/material';
+import { Paper, Typography } from '@mui/material';
import { yellow } from '@mui/material/colors';
import PropTypes from 'prop-types';
@@ -36,7 +36,7 @@ class ProgrammingFile extends Component {
const { file, fieldName, language } = this.props;
return (
<>
- {file.filename}
+ {file.filename}
;
+ return (
+
+ );
}
render() {
diff --git a/client/app/bundles/course/assessment/submission/components/comment/CommentField.jsx b/client/app/bundles/course/assessment/submission/components/comment/CommentField.jsx
index 4986cb284d6..c4c49ed8ee0 100644
--- a/client/app/bundles/course/assessment/submission/components/comment/CommentField.jsx
+++ b/client/app/bundles/course/assessment/submission/components/comment/CommentField.jsx
@@ -1,7 +1,8 @@
import { Component } from 'react';
-import { defineMessages, FormattedMessage } from 'react-intl';
+import { defineMessages, injectIntl } from 'react-intl';
import { Tooltip } from 'react-tooltip';
-import { Button, CircularProgress } from '@mui/material';
+import { Send } from '@mui/icons-material';
+import { LoadingButton } from '@mui/lab';
import PropTypes from 'prop-types';
import CKEditorRichText from 'lib/components/core/fields/CKEditorRichText';
@@ -9,7 +10,7 @@ import CKEditorRichText from 'lib/components/core/fields/CKEditorRichText';
const translations = defineMessages({
prompt: {
id: 'course.assessment.submission.comment.CommentField.prompt',
- defaultMessage: 'Enter your comment here',
+ defaultMessage: 'Add a new comment here...',
},
comment: {
id: 'course.assessment.submission.comment.CommentField.comment',
@@ -26,7 +27,7 @@ const translations = defineMessages({
},
});
-export default class CommentField extends Component {
+class CommentField extends Component {
onChange(nextValue) {
const { handleChange } = this.props;
handleChange(nextValue);
@@ -34,6 +35,7 @@ export default class CommentField extends Component {
render() {
const {
+ intl,
createComment,
inputId,
isSubmittingNormalComment,
@@ -55,48 +57,43 @@ export default class CommentField extends Component {
-
-
- }
onChange={(nextValue) => this.onChange(nextValue)}
+ placeholder={intl.formatMessage(translations.prompt)}
value={value}
/>
- createComment(value)}
- startIcon={
- isSubmittingNormalComment ? : null
- }
- style={{ marginRight: 10, marginBottom: 10 }}
- variant="contained"
- >
-
-
- {renderDelayedCommentButton && (
-
- createComment(value, true)}
- startIcon={
- isSubmittingNormalComment ? (
-
- ) : null
- }
- style={{ marginRight: 10, marginBottom: 10 }}
- variant="contained"
- >
-
-
-
-
- )}
+
+ }
+ loading={isSubmittingNormalComment}
+ onClick={() => createComment(value)}
+ style={{ marginRight: 10, marginBottom: 10 }}
+ variant="contained"
+ >
+ {intl.formatMessage(translations.comment)}
+
+
+ {renderDelayedCommentButton && (
+
+ createComment(value, true)}
+ style={{ marginRight: 10, marginBottom: 10 }}
+ variant="contained"
+ >
+ {intl.formatMessage(translations.commentDelayed)}
+
+
+
+
+ )}
+
>
);
}
@@ -112,4 +109,8 @@ CommentField.propTypes = {
createComment: PropTypes.func,
handleChange: PropTypes.func,
+
+ intl: PropTypes.object.isRequired,
};
+
+export default injectIntl(CommentField);
diff --git a/client/app/bundles/course/assessment/submission/containers/Annotations.jsx b/client/app/bundles/course/assessment/submission/containers/Annotations.jsx
index a8da3828b72..f53bd467f50 100644
--- a/client/app/bundles/course/assessment/submission/containers/Annotations.jsx
+++ b/client/app/bundles/course/assessment/submission/containers/Annotations.jsx
@@ -1,11 +1,11 @@
import { Component } from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
-import { toast } from 'react-toastify';
import { Button, Card, CardContent } from '@mui/material';
import PropTypes from 'prop-types';
import withRouter from 'lib/components/navigation/withRouter';
+import toast from 'lib/hooks/toast';
import * as annotationActions from '../actions/annotations';
import CodaveriCommentCard from '../components/comment/CodaveriCommentCard';
diff --git a/client/app/bundles/course/assessment/submission/containers/Comments.jsx b/client/app/bundles/course/assessment/submission/containers/Comments.jsx
index 8cbaf7484a9..4a80e799138 100644
--- a/client/app/bundles/course/assessment/submission/containers/Comments.jsx
+++ b/client/app/bundles/course/assessment/submission/containers/Comments.jsx
@@ -1,10 +1,11 @@
import { Component } from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
-import { toast } from 'react-toastify';
-import { grey } from '@mui/material/colors';
+import { Typography } from '@mui/material';
import PropTypes from 'prop-types';
+import toast from 'lib/hooks/toast';
+
import * as commentActions from '../actions/comments';
import CommentCard from '../components/comment/CommentCard';
import CommentField from '../components/comment/CommentField';
@@ -32,10 +33,11 @@ class VisibleComments extends Component {
} = this.props;
return (
-
-
+
+
-
+
+
{posts.map(
(post) =>
(graderView || !post.isDelayed) && (
@@ -49,6 +51,7 @@ class VisibleComments extends Component {
/>
),
)}
+
- {intl.formatMessage(translations.gradeSummary)}
+
+ {intl.formatMessage(translations.gradeSummary)}
+
@@ -276,7 +279,9 @@ class VisibleGradingPanel extends Component {
return (
-
{intl.formatMessage(translations.statistics)}
+
+ {intl.formatMessage(translations.statistics)}
+
{tableRow(
diff --git a/client/app/bundles/course/assessment/submission/containers/PastAnswers.jsx b/client/app/bundles/course/assessment/submission/containers/PastAnswers.jsx
index 5de39e15a7c..55bb8de878f 100644
--- a/client/app/bundles/course/assessment/submission/containers/PastAnswers.jsx
+++ b/client/app/bundles/course/assessment/submission/containers/PastAnswers.jsx
@@ -8,6 +8,7 @@ import {
InputLabel,
MenuItem,
Select,
+ Typography,
} from '@mui/material';
import { yellow } from '@mui/material/colors';
import PropTypes from 'prop-types';
@@ -101,12 +102,12 @@ class PastAnswers extends Component {
return (
-
+
{answer.isDraftAnswer
? intl.formatMessage(translations.savedAt)
: intl.formatMessage(translations.submittedAt)}
: {date}
-
+
{this.getAnswersHistory(question, answer)}
diff --git a/client/app/bundles/course/assessment/submission/containers/PostPreview.jsx b/client/app/bundles/course/assessment/submission/containers/PostPreview.jsx
index f8c4467717a..d661332f653 100644
--- a/client/app/bundles/course/assessment/submission/containers/PostPreview.jsx
+++ b/client/app/bundles/course/assessment/submission/containers/PostPreview.jsx
@@ -1,5 +1,6 @@
import { connect } from 'react-redux';
-import ExpandMore from '@mui/icons-material/ExpandMore';
+import { ChevronRight } from '@mui/icons-material';
+import { Typography } from '@mui/material';
import PropTypes from 'prop-types';
import stripHtmlTags from 'lib/helpers/htmlFormatHelpers';
@@ -23,8 +24,11 @@ const VisiblePostPreview = (props) => {
const { style, creator, text } = props;
return (
-
- {`${creator}: ${stripHtmlTags(text)}`}
+
+
+
+ {`${creator}: ${stripHtmlTags(text)}`}
+
);
};
diff --git a/client/app/bundles/course/assessment/submission/containers/QuestionGrade.jsx b/client/app/bundles/course/assessment/submission/containers/QuestionGrade.jsx
index 7ab446e8699..db51ff2d82f 100644
--- a/client/app/bundles/course/assessment/submission/containers/QuestionGrade.jsx
+++ b/client/app/bundles/course/assessment/submission/containers/QuestionGrade.jsx
@@ -1,20 +1,15 @@
import { Component } from 'react';
import { connect } from 'react-redux';
-import { Paper } from '@mui/material';
-import { grey } from '@mui/material/colors';
+import { Paper, Typography } from '@mui/material';
import PropTypes from 'prop-types';
+import TextField from 'lib/components/core/fields/TextField';
+
import actionTypes from '../constants';
import { questionGradeShape, questionShape } from '../propTypes';
const GRADE_STEP = 1;
-const styles = {
- container: {
- marginTop: 20,
- },
-};
-
/**
* Checks if the given value is a valid decimal of the form `0.00`.
*
@@ -60,10 +55,11 @@ class VisibleQuestionGrade extends Component {
renderQuestionGrade() {
const { question, grading } = this.props;
+
return (
-
+
{`${grading.grade} / ${question.maximumGrade}`}
-
+
);
}
@@ -73,9 +69,10 @@ class VisibleQuestionGrade extends Component {
const maxGrade = question.maximumGrade;
return (
-
-
+ this.processValue(e.target.value)}
onChange={(e) => this.processValue(e.target.value, true)}
onKeyDown={(e) => {
@@ -89,10 +86,13 @@ class VisibleQuestionGrade extends Component {
this.stepGrade(-GRADE_STEP);
}
}}
+ size="small"
style={{ width: 100 }}
value={initialGrade ?? ''}
+ variant="filled"
/>
- {` / ${maxGrade}`}
+
+ {` / ${maxGrade}`}
);
}
@@ -105,16 +105,14 @@ class VisibleQuestionGrade extends Component {
}
return (
-
-
- Grading
-
+
+
+ Grade
+
+
{editable
? this.renderQuestionGradeField()
: this.renderQuestionGrade()}
diff --git a/client/app/bundles/course/assessment/submission/containers/TestCaseView/__test__/index.test.js b/client/app/bundles/course/assessment/submission/containers/TestCaseView/__test__/index.test.js
index a6a3c8bf384..a60d3cad126 100644
--- a/client/app/bundles/course/assessment/submission/containers/TestCaseView/__test__/index.test.js
+++ b/client/app/bundles/course/assessment/submission/containers/TestCaseView/__test__/index.test.js
@@ -1,7 +1,7 @@
-import { mount } from 'enzyme';
+/* eslint-disable sonarjs/no-duplicate-string */
+import { render, within } from 'test-utils';
import { VisibleTestCaseView } from 'course/assessment/submission/containers/TestCaseView';
-import Providers from 'lib/components/wrappers/Providers';
import { workflowStates } from '../../../constants';
@@ -51,162 +51,136 @@ const defaultStaffViewProps = {
},
};
-const publicTestCases = '#publicTestCases';
-const privateTestCases = '#privateTestCases';
-const evaluationTestCases = '#evaluationTestCases';
-const standardOutput = '#standardOutput';
-const standardError = '#standardError';
-const privateWarningIcon = '#warning-icon-private-test-cases';
-const evaluationWarningIcon = '#warning-icon-evaluation-test-cases';
-const standardErrorWarningIcon = '#warning-icon-standard-error';
-const standardOutputWarningIcon = '#warning-icon-standard-output';
+const getWarning = (page, text) =>
+ within(page.getByText(text).closest('div')).queryByText(
+ 'Only staff can see this.',
+ { exact: false },
+ );
describe('TestCaseView', () => {
describe('when viewing as staff', () => {
it('renders all test cases and standard streams', () => {
- const testCaseView = mount(
-
-
- ,
- );
+ const page = render( );
- expect(testCaseView.find(publicTestCases).exists()).toBe(true);
- expect(testCaseView.find(privateTestCases).exists()).toBe(true);
- expect(testCaseView.find(evaluationTestCases).exists()).toBe(true);
- expect(testCaseView.find(standardOutput).exists()).toBe(true);
- expect(testCaseView.find(standardError).exists()).toBe(true);
+ expect(page.getByText('Public Test Cases')).toBeVisible();
+ expect(page.getByText('Private Test Cases')).toBeVisible();
+ expect(page.getByText('Evaluation Test Cases')).toBeVisible();
+ expect(page.getByText('Standard Output')).toBeVisible();
+ expect(page.getByText('Standard Error')).toBeVisible();
});
it('renders staff-only warnings', () => {
- const testCaseView = mount(
-
-
- ,
- );
+ const page = render( );
- expect(testCaseView.find(privateWarningIcon).exists()).toBe(true);
- expect(testCaseView.find(evaluationWarningIcon).exists()).toBe(true);
- expect(testCaseView.find(standardOutputWarningIcon).exists()).toBe(true);
- expect(testCaseView.find(standardErrorWarningIcon).exists()).toBe(true);
+ expect(getWarning(page, 'Private Test Cases')).toBeVisible();
+ expect(getWarning(page, 'Evaluation Test Cases')).toBeVisible();
+ expect(getWarning(page, 'Standard Output')).toBeVisible();
+ expect(getWarning(page, 'Standard Error')).toBeVisible();
});
describe('when showEvaluation & showPrivate are true', () => {
it('renders staff-only warnings when assessment is not yet published', () => {
- const testCaseView = mount(
-
-
- ,
+ const page = render(
+ ,
);
- expect(testCaseView.find(privateWarningIcon).exists()).toBe(true);
- expect(testCaseView.find(evaluationWarningIcon).exists()).toBe(true);
+ expect(getWarning(page, 'Private Test Cases')).toBeVisible();
+ expect(getWarning(page, 'Evaluation Test Cases')).toBeVisible();
});
it('does not render staff-only warnings when assessment is published', () => {
- const testCaseView = mount(
-
-
- ,
+ const page = render(
+ ,
);
- expect(testCaseView.find(privateWarningIcon).exists()).toBe(false);
- expect(testCaseView.find(evaluationWarningIcon).exists()).toBe(false);
+ expect(getWarning(page, 'Private Test Cases')).not.toBeInTheDocument();
+ expect(
+ getWarning(page, 'Evaluation Test Cases'),
+ ).not.toBeInTheDocument();
});
});
describe('when students can see standard streams', () => {
it('does not render staff-only warnings', () => {
- const testCaseView = mount(
-
-
- ,
+ const page = render(
+ ,
);
- expect(testCaseView.find(standardOutputWarningIcon).exists()).toBe(
- false,
- );
- expect(testCaseView.find(standardErrorWarningIcon).exists()).toBe(
- false,
- );
+ expect(getWarning(page, 'Standard Output')).not.toBeInTheDocument();
+ expect(getWarning(page, 'Standard Error')).not.toBeInTheDocument();
});
});
});
describe('when viewing as student', () => {
it('does not show any staff-only warnings', () => {
- const testCaseView = mount(
-
-
- ,
+ const page = render(
+ ,
);
- expect(testCaseView.find(privateWarningIcon).exists()).toBe(false);
- expect(testCaseView.find(evaluationWarningIcon).exists()).toBe(false);
- expect(testCaseView.find(standardOutputWarningIcon).exists()).toBe(false);
- expect(testCaseView.find(standardErrorWarningIcon).exists()).toBe(false);
+ expect(getWarning(page, 'Private Test Cases')).not.toBeInTheDocument();
+ expect(getWarning(page, 'Evaluation Test Cases')).not.toBeInTheDocument();
+ expect(getWarning(page, 'Standard Output')).not.toBeInTheDocument();
+ expect(getWarning(page, 'Standard Error')).not.toBeInTheDocument();
});
it('shows standard streams when the flag is enabled', () => {
- const testCaseView = mount(
-
-
- ,
+ const page = render(
+ ,
);
- expect(testCaseView.find(standardOutput).exists()).toBe(true);
- expect(testCaseView.find(standardError).exists()).toBe(true);
+ expect(page.getByText('Standard Output')).toBeVisible();
+ expect(page.getByText('Standard Error')).toBeVisible();
});
describe('when showEvaluation & showPrivate flags are enabled', () => {
it('shows private and evaluation tests after assessment is published', () => {
- const testCaseView = mount(
-
-
- ,
+ const page = render(
+ ,
);
- expect(testCaseView.find(privateTestCases).exists()).toBe(true);
- expect(testCaseView.find(evaluationTestCases).exists()).toBe(true);
+ expect(page.getByText('Private Test Cases')).toBeVisible();
+ expect(page.getByText('Evaluation Test Cases')).toBeVisible();
});
it('does not show private and evaluation tests before assessment is published', () => {
- const testCaseView = mount(
-
-
- ,
+ const page = render(
+ ,
);
- expect(testCaseView.find(privateTestCases).exists()).toBe(false);
- expect(testCaseView.find(evaluationTestCases).exists()).toBe(false);
+ expect(page.queryByText('Private Test Cases')).not.toBeInTheDocument();
+ expect(
+ page.queryByText('Evaluation Test Cases'),
+ ).not.toBeInTheDocument();
});
});
});
diff --git a/client/app/bundles/course/assessment/submission/containers/TestCaseView/index.jsx b/client/app/bundles/course/assessment/submission/containers/TestCaseView/index.jsx
index 51d1e616e66..2ae13530b4e 100644
--- a/client/app/bundles/course/assessment/submission/containers/TestCaseView/index.jsx
+++ b/client/app/bundles/course/assessment/submission/containers/TestCaseView/index.jsx
@@ -1,16 +1,11 @@
-import { Component } from 'react';
+import { Component, Fragment } from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
-import { Tooltip } from 'react-tooltip';
-import Check from '@mui/icons-material/Check';
+import { Done } from '@mui/icons-material';
import Clear from '@mui/icons-material/Clear';
-import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
-import Warning from '@mui/icons-material/Warning';
import {
- Accordion,
- AccordionDetails,
- AccordionSummary,
- Paper,
+ Alert,
+ Chip,
Table,
TableBody,
TableCell,
@@ -18,40 +13,24 @@ import {
TableRow,
Typography,
} from '@mui/material';
-import { green, red, yellow } from '@mui/material/colors';
+import { green, red } from '@mui/material/colors';
import PropTypes from 'prop-types';
import Expandable from 'lib/components/core/Expandable';
+import Accordion from 'lib/components/core/layouts/Accordion';
import { workflowStates } from '../../constants';
import { testCaseShape } from '../../propTypes';
const styles = {
- panel: {
- margin: 0,
- },
- panelSummary: {
- fontSize: 16,
- },
testCaseRow: {
unattempted: {},
correct: { backgroundColor: green[50] },
wrong: { backgroundColor: red[50] },
},
- testCasesContainer: {
- marginBottom: 20,
- },
};
const translations = defineMessages({
- testCases: {
- id: 'course.assessment.submission.TestCaseView.testCases',
- defaultMessage: 'Test Cases',
- },
- identifier: {
- id: 'course.assessment.submission.TestCaseView.identifier',
- defaultMessage: 'Identifier',
- },
expression: {
id: 'course.assessment.submission.TestCaseView.experession',
defaultMessage: 'Expression',
@@ -64,9 +43,9 @@ const translations = defineMessages({
id: 'course.assessment.submission.TestCaseView.output',
defaultMessage: 'Output',
},
- passed: {
- id: 'course.assessment.submission.TestCaseView.passed',
- defaultMessage: 'Passed',
+ allPassed: {
+ id: 'course.assessment.submission.TestCaseView.allPassed',
+ defaultMessage: 'All passed',
},
publicTestCases: {
id: 'course.assessment.submission.TestCaseView.publicTestCases',
@@ -82,15 +61,12 @@ const translations = defineMessages({
},
staffOnlyTestCases: {
id: 'course.assessment.submission.TestCaseView.staffOnlyTestCases',
- defaultMessage:
- 'You are able to view these test cases because you are staff. \
- Students will not be able to see them.',
+ defaultMessage: 'Only staff can see this.',
},
staffOnlyOutputStream: {
id: 'course.assessment.submission.TestCaseView.staffOnlyOutputStream',
defaultMessage:
- 'You can view the output streams because you are staff. \
- Students will not be able to see them.',
+ "Only staff can see this. Students can't see output streams.",
},
standardOutput: {
id: 'course.assessment.submission.TestCaseView.standardOutput',
@@ -106,6 +82,10 @@ const translations = defineMessages({
'The answer is currently being evaluated, come back after a while \
to see the latest results.',
},
+ noOutputs: {
+ id: 'course.assessment.submission.TestCaseView.noOutputs',
+ defaultMessage: 'No outputs',
+ },
});
export class VisibleTestCaseView extends Component {
@@ -113,87 +93,31 @@ export class VisibleTestCaseView extends Component {
return (
}
+ size="small"
+ variant="outlined"
+ />
+ )
+ }
id={outputStreamType}
style={styles.panel}
+ subtitle={
+ showStaffOnlyWarning && (
+
+ )
+ }
+ title={ }
>
- }
- style={styles.panelSummary}
- >
- <>
-
- {showStaffOnlyWarning &&
- VisibleTestCaseView.renderStaffOnlyOutputStreamWarning(
- outputStreamType,
- )}
- >
-
-
- {output}
-
+ {output}
);
}
- static renderConditionalOutputStreamIcon(outputStreamType) {
- if (outputStreamType === 'standardOutput') {
- return ;
- }
- return ;
- }
-
- static renderStaffOnlyOutputStreamWarning(outputStreamType) {
- return (
-
-
- {VisibleTestCaseView.renderConditionalOutputStreamIcon(
- outputStreamType,
- )}
-
-
-
-
-
-
- );
- }
-
- static renderStaffOnlyTestCasesWarning(testCaseType) {
- return (
-
-
- {VisibleTestCaseView.renderConditionalTestCaseWarningIcon(
- testCaseType,
- )}
-
-
-
-
-
-
- );
- }
-
- static renderConditionalTestCaseWarningIcon(testCaseType) {
- if (testCaseType === 'publicTestCases') {
- return ;
- }
- if (testCaseType === 'privateTestCases') {
- return ;
- }
- return ;
- }
-
- static renderTitle(testCaseType, warn) {
- return (
- <>
-
- {warn &&
- VisibleTestCaseView.renderStaffOnlyTestCasesWarning(testCaseType)}
- >
- );
- }
-
renderTestCaseRow(testCase) {
const {
testCases: { canReadTests },
@@ -208,47 +132,62 @@ export class VisibleTestCaseView extends Component {
let testCaseIcon;
if (testCase.passed !== undefined) {
testCaseResult = testCase.passed ? 'correct' : 'wrong';
- testCaseIcon = testCase.passed ? : ;
+ testCaseIcon = testCase.passed ? (
+
+ ) : (
+
+ );
}
- const tableRowColumnFor = (field) => (
- {field}
- );
-
return (
-
- {canReadTests && tableRowColumnFor(truncatedIdentifier)}
-
- {tableRowColumnFor(
-
-
- {testCase.expression}
-
- ,
+
+ {canReadTests && (
+
+
+
+ {truncatedIdentifier}
+
+
+
)}
- {tableRowColumnFor(
-
-
- {testCase.expected || ''}
-
- ,
- )}
+
+
+
+
+ {testCase.expression}
+
+
+
- {(canReadTests || showPublicTestCasesOutput) &&
- tableRowColumnFor(
+
- {testCase.output || ''}
+ {testCase.expected || ''}
- ,
+
+
+
+ {(canReadTests || showPublicTestCasesOutput) && (
+
+
+
+ {testCase.output || ''}
+
+
+
)}
- {tableRowColumnFor(testCaseIcon)}
-
+ {testCaseIcon}
+
+
);
}
@@ -264,54 +203,60 @@ export class VisibleTestCaseView extends Component {
return null;
}
- const passedTestCases = testCases.reduce((val, testCase) => {
- if (testCase.passed !== undefined) {
- return val && testCase.passed;
- }
- return val;
- }, true);
- let headerStyle = { ...styles.panelSummary };
- if (collapsible && !isDraftAnswer) {
- headerStyle = {
- ...headerStyle,
- backgroundColor: passedTestCases ? green[100] : red[100],
- };
- }
-
- const tableHeaderColumnFor = (field) => (
-
-
-
+ const passedTestCases = testCases.reduce(
+ (passed, testCase) => passed && testCase?.passed,
+ true,
);
- const title = VisibleTestCaseView.renderTitle(testCaseType, warn);
+ const shouldShowAllPassed = !isDraftAnswer && passedTestCases;
return (
}
+ label={ }
+ size="small"
+ variant="outlined"
+ />
+ )
+ }
id={testCaseType}
- style={styles.panel}
+ subtitle={
+ warn &&
+ }
+ title={ }
>
- } style={headerStyle}>
- {title}
-
-
-
-
-
- {canReadTests && tableHeaderColumnFor('identifier')}
- {tableHeaderColumnFor('expression')}
- {tableHeaderColumnFor('expected')}
- {((graderView && canReadTests) || showPublicTestCasesOutput) &&
- tableHeaderColumnFor('output')}
- {tableHeaderColumnFor('passed')}
-
-
-
- {testCases.map(this.renderTestCaseRow.bind(this))}
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ {((graderView && canReadTests) || showPublicTestCasesOutput) && (
+
+
+
+ )}
+
+
+
+
+
+
+ {testCases.map(this.renderTestCaseRow.bind(this))}
+
+
);
}
@@ -342,27 +287,20 @@ export class VisibleTestCaseView extends Component {
(graderView && testCases.canReadTests) || showEvaluationTestToStudents;
return (
-
+
{isAutograding && (
-
+
-
+
)}
-
-
-
+
{this.renderTestCases(
testCases.public_test,
'publicTestCases',
false,
isDraftAnswer,
)}
+
{showPrivateTest &&
this.renderTestCases(
testCases.private_test,
@@ -370,6 +308,7 @@ export class VisibleTestCaseView extends Component {
!showPrivateTestToStudents,
isDraftAnswer,
)}
+
{showEvaluationTest &&
this.renderTestCases(
testCases.evaluation_test,
@@ -377,6 +316,7 @@ export class VisibleTestCaseView extends Component {
!showEvaluationTestToStudents,
isDraftAnswer,
)}
+
{showOutputStreams &&
!collapsible &&
VisibleTestCaseView.renderOutputStream(
@@ -384,6 +324,7 @@ export class VisibleTestCaseView extends Component {
testCases.stdout,
!showStdoutAndStderr,
)}
+
{showOutputStreams &&
!collapsible &&
VisibleTestCaseView.renderOutputStream(
diff --git a/client/app/bundles/course/assessment/submission/containers/UploadedFileView.jsx b/client/app/bundles/course/assessment/submission/containers/UploadedFileView.jsx
index 9ef8a80b547..7e4b3e14463 100644
--- a/client/app/bundles/course/assessment/submission/containers/UploadedFileView.jsx
+++ b/client/app/bundles/course/assessment/submission/containers/UploadedFileView.jsx
@@ -1,7 +1,7 @@
import { Component } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
-import { Chip } from '@mui/material';
+import { Chip, Typography } from '@mui/material';
import PropTypes from 'prop-types';
import ConfirmationDialog from 'lib/components/core/dialogs/ConfirmationDialog';
@@ -14,7 +14,7 @@ import { attachmentShape } from '../propTypes';
const translations = defineMessages({
uploadedFiles: {
id: 'course.assessment.submission.UploadedFileView.uploadedFiles',
- defaultMessage: 'Uploaded Files:',
+ defaultMessage: 'Uploaded Files',
},
deleteConfirmation: {
id: 'course.assessment.submission.UploadedFileView.deleteConfirmation',
@@ -101,12 +101,16 @@ class VisibleUploadedFileView extends Component {
const { intl, attachments } = this.props;
return (
<>
-
{intl.formatMessage(translations.uploadedFiles)}
+
+ {intl.formatMessage(translations.uploadedFiles)}
+
{attachments.length ? (
attachments.map(this.renderAttachment, this)
) : (
- {intl.formatMessage(translations.noFiles)}
+
+ {intl.formatMessage(translations.noFiles)}
+
)}
{this.renderDeleteDialog()}
diff --git a/client/app/bundles/course/assessment/submission/loaders/ScribingViewLoader.js b/client/app/bundles/course/assessment/submission/loaders/ScribingViewLoader.js
deleted file mode 100644
index 2ef0dc4a754..00000000000
--- a/client/app/bundles/course/assessment/submission/loaders/ScribingViewLoader.js
+++ /dev/null
@@ -1,7 +0,0 @@
-const { Promise } = global;
-
-export default () =>
- Promise.all([
- import(/* webpackChunkName: "react-color" */ 'react-color'),
- import(/* webpackChunkName: "fabric" */ 'fabric'),
- ]);
diff --git a/client/app/bundles/course/assessment/submission/pages/LogsIndex/LogsHead.tsx b/client/app/bundles/course/assessment/submission/pages/LogsIndex/LogsHead.tsx
index e468136f920..793247beb66 100644
--- a/client/app/bundles/course/assessment/submission/pages/LogsIndex/LogsHead.tsx
+++ b/client/app/bundles/course/assessment/submission/pages/LogsIndex/LogsHead.tsx
@@ -55,7 +55,7 @@ const LogsHead: FC
= (props) => {
{t(translations.studentName)}
- {info.studentName}
+ {info.studentName}
diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionEditForm.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionEditForm.jsx
index f85e901e6c1..4df11d06d95 100644
--- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionEditForm.jsx
+++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionEditForm.jsx
@@ -12,10 +12,12 @@ import {
DialogActions,
DialogContent,
DialogTitle,
+ Paper,
Step,
StepButton,
Stepper,
SvgIcon,
+ Typography,
} from '@mui/material';
import { blue, grey, red, yellow } from '@mui/material/colors';
import PropTypes from 'prop-types';
@@ -45,18 +47,11 @@ const Comments = lazy(() =>
);
const styles = {
- questionCardContainer: {
- marginTop: 20,
- padding: 40,
- },
explanationContainer: {
marginTop: 30,
marginBottom: 30,
borderRadius: 5,
},
- questionContainer: {
- paddingTop: 10,
- },
formButton: {
marginBottom: 10,
marginRight: 10,
@@ -65,9 +60,6 @@ const styles = {
marginBottom: 5,
marginRight: 5,
},
- loadingComment: {
- marginTop: 10,
- },
};
const SubmissionEditForm = (props) => {
@@ -178,7 +170,9 @@ const SubmissionEditForm = (props) => {
{intl.formatMessage(translations.examDialogTitle)}
- {intl.formatMessage(translations.examDialogMessage)}
+
+ {intl.formatMessage(translations.examDialogMessage)}
+
setExamNotice(false)}>
@@ -223,7 +217,11 @@ const SubmissionEditForm = (props) => {
{explanation.explanations.map((exp, index) => {
const key = `question-${questionId}-explanation-${index}`;
return (
-
+
);
})}
@@ -407,49 +405,48 @@ const SubmissionEditForm = (props) => {
};
const renderQuestions = () => (
- <>
+
{questionIds.map((id, index) => {
const question = questions[id];
const { answerId, topicId, viewHistory } = question;
const topic = topics[topicId];
return (
-
-
- {question.type === questionTypes.Programming && !viewHistory
- ? renderExplanationPanel(id)
- : null}
- {viewHistory ? null : renderAutogradingErrorPanel(id)}
- {viewHistory ? null : renderProgrammingQuestionActions(id)}
- {viewHistory ? null : renderQuestionGrading(id)}
-
- {intl.formatMessage(translations.loadingComment)}
-
- }
- >
-
-
-
+
+
+
+ {question.type === questionTypes.Programming &&
+ !viewHistory &&
+ renderExplanationPanel(id)}
+
+ {!viewHistory && renderAutogradingErrorPanel(id)}
+ {!viewHistory && renderProgrammingQuestionActions(id)}
+ {!viewHistory && renderQuestionGrading(id)}
+
+
+ {intl.formatMessage(translations.loadingComment)}
+
+ }
+ >
+
+
+
);
})}
- >
+
);
const renderResetDialog = () => (
@@ -664,7 +661,7 @@ const SubmissionEditForm = (props) => {
);
return (
-
+ <>
+ >
);
};
diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionEditStepForm.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionEditStepForm.jsx
index 4f682fc1958..fd063e790ed 100644
--- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionEditStepForm.jsx
+++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionEditStepForm.jsx
@@ -8,6 +8,7 @@ import {
CardContent,
CardHeader,
CircularProgress,
+ Paper,
Step,
StepButton,
StepLabel,
@@ -512,7 +513,7 @@ const SubmissionEditStepForm = (props) => {
{renderExplanationPanel(question)}
{!attempting && graderView ? renderReevaluateButton() : null}
{renderQuestionGrading(id)}
- {renderGradingPanel()}
+
{attempting ? (
{renderResetButton()}
@@ -521,17 +522,7 @@ const SubmissionEditStepForm = (props) => {
{renderAnswerLoadingIndicator()}
) : null}
-
- {renderSaveGradeButton()}
- {renderSaveDraftButton()}
-
-
- {renderFinaliseButton()}
-
- {renderUnsubmitButton()}
-
-
>
);
@@ -594,20 +585,37 @@ const SubmissionEditStepForm = (props) => {
return (
{renderStepper()}
-
-
-
+
+
+ {renderSubmitDialog()}
+
+
+
+ {renderGradingPanel()}
+
+
+ {renderSaveGradeButton()}
+ {renderSaveDraftButton()}
+
+
+ {renderFinaliseButton()}
+
+
+ {renderUnsubmitButton()}
+
+
{renderUnsubmitDialog()}
{renderResetDialog()}
diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx
index e6b2596d210..ab6bd37537b 100644
--- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx
+++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx
@@ -9,6 +9,7 @@ import {
CardHeader,
FormControlLabel,
Switch,
+ Typography,
} from '@mui/material';
import PropTypes from 'prop-types';
import withHeartbeatWorker from 'workers/withHeartbeatWorker';
@@ -234,15 +235,18 @@ class VisibleSubmissionEditIndex extends Component {
return (
- {assessment.title}} />
+
{assessment.description ? (
-
+
+
+
) : null}
- {assessment.files.length > 0 && (
+ {assessment.files?.length > 0 && (
- Files
+ Files
{assessment.files.map(renderFile)}
)}
@@ -428,7 +432,7 @@ class VisibleSubmissionEditIndex extends Component {
if (isLoading) return ;
if (isSubmissionBlocked) return ;
return (
-
+
{this.renderAssessment()}
{this.renderProgress()}
{this.renderContent()}
diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/SubmissionsTableRow.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/SubmissionsTableRow.jsx
index 6c61248151c..d61362a5e10 100644
--- a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/SubmissionsTableRow.jsx
+++ b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/SubmissionsTableRow.jsx
@@ -216,7 +216,6 @@ const SubmissionsTableRow = (props) => {
{
textColor: 'white',
color: palette.links,
}}
+ to={getEditSubmissionURL(courseId, assessmentId, submission.id)}
variant="filled"
/>
)}
diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/__test__/submissionsTable.test.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/__test__/submissionsTable.test.jsx
index 1c75aef8c7f..32392aa6b7f 100644
--- a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/__test__/submissionsTable.test.jsx
+++ b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/__test__/submissionsTable.test.jsx
@@ -1,7 +1,4 @@
-import { BrowserRouter } from 'react-router-dom';
-import { mount } from 'enzyme';
-
-import Providers from 'lib/components/wrappers/Providers';
+import { render } from 'test-utils';
import SubmissionsTable from '../SubmissionsTable';
@@ -27,6 +24,7 @@ const defaultProps = {
isUnsubmitting: false,
isDeleting: false,
isReminding: false,
+ isActive: true,
dispatch: () => {},
submissions: [
{
@@ -47,62 +45,48 @@ const defaultProps = {
],
};
-const setupTest = (propsOverrides) => {
- const props = { ...defaultProps, ...propsOverrides };
- const submissionsTable = mount(
-
-
-
-
- ,
- );
-
- return {
- props,
- submissionsTable,
- rowCount: submissionsTable.find('tr.submission-row'),
- logCount: submissionsTable.find('span.submission-access-logs'),
- unsubmitCount: submissionsTable.find('span.unsubmit-button'),
- deleteCount: submissionsTable.find('span.delete-button'),
- };
-};
-
describe(' ', () => {
describe('when canViewLogs, canUnsubmitSubmission and canDeleteAllSubmissions are set to true ', () => {
it('renders the submissions table with access log links', () => {
- const assessmentProps = {
- ...defaultAssessmentProps,
- canViewLogs: true,
- canUnsubmitSubmission: true,
- canDeleteAllSubmissions: true,
- };
- const { rowCount, logCount, unsubmitCount, deleteCount } = setupTest({
- assessment: assessmentProps,
- });
+ const page = render(
+ ,
+ );
- expect(rowCount).toHaveLength(1);
- expect(logCount).toHaveLength(1);
- expect(unsubmitCount).toHaveLength(1);
- expect(deleteCount).toHaveLength(1);
+ expect(page.getByText('John').closest('tr')).toBeVisible();
+ expect(page.getByTestId('HistoryIcon').closest('button')).toBeVisible();
+ expect(page.getByTestId('DeleteIcon').closest('button')).toBeVisible();
+ expect(
+ page.getByTestId('RemoveCircleIcon').closest('button'),
+ ).toBeVisible();
});
});
describe('when canViewLogs, canUnsubmitSubmission and canDeleteAllSubmissions are set to false', () => {
it('renders the submissions table without access log links', () => {
- const assessmentProps = {
- ...defaultAssessmentProps,
- canViewLogs: false,
- canUnsubmitSubmission: false,
- canDeleteAllSubmissions: false,
- };
- const { rowCount, logCount, unsubmitCount, deleteCount } = setupTest({
- assessment: assessmentProps,
- });
+ const page = render(
+ ,
+ );
- expect(rowCount).toHaveLength(1);
- expect(logCount).toHaveLength(0);
- expect(unsubmitCount).toHaveLength(0);
- expect(deleteCount).toHaveLength(0);
+ expect(page.getByText('John').closest('tr')).toBeVisible();
+ expect(page.queryByTestId('HistoryIcon')).not.toBeInTheDocument();
+ expect(page.queryByTestId('DeleteIcon')).not.toBeInTheDocument();
+ expect(page.queryByTestId('RemoveCircleIcon')).not.toBeInTheDocument();
});
});
});
diff --git a/client/app/bundles/course/assessment/submissions/SubmissionsIndex.tsx b/client/app/bundles/course/assessment/submissions/SubmissionsIndex.tsx
index e11a14b8c28..238af34e37b 100644
--- a/client/app/bundles/course/assessment/submissions/SubmissionsIndex.tsx
+++ b/client/app/bundles/course/assessment/submissions/SubmissionsIndex.tsx
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import {
SubmissionAssessmentFilterData,
SubmissionGroupFilterData,
@@ -11,6 +10,7 @@ import BackendPagination from 'lib/components/core/layouts/BackendPagination';
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import SubmissionFilter from './components/misc/SubmissionFilter';
diff --git a/client/app/bundles/course/assessment/submissions/components/buttons/SubmissionsTableButton.tsx b/client/app/bundles/course/assessment/submissions/components/buttons/SubmissionsTableButton.tsx
index a312ead2b18..bffa0e9b386 100644
--- a/client/app/bundles/course/assessment/submissions/components/buttons/SubmissionsTableButton.tsx
+++ b/client/app/bundles/course/assessment/submissions/components/buttons/SubmissionsTableButton.tsx
@@ -2,6 +2,7 @@ import { FC } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
import { Button } from '@mui/material';
+import Link from 'lib/components/core/Link';
import { getEditAssessmentSubmissionURL } from 'lib/helpers/url-builders';
import { getCourseId } from 'lib/helpers/url-helpers';
@@ -26,21 +27,24 @@ const SubmissionsTableButton: FC = (props) => {
const { intl, canGrade, assessmentId, submissionId } = props;
return (
-
- {canGrade
- ? intl.formatMessage(translations.gradeButton)
- : intl.formatMessage(translations.viewButton)}
-
+
+ {canGrade
+ ? intl.formatMessage(translations.gradeButton)
+ : intl.formatMessage(translations.viewButton)}
+
+
);
};
diff --git a/client/app/bundles/course/assessment/submissions/components/misc/SubmissionTabs.tsx b/client/app/bundles/course/assessment/submissions/components/misc/SubmissionTabs.tsx
index 4f33f87bdbe..e5ee9295d94 100644
--- a/client/app/bundles/course/assessment/submissions/components/misc/SubmissionTabs.tsx
+++ b/client/app/bundles/course/assessment/submissions/components/misc/SubmissionTabs.tsx
@@ -1,12 +1,12 @@
import { Dispatch, FC, SetStateAction, useEffect } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Box, Tab, Tabs } from '@mui/material';
import { tabsStyle } from 'theme/mui-style';
import { SubmissionsTabData } from 'types/course/assessment/submissions';
import CustomBadge from 'lib/components/extensions/CustomBadge';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import {
fetchAllStudentsPendingSubmissions,
diff --git a/client/app/bundles/course/assessment/translations.ts b/client/app/bundles/course/assessment/translations.ts
index c5525e5928c..820fcfc0841 100644
--- a/client/app/bundles/course/assessment/translations.ts
+++ b/client/app/bundles/course/assessment/translations.ts
@@ -361,17 +361,14 @@ const translations = defineMessages({
},
questionDuplicated: {
id: 'course.assessment.show.questionDuplicated',
- defaultMessage: 'Your question has been duplicated.',
+ defaultMessage:
+ 'Your question has been duplicated. Go to the assessment',
},
questionDuplicatedRefreshing: {
id: 'course.assessment.show.questionDuplicatedRefreshing',
defaultMessage:
'Your question has been duplicated. We are refreshing to show you the latest changes.',
},
- goToAssessment: {
- id: 'course.assessment.show.goToAssessment',
- defaultMessage: 'Go to the assessment',
- },
errorDuplicatingQuestion: {
id: 'course.assessment.show.errorDuplicatingQuestion',
defaultMessage: 'An error occurred when duplicating your question.',
diff --git a/client/app/bundles/course/container/CourseContainer.tsx b/client/app/bundles/course/container/CourseContainer.tsx
index 7250214ad20..1a6b6444934 100644
--- a/client/app/bundles/course/container/CourseContainer.tsx
+++ b/client/app/bundles/course/container/CourseContainer.tsx
@@ -42,7 +42,10 @@ const CourseContainer = (): JSX.Element => {
onChangeVisibility={setSidebarOpen}
/>
-
+
{!sidebarOpen && (
sidebarRef.current?.show()}>
@@ -57,7 +60,7 @@ const CourseContainer = (): JSX.Element => {
/>
-
+
diff --git a/client/app/bundles/course/container/Sidebar/CourseItem.tsx b/client/app/bundles/course/container/Sidebar/CourseItem.tsx
index a829010341a..b7170299680 100644
--- a/client/app/bundles/course/container/Sidebar/CourseItem.tsx
+++ b/client/app/bundles/course/container/Sidebar/CourseItem.tsx
@@ -1,39 +1,9 @@
-import { useMemo, useState } from 'react';
-import { defineMessages } from 'react-intl';
+import { ComponentRef, useRef } from 'react';
import { Avatar, Typography } from '@mui/material';
import { CourseLayoutData } from 'types/course/courses';
-import SearchField from 'lib/components/core/fields/SearchField';
import PopupMenu from 'lib/components/core/PopupMenu';
-import { useAppContext } from 'lib/containers/AppContainer';
-import useTranslation from 'lib/hooks/useTranslation';
-
-const translations = defineMessages({
- thisCourse: {
- id: 'course.courses.CourseItem.thisCourse',
- defaultMessage: 'This course',
- },
- jumpToOtherCourses: {
- id: 'course.courses.CourseItem.jumpToOtherCourses',
- defaultMessage: 'Jump to your other courses',
- },
- searchCourses: {
- id: 'course.courses.CourseItem.searchCourses',
- defaultMessage: 'Search courses',
- },
- noCoursesMatch: {
- id: 'course.courses.CourseItem.noCoursesMatch',
- defaultMessage: "Oops, no courses matched '{keyword}'.",
- },
- seeAllCourses: {
- id: 'course.courses.CourseItem.seeAllCourses',
- defaultMessage: 'See all courses',
- },
- createNewCourse: {
- id: 'course.courses.CourseItem.createNewCourse',
- defaultMessage: 'Create a new course',
- },
-});
+import CourseSwitcherPopupMenu from 'lib/components/navigation/CourseSwitcherPopupMenu';
interface CourseItemProps {
in: CourseLayoutData;
@@ -42,26 +12,13 @@ interface CourseItemProps {
const CourseItem = (props: CourseItemProps): JSX.Element => {
const { in: data } = props;
- const { t } = useTranslation();
-
- const { courses } = useAppContext();
-
- const [anchorElement, setAnchorElement] = useState
();
- const [filterCourseKeyword, setFilterCourseKeyword] = useState('');
-
- const filteredCourses = useMemo(() => {
- if (!filterCourseKeyword) return courses;
-
- return courses?.filter((course) =>
- course.title.toLowerCase().includes(filterCourseKeyword.toLowerCase()),
- );
- }, [filterCourseKeyword]);
+ const menuRef = useRef>(null);
return (
<>
setAnchorElement(e.currentTarget)}
+ onClick={(e): void => menuRef.current?.open(e)}
role="button"
tabIndex={0}
>
@@ -77,64 +34,13 @@ const CourseItem = (props: CourseItemProps): JSX.Element => {
- setAnchorElement(undefined)}
- >
-
- {data.courseTitle}
-
+
+
+ {data.courseTitle}
+
-
- {Boolean(courses?.length) && (
- <>
-
-
-
-
-
-
-
- {filteredCourses?.map((course) => (
-
- {course.title}
-
- ))}
-
- {!filteredCourses?.length && (
-
- {t(translations.noCoursesMatch, {
- keyword: filterCourseKeyword,
- })}
-
- )}
-
-
-
- >
- )}
-
-
-
- {t(translations.seeAllCourses)}
-
-
-
- {t(translations.createNewCourse)}
-
-
-
+
>
);
};
diff --git a/client/app/bundles/course/container/Sidebar/CourseUserItem.tsx b/client/app/bundles/course/container/Sidebar/CourseUserItem.tsx
index b3510956b9e..51f6414860c 100644
--- a/client/app/bundles/course/container/Sidebar/CourseUserItem.tsx
+++ b/client/app/bundles/course/container/Sidebar/CourseUserItem.tsx
@@ -4,7 +4,6 @@ import { Avatar, Typography } from '@mui/material';
import { CourseLayoutData } from 'types/course/courses';
import PopupMenu from 'lib/components/core/PopupMenu';
-import UserPopupMenuList from 'lib/components/navigation/UserPopupMenuList';
import { COURSE_USER_ROLES } from 'lib/constants/sharedConstants';
import useTranslation from 'lib/hooks/useTranslation';
@@ -34,16 +33,6 @@ const translations = defineMessages({
id: 'course.courses.CourseUserItem.inCoursemology',
defaultMessage: 'In Coursemology',
},
- notInThisCourse: {
- id: 'course.courses.CourseUserItem.notInThisCourse',
- defaultMessage: 'Not in this course',
- },
- notInThisCourseHint: {
- id: 'course.courses.CourseUserItem.notInThisCourseHint',
- defaultMessage:
- 'You are not a user in this course. So, you can only view publicly available information about this course. ' +
- "You may contact this course's instructor to be invited to this course.",
- },
});
interface CourseUserItemProps {
@@ -56,29 +45,24 @@ interface CourseUserNameAndRoleProps {
const CourseUserNameAndRole = (
props: CourseUserNameAndRoleProps,
-): JSX.Element => {
- const { t } = useTranslation();
-
- return (
- <>
-
- {props.from.courseUserName ?? props.from.userName}
-
-
-
- {COURSE_USER_ROLES[props.from.courseUserRole!] ??
- t(translations.notInThisCourse)}
-
- >
- );
-};
+): JSX.Element => (
+ <>
+
+ {props.from.courseUserName}
+
+
+
+ {COURSE_USER_ROLES[props.from.courseUserRole!]}
+
+ >
+);
const SimpleCourseUserItemContent = (
props: CourseUserItemProps,
@@ -147,47 +131,35 @@ const CourseUserItem = (props: CourseUserItemProps): JSX.Element => {
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
onClose={(): void => setAnchorElement(undefined)}
>
- {data.courseUserName ? (
- <>
- {data.userName !== data.courseUserName && (
- <>
-
- {t(translations.differentCourseNameHint)}
-
-
-
- >
- )}
-
-
-
- {t(translations.goToYourProfile)}
+ <>
+ {data.userName !== data.courseUserName && (
+ <>
+
+ {t(translations.differentCourseNameHint)}
+
+
+
+ >
+ )}
+
+
+
+ {t(translations.goToYourProfile)}
+
+
+ {data.manageEmailSubscriptionUrl && (
+
+ {t(translations.manageEmailSubscriptions)}
-
- {data.manageEmailSubscriptionUrl && (
-
- {t(translations.manageEmailSubscriptions)}
-
- )}
-
- >
- ) : (
-
- {t(translations.notInThisCourseHint)}
-
- )}
-
-
-
-
+ )}
+
+ >
>
);
diff --git a/client/app/bundles/course/container/Sidebar/LevelRing.tsx b/client/app/bundles/course/container/Sidebar/LevelRing.tsx
index fbf2692a665..528fe8c99b4 100644
--- a/client/app/bundles/course/container/Sidebar/LevelRing.tsx
+++ b/client/app/bundles/course/container/Sidebar/LevelRing.tsx
@@ -43,7 +43,10 @@ const LevelRing = (props: LevelRingProps): JSX.Element => {
{props.children}
-
+
{progress.level}
diff --git a/client/app/bundles/course/container/Sidebar/Sidebar.tsx b/client/app/bundles/course/container/Sidebar/Sidebar.tsx
index 1c21f5f5c79..a5f25fac318 100644
--- a/client/app/bundles/course/container/Sidebar/Sidebar.tsx
+++ b/client/app/bundles/course/container/Sidebar/Sidebar.tsx
@@ -38,8 +38,10 @@ const Sidebar = forwardRef, SidebarProps>(
>
+
-
+
+ {data.courseUserName && }
diff --git a/client/app/bundles/course/container/Sidebar/SidebarContainer.tsx b/client/app/bundles/course/container/Sidebar/SidebarContainer.tsx
index 036b9ae00a6..ada31eb653b 100644
--- a/client/app/bundles/course/container/Sidebar/SidebarContainer.tsx
+++ b/client/app/bundles/course/container/Sidebar/SidebarContainer.tsx
@@ -167,7 +167,7 @@ const SidebarContainer = forwardRef(
if (!(mobile && smallScreen)) return;
setPinned(false);
- }, [location.pathname]);
+ }, [location.pathname, location.search]);
const Container = mobile ? Collapsible : Hoverable;
diff --git a/client/app/bundles/course/courses/components/buttons/TodoAccessButton.tsx b/client/app/bundles/course/courses/components/buttons/TodoAccessButton.tsx
index 28426c5dfaf..e1945d4c4bc 100644
--- a/client/app/bundles/course/courses/components/buttons/TodoAccessButton.tsx
+++ b/client/app/bundles/course/courses/components/buttons/TodoAccessButton.tsx
@@ -1,48 +1,22 @@
-import { FC } from 'react';
-import { injectIntl, WrappedComponentProps } from 'react-intl';
import { Button } from '@mui/material';
-import axios from 'lib/axios';
+import Link from 'lib/components/core/Link';
-interface Props extends WrappedComponentProps {
+interface TodoAccessButtonProps {
accessButtonText: string;
accessButtonLink: string;
- submissionUrl: string;
- isVideo: boolean;
- isNewAttempt: boolean;
}
-const TodoAccessButton: FC = (props) => {
- const {
- accessButtonText,
- accessButtonLink,
- submissionUrl,
- isVideo,
- isNewAttempt,
- } = props;
+const TodoAccessButton = (props: TodoAccessButtonProps): JSX.Element => {
+ const { accessButtonText, accessButtonLink } = props;
+
return (
- {
- // TODO: Refactor below to remove if else check
- if (isVideo) {
- axios.get(accessButtonLink).then((response) => {
- window.location.href = `${submissionUrl}/${response.data.submissionId}/edit`;
- });
- } else if (isNewAttempt) {
- axios.get(accessButtonLink).then((response) => {
- window.location.href = response.data.redirectUrl;
- });
- } else {
- window.location.href = accessButtonLink;
- }
- }}
- style={{ width: 80 }}
- variant="contained"
- >
- {accessButtonText}
-
+
+
+ {accessButtonText}
+
+
);
};
-export default injectIntl(TodoAccessButton);
+export default TodoAccessButton;
diff --git a/client/app/bundles/course/courses/components/buttons/TodoIgnoreButton.tsx b/client/app/bundles/course/courses/components/buttons/TodoIgnoreButton.tsx
index 4c975c8283b..2111d46e515 100644
--- a/client/app/bundles/course/courses/components/buttons/TodoIgnoreButton.tsx
+++ b/client/app/bundles/course/courses/components/buttons/TodoIgnoreButton.tsx
@@ -1,10 +1,10 @@
import { FC } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Button } from '@mui/material';
import { getCourseId } from 'lib/helpers/url-helpers';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { removeTodo } from '../../operations';
@@ -48,10 +48,7 @@ const TodoIgnoreButton: FC = (props) => {
{
- onIgnore();
- }}
- style={{ width: 80 }}
+ onClick={onIgnore}
>
{intl.formatMessage(translations.ignoreButtonText)}
diff --git a/client/app/bundles/course/courses/components/forms/CourseInvitationCodeForm.tsx b/client/app/bundles/course/courses/components/forms/CourseInvitationCodeForm.tsx
index 804426220b3..fe6ff96dfc5 100644
--- a/client/app/bundles/course/courses/components/forms/CourseInvitationCodeForm.tsx
+++ b/client/app/bundles/course/courses/components/forms/CourseInvitationCodeForm.tsx
@@ -1,11 +1,11 @@
import { FC, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Button, TextField } from '@mui/material';
import { getRegistrationURL } from 'lib/helpers/url-builders';
import { getCourseId } from 'lib/helpers/url-helpers';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { sendNewRegistrationCode } from '../../operations';
diff --git a/client/app/bundles/course/courses/components/misc/CourseEnrolOptions.tsx b/client/app/bundles/course/courses/components/misc/CourseEnrolOptions.tsx
index 7a48bd93395..988595a52ce 100644
--- a/client/app/bundles/course/courses/components/misc/CourseEnrolOptions.tsx
+++ b/client/app/bundles/course/courses/components/misc/CourseEnrolOptions.tsx
@@ -1,11 +1,11 @@
import { FC } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Button } from '@mui/material';
import { getEnrolRequestURL } from 'lib/helpers/url-builders';
import { getCourseId } from 'lib/helpers/url-helpers';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { cancelEnrolRequest, submitEnrolRequest } from '../../operations';
import CourseInvitationCodeForm from '../forms/CourseInvitationCodeForm';
diff --git a/client/app/bundles/course/courses/components/tables/PendingTodosTable.tsx b/client/app/bundles/course/courses/components/tables/PendingTodosTable.tsx
index 63f78ff25ac..24daf3ba5a5 100644
--- a/client/app/bundles/course/courses/components/tables/PendingTodosTable.tsx
+++ b/client/app/bundles/course/courses/components/tables/PendingTodosTable.tsx
@@ -25,7 +25,6 @@ import {
getSurveyResponseURL,
getSurveyURL,
getVideoAttemptURL,
- getVideoSubmissionsURL,
} from 'lib/helpers/url-builders';
import { getCourseId } from 'lib/helpers/url-helpers';
@@ -105,7 +104,7 @@ const PendingTodosTable: FC = (props) => {
const renderButtons = (todo: TodoData): JSX.Element => {
let accessButtonText = '';
let accessButtonLink = '';
- let submissionUrl;
+
// TODO: Refactor below by changing switch to dictionary
switch (todoType) {
case 'surveys':
@@ -150,10 +149,6 @@ const PendingTodosTable: FC = (props) => {
)}/${todo.itemActableSpecificId}/edit`;
}
- submissionUrl = getAssessmentSubmissionURL(
- getCourseId(),
- todo.itemActableId,
- );
break;
case 'videos':
@@ -163,10 +158,6 @@ const PendingTodosTable: FC = (props) => {
todo.itemActableId,
);
- submissionUrl = getVideoSubmissionsURL(
- getCourseId(),
- todo.itemActableId,
- );
break;
default:
break;
@@ -182,13 +173,6 @@ const PendingTodosTable: FC = (props) => {
{
.catch(() => toast.error(t(translations.fetchCoursesFailure)));
}, [dispatch]);
+ useEffect(() => {
+ setIsNewCourseDialogOpen(shouldOpenNewCourseDialog);
+ }, [shouldOpenNewCourseDialog]);
+
// Adding appropriate button to the header
const headerToolbars: ReactElement[] = [];
if (coursesPermissions?.canCreate) {
diff --git a/client/app/bundles/course/courses/pages/CoursesNew/index.tsx b/client/app/bundles/course/courses/pages/CoursesNew/index.tsx
index e8e51ef23f9..be4543d589f 100644
--- a/client/app/bundles/course/courses/pages/CoursesNew/index.tsx
+++ b/client/app/bundles/course/courses/pages/CoursesNew/index.tsx
@@ -1,10 +1,10 @@
import { FC } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { NewCourseFormData } from 'types/course/courses';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import NewCourseForm from '../../components/forms/NewCourseForm';
diff --git a/client/app/bundles/course/discussion/topics/components/cards/CodaveriCommentCard.tsx b/client/app/bundles/course/discussion/topics/components/cards/CodaveriCommentCard.tsx
index 47e39002277..b18cb32c64f 100644
--- a/client/app/bundles/course/discussion/topics/components/cards/CodaveriCommentCard.tsx
+++ b/client/app/bundles/course/discussion/topics/components/cards/CodaveriCommentCard.tsx
@@ -1,6 +1,5 @@
import { FC, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { ArrowBack, Check, Clear, Reply } from '@mui/icons-material';
import { LoadingButton } from '@mui/lab';
import {
@@ -17,6 +16,7 @@ import { CommentPostMiniEntity } from 'types/course/comments';
import ConfirmationDialog from 'lib/components/core/dialogs/ConfirmationDialog';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { formatLongDateTime } from 'lib/moment';
import { deletePost, updatePostCodaveri } from '../../operations';
diff --git a/client/app/bundles/course/discussion/topics/components/cards/CommentCard.tsx b/client/app/bundles/course/discussion/topics/components/cards/CommentCard.tsx
index 0d1af9a3ee6..7018d93fbb6 100644
--- a/client/app/bundles/course/discussion/topics/components/cards/CommentCard.tsx
+++ b/client/app/bundles/course/discussion/topics/components/cards/CommentCard.tsx
@@ -5,11 +5,10 @@ import {
injectIntl,
WrappedComponentProps,
} from 'react-intl';
-import { toast } from 'react-toastify';
import Delete from '@mui/icons-material/Delete';
import Edit from '@mui/icons-material/Edit';
import { LoadingButton } from '@mui/lab';
-import { Avatar, Button, CardHeader } from '@mui/material';
+import { Avatar, Button, CardHeader, Typography } from '@mui/material';
import { grey, orange, red } from '@mui/material/colors';
import { CommentPostMiniEntity } from 'types/course/comments';
@@ -17,6 +16,7 @@ import ConfirmationDialog from 'lib/components/core/dialogs/ConfirmationDialog';
import CKEditorRichText from 'lib/components/core/fields/CKEditorRichText';
import Link from 'lib/components/core/Link';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { formatLongDateTime } from 'lib/moment';
import { deletePost, updatePost } from '../../operations';
@@ -169,7 +169,12 @@ const CommentCard: FC = (props) => {
);
}
- return
;
+ return (
+
+ );
};
return (
diff --git a/client/app/bundles/course/discussion/topics/components/fields/CommentField.tsx b/client/app/bundles/course/discussion/topics/components/fields/CommentField.tsx
index ba32da60ce1..fb0c574faa0 100644
--- a/client/app/bundles/course/discussion/topics/components/fields/CommentField.tsx
+++ b/client/app/bundles/course/discussion/topics/components/fields/CommentField.tsx
@@ -5,12 +5,12 @@ import {
injectIntl,
WrappedComponentProps,
} from 'react-intl';
-import { toast } from 'react-toastify';
import { LoadingButton } from '@mui/lab';
import { CommentTopicEntity } from 'types/course/comments';
import CKEditorRichText from 'lib/components/core/fields/CKEditorRichText';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { createPost } from '../../operations';
diff --git a/client/app/bundles/course/discussion/topics/components/lists/TopicList.tsx b/client/app/bundles/course/discussion/topics/components/lists/TopicList.tsx
index c8a2b8a89fe..ec70070df64 100644
--- a/client/app/bundles/course/discussion/topics/components/lists/TopicList.tsx
+++ b/client/app/bundles/course/discussion/topics/components/lists/TopicList.tsx
@@ -1,6 +1,5 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { Grid } from '@mui/material';
import { CommentSettings, CommentTopicEntity } from 'types/course/comments';
@@ -8,6 +7,7 @@ import BackendPagination from 'lib/components/core/layouts/BackendPagination';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import Note from 'lib/components/core/Note';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { fetchCommentData } from '../../operations';
diff --git a/client/app/bundles/course/discussion/topics/pages/CommentIndex/index.tsx b/client/app/bundles/course/discussion/topics/pages/CommentIndex/index.tsx
index 23b58efadf3..8a7ce6d4fdb 100644
--- a/client/app/bundles/course/discussion/topics/pages/CommentIndex/index.tsx
+++ b/client/app/bundles/course/discussion/topics/pages/CommentIndex/index.tsx
@@ -5,7 +5,6 @@ import {
IntlShape,
WrappedComponentProps,
} from 'react-intl';
-import { toast } from 'react-toastify';
import { Box, Tab, Tabs } from '@mui/material';
import { tabsStyle } from 'theme/mui-style';
import {
@@ -19,6 +18,7 @@ import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import CustomBadge from 'lib/components/extensions/CustomBadge';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import TopicList from '../../components/lists/TopicList';
import { fetchTabData } from '../../operations';
diff --git a/client/app/bundles/course/duplication/operations.js b/client/app/bundles/course/duplication/operations.js
index 5385157e1ab..b2944925db6 100644
--- a/client/app/bundles/course/duplication/operations.js
+++ b/client/app/bundles/course/duplication/operations.js
@@ -2,7 +2,7 @@ import CourseAPI from 'api/course';
import actionTypes from 'course/duplication/constants';
import pollJob from 'lib/helpers/jobHelpers';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
-import loadingToast from 'lib/hooks/loadingToast';
+import { loadingToast } from 'lib/hooks/toast';
import { actions } from './store';
import { getItemsPayload } from './utils';
diff --git a/client/app/bundles/course/duplication/pages/Duplication/__test__/ObjectDuplication.test.jsx b/client/app/bundles/course/duplication/pages/Duplication/__test__/ObjectDuplication.test.jsx
index 11ebdcd1d71..25ec78abe30 100644
--- a/client/app/bundles/course/duplication/pages/Duplication/__test__/ObjectDuplication.test.jsx
+++ b/client/app/bundles/course/duplication/pages/Duplication/__test__/ObjectDuplication.test.jsx
@@ -1,4 +1,4 @@
-import MockAdapter from 'axios-mock-adapter';
+import { createMockAdapter } from 'mocks/axiosMock';
import { store } from 'store';
import { render, waitFor } from 'test-utils';
@@ -7,7 +7,7 @@ import CourseAPI from 'api/course';
import ObjectDuplication from '../index';
const client = CourseAPI.duplication.client;
-const mock = new MockAdapter(client);
+const mock = createMockAdapter(client);
const responseData = {
sourceCourse: { id: 5 },
diff --git a/client/app/bundles/course/enrol-requests/components/buttons/PendingEnrolRequestsButtons.tsx b/client/app/bundles/course/enrol-requests/components/buttons/PendingEnrolRequestsButtons.tsx
index 08986c0c608..9154e9cd815 100644
--- a/client/app/bundles/course/enrol-requests/components/buttons/PendingEnrolRequestsButtons.tsx
+++ b/client/app/bundles/course/enrol-requests/components/buttons/PendingEnrolRequestsButtons.tsx
@@ -1,6 +1,5 @@
import { FC, memo, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import equal from 'fast-deep-equal';
import { EnrolRequestRowData } from 'types/course/enrolRequests';
@@ -8,6 +7,7 @@ import AcceptButton from 'lib/components/core/buttons/AcceptButton';
import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import { COURSE_USER_ROLES } from 'lib/constants/sharedConstants';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { approveEnrolRequest, rejectEnrolRequest } from '../../operations';
diff --git a/client/app/bundles/course/enrol-requests/pages/UserRequests/index.tsx b/client/app/bundles/course/enrol-requests/pages/UserRequests/index.tsx
index 1c353f6c2cc..8749789204e 100644
--- a/client/app/bundles/course/enrol-requests/pages/UserRequests/index.tsx
+++ b/client/app/bundles/course/enrol-requests/pages/UserRequests/index.tsx
@@ -1,11 +1,11 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import Note from 'lib/components/core/Note';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import UserManagementTabs from '../../../users/components/navigation/UserManagementTabs';
import PendingEnrolRequestsButtons from '../../components/buttons/PendingEnrolRequestsButtons';
diff --git a/client/app/bundles/course/experience-points/disbursement/components/forms/DisbursementForm.tsx b/client/app/bundles/course/experience-points/disbursement/components/forms/DisbursementForm.tsx
index ab8c0a5a807..05182f83629 100644
--- a/client/app/bundles/course/experience-points/disbursement/components/forms/DisbursementForm.tsx
+++ b/client/app/bundles/course/experience-points/disbursement/components/forms/DisbursementForm.tsx
@@ -6,7 +6,6 @@ import {
injectIntl,
WrappedComponentProps,
} from 'react-intl';
-import { toast } from 'react-toastify';
import { yupResolver } from '@hookform/resolvers/yup';
import { Autocomplete, Button, Grid, TextField } from '@mui/material';
import {
@@ -21,6 +20,7 @@ import Page from 'lib/components/core/layouts/Page';
import FormTextField from 'lib/components/form/fields/TextField';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import formTranslations from 'lib/translations/form';
import { createDisbursement } from '../../operations';
diff --git a/client/app/bundles/course/experience-points/disbursement/components/forms/FilterForm.tsx b/client/app/bundles/course/experience-points/disbursement/components/forms/FilterForm.tsx
index 06545cdd572..7644f3d4dfb 100644
--- a/client/app/bundles/course/experience-points/disbursement/components/forms/FilterForm.tsx
+++ b/client/app/bundles/course/experience-points/disbursement/components/forms/FilterForm.tsx
@@ -6,7 +6,6 @@ import {
injectIntl,
WrappedComponentProps,
} from 'react-intl';
-import { toast } from 'react-toastify';
import { yupResolver } from '@hookform/resolvers/yup';
import { LoadingButton } from '@mui/lab';
import { Grid } from '@mui/material';
@@ -19,6 +18,7 @@ import FormDateTimePickerField from 'lib/components/form/fields/DateTimePickerFi
import FormTextField from 'lib/components/form/fields/TextField';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import formTranslations from 'lib/translations/form';
import { fetchFilteredForumDisbursements } from '../../operations';
diff --git a/client/app/bundles/course/experience-points/disbursement/components/forms/ForumDisbursementForm.tsx b/client/app/bundles/course/experience-points/disbursement/components/forms/ForumDisbursementForm.tsx
index 09570b2999f..9bde44a1bfe 100644
--- a/client/app/bundles/course/experience-points/disbursement/components/forms/ForumDisbursementForm.tsx
+++ b/client/app/bundles/course/experience-points/disbursement/components/forms/ForumDisbursementForm.tsx
@@ -6,7 +6,6 @@ import {
injectIntl,
WrappedComponentProps,
} from 'react-intl';
-import { toast } from 'react-toastify';
import { yupResolver } from '@hookform/resolvers/yup';
import { Button, Grid } from '@mui/material';
import equal from 'fast-deep-equal';
@@ -23,6 +22,7 @@ import Page from 'lib/components/core/layouts/Page';
import FormTextField from 'lib/components/form/fields/TextField';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import formTranslations from 'lib/translations/form';
import { createForumDisbursement } from '../../operations';
diff --git a/client/app/bundles/course/experience-points/disbursement/components/tables/DisbursementTable.tsx b/client/app/bundles/course/experience-points/disbursement/components/tables/DisbursementTable.tsx
index d07b2cee48f..e63156decad 100644
--- a/client/app/bundles/course/experience-points/disbursement/components/tables/DisbursementTable.tsx
+++ b/client/app/bundles/course/experience-points/disbursement/components/tables/DisbursementTable.tsx
@@ -79,8 +79,8 @@ const DisbursementTable: FC = (props: Props) => {
}),
customBodyRenderLite: (dataIndex): JSX.Element => (
{filteredUsers[dataIndex].name}
diff --git a/client/app/bundles/course/experience-points/disbursement/pages/DisbursementIndex/index.tsx b/client/app/bundles/course/experience-points/disbursement/pages/DisbursementIndex/index.tsx
index 26e25c0a0fc..c62ccde03ad 100644
--- a/client/app/bundles/course/experience-points/disbursement/pages/DisbursementIndex/index.tsx
+++ b/client/app/bundles/course/experience-points/disbursement/pages/DisbursementIndex/index.tsx
@@ -5,7 +5,6 @@ import {
injectIntl,
WrappedComponentProps,
} from 'react-intl';
-import { toast } from 'react-toastify';
import { Group } from '@mui/icons-material';
import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
import { Tab, Tabs } from '@mui/material';
@@ -14,6 +13,7 @@ import palette from 'theme/palette';
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { fetchDisbursements, fetchForumDisbursements } from '../../operations';
import ForumDisbursement from '../ForumDisbursement';
diff --git a/client/app/bundles/course/experience-points/disbursement/pages/ForumDisbursement/index.tsx b/client/app/bundles/course/experience-points/disbursement/pages/ForumDisbursement/index.tsx
index 430ebcd4e94..94654237d30 100644
--- a/client/app/bundles/course/experience-points/disbursement/pages/ForumDisbursement/index.tsx
+++ b/client/app/bundles/course/experience-points/disbursement/pages/ForumDisbursement/index.tsx
@@ -1,6 +1,5 @@
import { FC, useState } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import CloseIcon from '@mui/icons-material/Close';
import {
Dialog,
@@ -17,6 +16,7 @@ import Link from 'lib/components/core/Link';
import { getCourseUserURL } from 'lib/helpers/url-builders';
import { getCourseId } from 'lib/helpers/url-helpers';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { formatLongDateTime } from 'lib/moment';
diff --git a/client/app/bundles/course/forum/components/buttons/ForumManagementButtons.tsx b/client/app/bundles/course/forum/components/buttons/ForumManagementButtons.tsx
index c8b0f8811e7..5cb0e7c4bd6 100644
--- a/client/app/bundles/course/forum/components/buttons/ForumManagementButtons.tsx
+++ b/client/app/bundles/course/forum/components/buttons/ForumManagementButtons.tsx
@@ -1,7 +1,6 @@
import { FC, useState } from 'react';
import { defineMessages } from 'react-intl';
import { useNavigate } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { MoreHoriz } from '@mui/icons-material';
import { ClickAwayListener, IconButton } from '@mui/material';
import { ForumEntity } from 'types/course/forums';
@@ -10,6 +9,7 @@ import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import EditButton from 'lib/components/core/buttons/EditButton';
import { getCourseId } from 'lib/helpers/url-helpers';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { deleteForum } from '../../operations';
diff --git a/client/app/bundles/course/forum/components/buttons/ForumTopicManagementButtons.tsx b/client/app/bundles/course/forum/components/buttons/ForumTopicManagementButtons.tsx
index 7d451e82bca..1fff548c88d 100644
--- a/client/app/bundles/course/forum/components/buttons/ForumTopicManagementButtons.tsx
+++ b/client/app/bundles/course/forum/components/buttons/ForumTopicManagementButtons.tsx
@@ -1,7 +1,6 @@
import { FC, useState } from 'react';
import { defineMessages } from 'react-intl';
import { useNavigate, useParams } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { MoreHoriz } from '@mui/icons-material';
import { ClickAwayListener, IconButton } from '@mui/material';
import { ForumTopicEntity } from 'types/course/forums';
@@ -10,6 +9,7 @@ import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import EditButton from 'lib/components/core/buttons/EditButton';
import { getCourseId } from 'lib/helpers/url-helpers';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { deleteForumTopic } from '../../operations';
diff --git a/client/app/bundles/course/forum/components/buttons/ForumTopicPostEditActionButtons.tsx b/client/app/bundles/course/forum/components/buttons/ForumTopicPostEditActionButtons.tsx
index 1e4fbe931fd..9e6872e3057 100644
--- a/client/app/bundles/course/forum/components/buttons/ForumTopicPostEditActionButtons.tsx
+++ b/client/app/bundles/course/forum/components/buttons/ForumTopicPostEditActionButtons.tsx
@@ -1,11 +1,11 @@
import { Dispatch, FC, SetStateAction, useState } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { Button } from '@mui/material';
import { ForumTopicPostEntity } from 'types/course/forums';
import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import formTranslations from 'lib/translations/form';
diff --git a/client/app/bundles/course/forum/components/buttons/ForumTopicPostManagementButtons.tsx b/client/app/bundles/course/forum/components/buttons/ForumTopicPostManagementButtons.tsx
index c9f8f5796c0..456b694792b 100644
--- a/client/app/bundles/course/forum/components/buttons/ForumTopicPostManagementButtons.tsx
+++ b/client/app/bundles/course/forum/components/buttons/ForumTopicPostManagementButtons.tsx
@@ -1,13 +1,13 @@
import { FC, useState } from 'react';
import { defineMessages } from 'react-intl';
import { useNavigate, useParams } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { ForumTopicPostEntity } from 'types/course/forums';
import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import EditButton from 'lib/components/core/buttons/EditButton';
import { getCourseId } from 'lib/helpers/url-helpers';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { deleteForumTopicPost } from '../../operations';
diff --git a/client/app/bundles/course/forum/components/buttons/HideButton.tsx b/client/app/bundles/course/forum/components/buttons/HideButton.tsx
index f91deadb682..fd6e03f6414 100644
--- a/client/app/bundles/course/forum/components/buttons/HideButton.tsx
+++ b/client/app/bundles/course/forum/components/buttons/HideButton.tsx
@@ -1,10 +1,10 @@
import { FC, useState } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { Button } from '@mui/material';
import { ForumTopicEntity } from 'types/course/forums';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { updateForumTopicHidden } from '../../operations';
diff --git a/client/app/bundles/course/forum/components/buttons/LockButton.tsx b/client/app/bundles/course/forum/components/buttons/LockButton.tsx
index 03a0be2f538..5f7b494b38b 100644
--- a/client/app/bundles/course/forum/components/buttons/LockButton.tsx
+++ b/client/app/bundles/course/forum/components/buttons/LockButton.tsx
@@ -1,10 +1,10 @@
import { FC, useState } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { Button } from '@mui/material';
import { ForumTopicEntity } from 'types/course/forums';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { updateForumTopicLocked } from '../../operations';
diff --git a/client/app/bundles/course/forum/components/buttons/MarkAnswerButton.tsx b/client/app/bundles/course/forum/components/buttons/MarkAnswerButton.tsx
index c08a1dfabaa..459e5aec179 100644
--- a/client/app/bundles/course/forum/components/buttons/MarkAnswerButton.tsx
+++ b/client/app/bundles/course/forum/components/buttons/MarkAnswerButton.tsx
@@ -1,5 +1,4 @@
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { CheckCircle, CheckCircleOutline } from '@mui/icons-material';
import { Chip, IconButton, IconButtonProps } from '@mui/material';
import {
@@ -9,6 +8,7 @@ import {
} from 'types/course/forums';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { toggleForumTopicPostAnswer } from '../../operations';
diff --git a/client/app/bundles/course/forum/components/buttons/SubscribeButton.tsx b/client/app/bundles/course/forum/components/buttons/SubscribeButton.tsx
index 623228e2428..b8d4612449c 100644
--- a/client/app/bundles/course/forum/components/buttons/SubscribeButton.tsx
+++ b/client/app/bundles/course/forum/components/buttons/SubscribeButton.tsx
@@ -1,12 +1,12 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages } from 'react-intl';
import { useSearchParams } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { Button, Switch, Tooltip } from '@mui/material';
import { EmailSubscriptionSetting } from 'types/course/forums';
import Link from 'lib/components/core/Link';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import {
@@ -137,8 +137,8 @@ const SubscribeButton: FC = ({
subscribedTooltip = t(translations.userSettingSubscribed, {
manageMySubscriptionLink: (
{t(commonTranslations.manageMySubscriptions)}
diff --git a/client/app/bundles/course/forum/components/buttons/VotePostButton.tsx b/client/app/bundles/course/forum/components/buttons/VotePostButton.tsx
index 9a3760cd3ad..4cd8dd4676e 100644
--- a/client/app/bundles/course/forum/components/buttons/VotePostButton.tsx
+++ b/client/app/bundles/course/forum/components/buttons/VotePostButton.tsx
@@ -1,6 +1,5 @@
import { FC } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import {
ThumbDownAlt,
ThumbDownOffAlt,
@@ -11,6 +10,7 @@ import { IconButton, IconButtonProps } from '@mui/material';
import { ForumTopicPostEntity } from 'types/course/forums';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { voteTopicPost } from '../../operations';
diff --git a/client/app/bundles/course/forum/components/cards/ReplyCard.tsx b/client/app/bundles/course/forum/components/cards/ReplyCard.tsx
index d4fe9fd93de..9a3d9566fc7 100644
--- a/client/app/bundles/course/forum/components/cards/ReplyCard.tsx
+++ b/client/app/bundles/course/forum/components/cards/ReplyCard.tsx
@@ -2,13 +2,13 @@ import { Dispatch, FC, SetStateAction, useEffect, useState } from 'react';
import { defineMessages } from 'react-intl';
import { useParams } from 'react-router-dom';
import { Element, scroller } from 'react-scroll';
-import { toast } from 'react-toastify';
import { Button, Card, CardActions, CardContent } from '@mui/material';
import { ForumTopicPostFormData } from 'types/course/forums';
import Checkbox from 'lib/components/core/buttons/Checkbox';
import CKEditorRichText from 'lib/components/core/fields/CKEditorRichText';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import formTranslations from 'lib/translations/form';
diff --git a/client/app/bundles/course/forum/pages/ForumEdit/index.tsx b/client/app/bundles/course/forum/pages/ForumEdit/index.tsx
index 5f131253f7d..390d63e8f2f 100644
--- a/client/app/bundles/course/forum/pages/ForumEdit/index.tsx
+++ b/client/app/bundles/course/forum/pages/ForumEdit/index.tsx
@@ -1,11 +1,11 @@
import { FC } from 'react';
import { defineMessages } from 'react-intl';
import { useNavigate } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { ForumEntity, ForumFormData } from 'types/course/forums';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import ForumForm from '../../components/forms/ForumForm';
diff --git a/client/app/bundles/course/forum/pages/ForumNew/index.tsx b/client/app/bundles/course/forum/pages/ForumNew/index.tsx
index 6aeef72a962..33ee2623248 100644
--- a/client/app/bundles/course/forum/pages/ForumNew/index.tsx
+++ b/client/app/bundles/course/forum/pages/ForumNew/index.tsx
@@ -1,9 +1,9 @@
import { FC } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import ForumForm from '../../components/forms/ForumForm';
diff --git a/client/app/bundles/course/forum/pages/ForumShow/index.tsx b/client/app/bundles/course/forum/pages/ForumShow/index.tsx
index 82569fc43f8..5753e1bf01f 100644
--- a/client/app/bundles/course/forum/pages/ForumShow/index.tsx
+++ b/client/app/bundles/course/forum/pages/ForumShow/index.tsx
@@ -1,12 +1,12 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages } from 'react-intl';
import { useParams } from 'react-router-dom';
-import { toast } from 'react-toastify';
import AddButton from 'lib/components/core/buttons/AddButton';
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import ForumManagementButtons from '../../components/buttons/ForumManagementButtons';
diff --git a/client/app/bundles/course/forum/pages/ForumTopicEdit/index.tsx b/client/app/bundles/course/forum/pages/ForumTopicEdit/index.tsx
index 00167924fb7..0ed0f771822 100644
--- a/client/app/bundles/course/forum/pages/ForumTopicEdit/index.tsx
+++ b/client/app/bundles/course/forum/pages/ForumTopicEdit/index.tsx
@@ -1,11 +1,11 @@
import { FC } from 'react';
import { defineMessages } from 'react-intl';
import { useNavigate } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { ForumTopicEntity, ForumTopicFormData } from 'types/course/forums';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import ForumTopicForm from '../../components/forms/ForumTopicForm';
diff --git a/client/app/bundles/course/forum/pages/ForumTopicNew/index.tsx b/client/app/bundles/course/forum/pages/ForumTopicNew/index.tsx
index 42ff9c7b76b..13d90081c29 100644
--- a/client/app/bundles/course/forum/pages/ForumTopicNew/index.tsx
+++ b/client/app/bundles/course/forum/pages/ForumTopicNew/index.tsx
@@ -1,11 +1,11 @@
import { FC } from 'react';
import { defineMessages } from 'react-intl';
import { useNavigate, useParams } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { ForumTopicFormData, TopicType } from 'types/course/forums';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import ForumTopicForm from '../../components/forms/ForumTopicForm';
diff --git a/client/app/bundles/course/forum/pages/ForumTopicPostNew/index.tsx b/client/app/bundles/course/forum/pages/ForumTopicPostNew/index.tsx
index 9af02e88729..6db8b1e8cd9 100644
--- a/client/app/bundles/course/forum/pages/ForumTopicPostNew/index.tsx
+++ b/client/app/bundles/course/forum/pages/ForumTopicPostNew/index.tsx
@@ -2,12 +2,12 @@ import { FC, useState } from 'react';
import { defineMessages } from 'react-intl';
import { useParams } from 'react-router-dom';
import { scroller } from 'react-scroll';
-import { toast } from 'react-toastify';
import { Add } from '@mui/icons-material';
import { Fab, Tooltip } from '@mui/material';
import { ForumTopicEntity, ForumTopicPostFormData } from 'types/course/forums';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import ForumTopicPostForm from '../../components/forms/ForumTopicPostForm';
diff --git a/client/app/bundles/course/forum/pages/ForumTopicShow/index.tsx b/client/app/bundles/course/forum/pages/ForumTopicShow/index.tsx
index 38cb08157a4..987da3108e9 100644
--- a/client/app/bundles/course/forum/pages/ForumTopicShow/index.tsx
+++ b/client/app/bundles/course/forum/pages/ForumTopicShow/index.tsx
@@ -1,7 +1,6 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { defineMessages } from 'react-intl';
import { useParams } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { Box } from '@mui/material';
import { TopicType } from 'types/course/forums';
@@ -9,6 +8,7 @@ import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import Note from 'lib/components/core/Note';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import ForumTopicManagementButtons from '../../components/buttons/ForumTopicManagementButtons';
diff --git a/client/app/bundles/course/forum/pages/ForumsIndex/index.tsx b/client/app/bundles/course/forum/pages/ForumsIndex/index.tsx
index 1f2072c4640..343640c8528 100644
--- a/client/app/bundles/course/forum/pages/ForumsIndex/index.tsx
+++ b/client/app/bundles/course/forum/pages/ForumsIndex/index.tsx
@@ -1,11 +1,11 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import AddButton from 'lib/components/core/buttons/AddButton';
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import MarkAllAsReadButton from '../../components/buttons/MarkAllAsReadButton';
diff --git a/client/app/bundles/course/group/pages/GroupIndex/index.jsx b/client/app/bundles/course/group/pages/GroupIndex/index.jsx
index 05605d4e146..a34c16ed6c6 100644
--- a/client/app/bundles/course/group/pages/GroupIndex/index.jsx
+++ b/client/app/bundles/course/group/pages/GroupIndex/index.jsx
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { Outlet, useNavigate, useParams } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { Tab, Tabs } from '@mui/material';
import PropTypes from 'prop-types';
@@ -10,6 +9,7 @@ import Page from 'lib/components/core/layouts/Page';
import Link from 'lib/components/core/Link';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import Note from 'lib/components/core/Note';
+import toast from 'lib/hooks/toast';
import GroupNew from '../GroupNew';
diff --git a/client/app/bundles/course/leaderboard/components/tables/LeaderboardTable.tsx b/client/app/bundles/course/leaderboard/components/tables/LeaderboardTable.tsx
index 1c49297cd72..f8a4e309712 100644
--- a/client/app/bundles/course/leaderboard/components/tables/LeaderboardTable.tsx
+++ b/client/app/bundles/course/leaderboard/components/tables/LeaderboardTable.tsx
@@ -139,12 +139,9 @@ const LeaderboardTable: FC = (props: Props) => {
= (props: Props) => {
alt={achievement.badge.name}
className="achievement"
component={Link}
- href={getAchievementURL(getCourseId(), achievement.id)}
id={`achievement_${achievement.id}`}
src={achievement.badge.url}
+ to={getAchievementURL(getCourseId(), achievement.id)}
underline="none"
/>
@@ -296,8 +293,8 @@ const LeaderboardTable: FC = (props: Props) => {
diff --git a/client/app/bundles/course/leaderboard/pages/LeaderboardIndex/index.tsx b/client/app/bundles/course/leaderboard/pages/LeaderboardIndex/index.tsx
index 9935f2650f7..68b5fdddcb9 100644
--- a/client/app/bundles/course/leaderboard/pages/LeaderboardIndex/index.tsx
+++ b/client/app/bundles/course/leaderboard/pages/LeaderboardIndex/index.tsx
@@ -5,7 +5,6 @@ import {
injectIntl,
WrappedComponentProps,
} from 'react-intl';
-import { toast } from 'react-toastify';
import { AutoFixHigh, EmojiEvents, Group, Person } from '@mui/icons-material';
import { Grid, Tab, Tabs } from '@mui/material';
import { useTheme } from '@mui/material/styles';
@@ -15,6 +14,7 @@ import palette from 'theme/palette';
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import LeaderboardTable from '../../components/tables/LeaderboardTable';
import fetchLeaderboard from '../../operations';
diff --git a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/index.test.jsx b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/index.test.jsx
index 1d35b8968dc..7ca44bee468 100644
--- a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/index.test.jsx
+++ b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/index.test.jsx
@@ -1,4 +1,4 @@
-import MockAdapter from 'axios-mock-adapter';
+import { createMockAdapter } from 'mocks/axiosMock';
import { render, waitFor } from 'test-utils';
import CourseAPI from 'api/course';
@@ -6,7 +6,7 @@ import CourseAPI from 'api/course';
import LessonPlanLayout from '..';
const client = CourseAPI.lessonPlan.client;
-const mock = new MockAdapter(client);
+const mock = createMockAdapter(client);
beforeEach(() => {
mock.reset();
diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/ItemRow.test.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/ItemRow.test.jsx
index e72d108ae32..a8cc1ddd790 100644
--- a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/ItemRow.test.jsx
+++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/ItemRow.test.jsx
@@ -1,11 +1,11 @@
-import MockAdapter from 'axios-mock-adapter';
+import { createMockAdapter } from 'mocks/axiosMock';
import { fireEvent, render, waitFor } from 'test-utils';
import CourseAPI from 'api/course';
import ItemRow from '../ItemRow';
-const mock = new MockAdapter(CourseAPI.lessonPlan.client);
+const mock = createMockAdapter(CourseAPI.lessonPlan.client);
const startAt = '01-01-2017';
const endAt = '02-02-2017';
diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/MilestoneRow.test.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/MilestoneRow.test.jsx
index c250ebf04a1..6e0aee4ee9f 100644
--- a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/MilestoneRow.test.jsx
+++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/MilestoneRow.test.jsx
@@ -1,11 +1,11 @@
-import MockAdapter from 'axios-mock-adapter';
+import { createMockAdapter } from 'mocks/axiosMock';
import { fireEvent, render, waitFor } from 'test-utils';
import CourseAPI from 'api/course';
import MilestoneRow from '../MilestoneRow';
-const mock = new MockAdapter(CourseAPI.lessonPlan.client);
+const mock = createMockAdapter(CourseAPI.lessonPlan.client);
beforeEach(() => {
mock.reset();
diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/Details/index.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/Details/index.jsx
index 869513223e8..466daabcfb1 100644
--- a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/Details/index.jsx
+++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/Details/index.jsx
@@ -1,5 +1,5 @@
import { PureComponent } from 'react';
-import { CardContent, CardHeader } from '@mui/material';
+import { CardContent, CardHeader, Typography } from '@mui/material';
import PropTypes from 'prop-types';
import Link from 'lib/components/core/Link';
@@ -12,7 +12,14 @@ class Details extends PureComponent {
if (!description) {
return null;
}
- return ;
+ return (
+
+
+
+ );
}
renderTitle() {
diff --git a/client/app/bundles/course/material/folders/components/buttons/DownloadFolderButton.tsx b/client/app/bundles/course/material/folders/components/buttons/DownloadFolderButton.tsx
index 63dc3e795ad..939a903e302 100644
--- a/client/app/bundles/course/material/folders/components/buttons/DownloadFolderButton.tsx
+++ b/client/app/bundles/course/material/folders/components/buttons/DownloadFolderButton.tsx
@@ -1,6 +1,5 @@
import { FC, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import {
Download as DownloadIcon,
Downloading as DownloadingIcon,
@@ -9,6 +8,7 @@ import { IconButton, Tooltip } from '@mui/material';
import CustomTooltip from 'lib/components/core/CustomTooltip';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { downloadFolder } from '../../operations';
diff --git a/client/app/bundles/course/material/folders/components/buttons/WorkbinTableButtons.tsx b/client/app/bundles/course/material/folders/components/buttons/WorkbinTableButtons.tsx
index 287f59045b6..b8ff6a88b13 100644
--- a/client/app/bundles/course/material/folders/components/buttons/WorkbinTableButtons.tsx
+++ b/client/app/bundles/course/material/folders/components/buttons/WorkbinTableButtons.tsx
@@ -1,11 +1,11 @@
import { FC, useState } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { Stack } from '@mui/material';
import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import EditButton from 'lib/components/core/buttons/EditButton';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { deleteFolder, deleteMaterial } from '../../operations';
diff --git a/client/app/bundles/course/material/folders/components/misc/MaterialEdit.tsx b/client/app/bundles/course/material/folders/components/misc/MaterialEdit.tsx
index fa550452d08..46463a0ffc6 100644
--- a/client/app/bundles/course/material/folders/components/misc/MaterialEdit.tsx
+++ b/client/app/bundles/course/material/folders/components/misc/MaterialEdit.tsx
@@ -1,10 +1,10 @@
import { FC } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { MaterialFormData } from 'types/course/material/folders';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { updateMaterial } from '../../operations';
diff --git a/client/app/bundles/course/material/folders/components/misc/MaterialUpload.tsx b/client/app/bundles/course/material/folders/components/misc/MaterialUpload.tsx
index 9c840f3e210..d78e80664fa 100644
--- a/client/app/bundles/course/material/folders/components/misc/MaterialUpload.tsx
+++ b/client/app/bundles/course/material/folders/components/misc/MaterialUpload.tsx
@@ -1,10 +1,10 @@
import { FC, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Dialog, DialogContent, DialogTitle } from '@mui/material';
import ConfirmationDialog from 'lib/components/core/dialogs/ConfirmationDialog';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { uploadMaterials } from '../../operations';
import MaterialUploadForm from '../forms/MaterialUploadForm';
diff --git a/client/app/bundles/course/material/folders/components/misc/MultipleFileInput.tsx b/client/app/bundles/course/material/folders/components/misc/MultipleFileInput.tsx
index c317f4f1df8..10c0b3bbcd4 100644
--- a/client/app/bundles/course/material/folders/components/misc/MultipleFileInput.tsx
+++ b/client/app/bundles/course/material/folders/components/misc/MultipleFileInput.tsx
@@ -1,10 +1,11 @@
import { Dispatch, FC, SetStateAction, useState } from 'react';
import Dropzone from 'react-dropzone';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { FileUpload as FileUploadIcon } from '@mui/icons-material';
import { Chip } from '@mui/material';
+import toast from 'lib/hooks/toast';
+
interface Props extends WrappedComponentProps {
uploadedFiles: File[];
setUploadedFiles: Dispatch>;
diff --git a/client/app/bundles/course/material/folders/pages/FolderEdit/index.tsx b/client/app/bundles/course/material/folders/pages/FolderEdit/index.tsx
index 23ec1a0a8f2..aec4e6aefa4 100644
--- a/client/app/bundles/course/material/folders/pages/FolderEdit/index.tsx
+++ b/client/app/bundles/course/material/folders/pages/FolderEdit/index.tsx
@@ -1,10 +1,10 @@
import { FC } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { FolderFormData } from 'types/course/material/folders';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import FolderForm from '../../components/forms/FolderForm';
diff --git a/client/app/bundles/course/material/folders/pages/FolderNew/index.tsx b/client/app/bundles/course/material/folders/pages/FolderNew/index.tsx
index 6c688cf6421..96e6c7e044d 100644
--- a/client/app/bundles/course/material/folders/pages/FolderNew/index.tsx
+++ b/client/app/bundles/course/material/folders/pages/FolderNew/index.tsx
@@ -1,10 +1,10 @@
import { FC } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { FolderFormData } from 'types/course/material/folders';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import FolderForm from '../../components/forms/FolderForm';
diff --git a/client/app/bundles/course/material/materialLoader.ts b/client/app/bundles/course/material/materialLoader.ts
new file mode 100644
index 00000000000..f6a8a2cdf61
--- /dev/null
+++ b/client/app/bundles/course/material/materialLoader.ts
@@ -0,0 +1,40 @@
+import { defineMessages } from 'react-intl';
+import { LoaderFunction, redirect } from 'react-router-dom';
+import { getIdFromUnknown } from 'utilities';
+
+import CourseAPI from 'api/course';
+import toast from 'lib/hooks/toast';
+import { Translated } from 'lib/hooks/useTranslation';
+
+const translations = defineMessages({
+ errorAccessingMaterial: {
+ id: 'material.attemptLoader.errorAccessingMaterial',
+ defaultMessage:
+ 'An error occurred while accessing this material. Try again later.',
+ },
+});
+
+const materialLoader: Translated =
+ (t) =>
+ async ({ params }) => {
+ const { courseId } = params;
+
+ try {
+ const folderId = getIdFromUnknown(params?.folderId);
+ const materialId = getIdFromUnknown(params?.materialId);
+ if (!folderId || !materialId) return redirect('/');
+
+ const { data } = await CourseAPI.materials.fetch(folderId, materialId);
+
+ window.location.href = data.redirectUrl;
+ return redirect(`/courses/${courseId}/materials/folders/${folderId}`);
+ } catch {
+ toast.error(t(translations.errorAccessingMaterial));
+
+ if (!courseId) return redirect('/');
+
+ return redirect(`/courses/${courseId}`);
+ }
+ };
+
+export default materialLoader;
diff --git a/client/app/bundles/course/reference-timelines/components/CreateRenameTimelinePrompt.tsx b/client/app/bundles/course/reference-timelines/components/CreateRenameTimelinePrompt.tsx
index 1de6cef7981..29633a34fe8 100644
--- a/client/app/bundles/course/reference-timelines/components/CreateRenameTimelinePrompt.tsx
+++ b/client/app/bundles/course/reference-timelines/components/CreateRenameTimelinePrompt.tsx
@@ -1,11 +1,11 @@
import { useState } from 'react';
-import { toast } from 'react-toastify';
import { Alert } from '@mui/material';
import { TimelineData } from 'types/course/referenceTimelines';
import Prompt from 'lib/components/core/dialogs/Prompt';
import TextField from 'lib/components/core/fields/TextField';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import formTranslations from 'lib/translations/form';
diff --git a/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopup.tsx b/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopup.tsx
index 899b5653715..ceb9814270b 100644
--- a/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopup.tsx
+++ b/client/app/bundles/course/reference-timelines/components/TimePopup/TimePopup.tsx
@@ -1,4 +1,3 @@
-import { toast } from 'react-toastify';
import { Typography } from '@mui/material';
import moment from 'moment';
import {
@@ -7,6 +6,7 @@ import {
} from 'types/course/referenceTimelines';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { useSetLastSaved } from '../../contexts';
diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverviewItem.tsx b/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverviewItem.tsx
index 90d83787efc..974d74e2b46 100644
--- a/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverviewItem.tsx
+++ b/client/app/bundles/course/reference-timelines/components/TimelinesOverview/TimelinesOverviewItem.tsx
@@ -1,11 +1,11 @@
import { useState } from 'react';
-import { toast } from 'react-toastify';
import { MoreVert } from '@mui/icons-material';
import { Divider, IconButton, Menu, MenuItem } from '@mui/material';
import { TimelineData } from 'types/course/referenceTimelines';
import Checkbox from 'lib/components/core/buttons/Checkbox';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { useLastSaved, useSetLastSaved } from '../../contexts';
diff --git a/client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignedTimeline.tsx b/client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignedTimeline.tsx
index 6557489fa69..0f28d27b393 100644
--- a/client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignedTimeline.tsx
+++ b/client/app/bundles/course/reference-timelines/components/TimelinesStack/AssignedTimeline.tsx
@@ -1,5 +1,4 @@
import { useState } from 'react';
-import { toast } from 'react-toastify';
import {
ItemWithTimeData,
TimeData,
@@ -7,6 +6,7 @@ import {
} from 'types/course/referenceTimelines';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { useLastSaved, useSetLastSaved } from '../../contexts';
diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/course/StudentPerformanceTable.jsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/course/StudentPerformanceTable.jsx
index 3dc0abfd1ea..8b8577316c6 100644
--- a/client/app/bundles/course/statistics/pages/StatisticsIndex/course/StudentPerformanceTable.jsx
+++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/course/StudentPerformanceTable.jsx
@@ -245,7 +245,7 @@ const StudentPerformanceTable = ({
customBodyRenderLite: (dataIndex) => {
const student = displayedStudents[dataIndex];
return (
-
+
{student.name}
);
@@ -303,7 +303,7 @@ const StudentPerformanceTable = ({
<>
{groupManagers.map((m, index) => (
-
+
{m.name}
{index < groupManagers.length - 1 && ', '}
@@ -347,8 +347,8 @@ const StudentPerformanceTable = ({
return (
{student.experiencePoints}
@@ -428,8 +428,8 @@ const StudentPerformanceTable = ({
return (
{student.videoSubmissionCount}
diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/students/StudentsStatisticsTable.jsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/students/StudentsStatisticsTable.jsx
index 191e9a269a4..9e243650864 100644
--- a/client/app/bundles/course/statistics/pages/StatisticsIndex/students/StudentsStatisticsTable.jsx
+++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/students/StudentsStatisticsTable.jsx
@@ -168,7 +168,7 @@ const StudentsStatisticsTable = ({ metadata, students }) => {
<>
{groupManagers.map((m, index) => (
-
+
{m.name}
{index < groupManagers.length - 1 && ', '}
@@ -201,8 +201,8 @@ const StudentsStatisticsTable = ({ metadata, students }) => {
return (
{student.experiencePoints}
@@ -224,8 +224,8 @@ const StudentsStatisticsTable = ({ metadata, students }) => {
return (
{student.videoSubmissionCount}
diff --git a/client/app/bundles/course/survey/__test__/index.test.tsx b/client/app/bundles/course/survey/__test__/index.test.tsx
index fc94da11505..3a66c1049de 100644
--- a/client/app/bundles/course/survey/__test__/index.test.tsx
+++ b/client/app/bundles/course/survey/__test__/index.test.tsx
@@ -1,4 +1,4 @@
-import MockAdapter from 'axios-mock-adapter';
+import { createMockAdapter } from 'mocks/axiosMock';
import { render, waitFor } from 'test-utils';
import CourseAPI from 'api/course';
@@ -18,7 +18,7 @@ const SURVEYS = [
},
];
-const mock = new MockAdapter(CourseAPI.survey.surveys.client);
+const mock = createMockAdapter(CourseAPI.survey.surveys.client);
beforeEach(() => {
mock.reset();
diff --git a/client/app/bundles/course/survey/actions/__test__/responses.test.ts b/client/app/bundles/course/survey/actions/__test__/responses.test.ts
index db202903d74..c39b7427020 100644
--- a/client/app/bundles/course/survey/actions/__test__/responses.test.ts
+++ b/client/app/bundles/course/survey/actions/__test__/responses.test.ts
@@ -1,4 +1,4 @@
-import MockAdapter from 'axios-mock-adapter';
+import { createMockAdapter } from 'mocks/axiosMock';
import { dispatch } from 'store';
import CourseAPI from 'api/course';
@@ -7,7 +7,7 @@ import history from 'lib/history';
import { createResponse } from '../responses';
const client = CourseAPI.survey.responses.client;
-const mock = new MockAdapter(client);
+const mock = createMockAdapter(client);
const mockNavigate = jest.fn();
beforeEach(() => {
diff --git a/client/app/bundles/course/survey/components/OptionsListItem.jsx b/client/app/bundles/course/survey/components/OptionsListItem.jsx
index 6c632fa121e..6161c8dbdc1 100644
--- a/client/app/bundles/course/survey/components/OptionsListItem.jsx
+++ b/client/app/bundles/course/survey/components/OptionsListItem.jsx
@@ -1,5 +1,5 @@
import { PureComponent } from 'react';
-import { Card, CardContent } from '@mui/material';
+import { Card, CardContent, Typography } from '@mui/material';
import PropTypes from 'prop-types';
import Thumbnail from 'lib/components/core/Thumbnail';
@@ -65,7 +65,10 @@ class OptionsListItem extends PureComponent {
{optionText ? (
-
+
) : null}
{widget}
@@ -88,9 +91,10 @@ class OptionsListItem extends PureComponent {
) : (
[]
)}
-
);
diff --git a/client/app/bundles/course/survey/containers/RespondButton/__test__/index.test.jsx b/client/app/bundles/course/survey/containers/RespondButton/__test__/index.test.jsx
index 78a2be26517..3ea1c1d1eb1 100644
--- a/client/app/bundles/course/survey/containers/RespondButton/__test__/index.test.jsx
+++ b/client/app/bundles/course/survey/containers/RespondButton/__test__/index.test.jsx
@@ -1,11 +1,11 @@
-import MockAdapter from 'axios-mock-adapter';
+import { createMockAdapter } from 'mocks/axiosMock';
import { fireEvent, render, waitFor } from 'test-utils';
import CourseAPI from 'api/course';
import RespondButton from '../index';
-const mock = new MockAdapter(CourseAPI.survey.responses.client);
+const mock = createMockAdapter(CourseAPI.survey.responses.client);
beforeEach(() => {
mock.reset();
diff --git a/client/app/bundles/course/survey/pages/ResponseEdit/__test__/index.test.jsx b/client/app/bundles/course/survey/pages/ResponseEdit/__test__/index.test.jsx
index d6afa29c668..82ee7f1bb03 100644
--- a/client/app/bundles/course/survey/pages/ResponseEdit/__test__/index.test.jsx
+++ b/client/app/bundles/course/survey/pages/ResponseEdit/__test__/index.test.jsx
@@ -1,7 +1,7 @@
import { connect } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
-import MockAdapter from 'axios-mock-adapter';
import { mount } from 'enzyme';
+import { createMockAdapter } from 'mocks/axiosMock';
import CourseAPI from 'api/course';
import storeCreator from 'course/survey/store';
@@ -9,7 +9,7 @@ import storeCreator from 'course/survey/store';
import ResponseEdit from '../index';
const client = CourseAPI.survey.responses.client;
-const mock = new MockAdapter(client);
+const mock = createMockAdapter(client);
const responseData = {
response: {
diff --git a/client/app/bundles/course/survey/pages/ResponseIndex/__test__/index.test.jsx b/client/app/bundles/course/survey/pages/ResponseIndex/__test__/index.test.jsx
index 8c696f81fc6..85c86d38a05 100644
--- a/client/app/bundles/course/survey/pages/ResponseIndex/__test__/index.test.jsx
+++ b/client/app/bundles/course/survey/pages/ResponseIndex/__test__/index.test.jsx
@@ -1,11 +1,11 @@
-import MockAdapter from 'axios-mock-adapter';
+import { createMockAdapter } from 'mocks/axiosMock';
import { render, waitFor, within } from 'test-utils';
import CourseAPI from 'api/course';
import ResponseIndex from '../index';
-const mock = new MockAdapter(CourseAPI.survey.responses.client);
+const mock = createMockAdapter(CourseAPI.survey.responses.client);
const responsesData = {
responses: [
diff --git a/client/app/bundles/course/survey/pages/SurveyResults/__test__/index.test.jsx b/client/app/bundles/course/survey/pages/SurveyResults/__test__/index.test.jsx
index 8aea327ddfc..f6eb700e30a 100644
--- a/client/app/bundles/course/survey/pages/SurveyResults/__test__/index.test.jsx
+++ b/client/app/bundles/course/survey/pages/SurveyResults/__test__/index.test.jsx
@@ -1,4 +1,4 @@
-import MockAdapter from 'axios-mock-adapter';
+import { createMockAdapter } from 'mocks/axiosMock';
import { store } from 'store';
import { fireEvent, render, waitFor } from 'test-utils';
@@ -7,7 +7,7 @@ import CourseAPI from 'api/course';
import { SurveyResults } from '../index';
const client = CourseAPI.survey.surveys.client;
-const mock = new MockAdapter(client);
+const mock = createMockAdapter(client);
const data = {
sections: [
diff --git a/client/app/bundles/course/survey/pages/SurveyShow/SurveyDetails.jsx b/client/app/bundles/course/survey/pages/SurveyShow/SurveyDetails.jsx
index f52c281fc65..0716fde52c4 100644
--- a/client/app/bundles/course/survey/pages/SurveyShow/SurveyDetails.jsx
+++ b/client/app/bundles/course/survey/pages/SurveyShow/SurveyDetails.jsx
@@ -9,6 +9,7 @@ import {
TableBody,
TableCell,
TableRow,
+ Typography,
} from '@mui/material';
import PropTypes from 'prop-types';
@@ -63,12 +64,13 @@ const SurveyDetails = (props) => {
return (
-
+
-
-
+
);
diff --git a/client/app/bundles/course/user-email-subscriptions/UserEmailSubscriptions.tsx b/client/app/bundles/course/user-email-subscriptions/UserEmailSubscriptions.tsx
index dda9f1daa97..d8e50998054 100644
--- a/client/app/bundles/course/user-email-subscriptions/UserEmailSubscriptions.tsx
+++ b/client/app/bundles/course/user-email-subscriptions/UserEmailSubscriptions.tsx
@@ -29,7 +29,7 @@ const UserEmailSubscriptions = (): JSX.Element => {
dispatch(
fetchUserEmailSubscriptions(
- queryParams,
+ Object.fromEntries(queryParams.entries()),
() => setStatus('success'),
() => setStatus('error'),
),
diff --git a/client/app/bundles/course/user-invitations/components/buttons/PendingInvitationsButtons.tsx b/client/app/bundles/course/user-invitations/components/buttons/PendingInvitationsButtons.tsx
index df2f63759ea..b5d04b33b0a 100644
--- a/client/app/bundles/course/user-invitations/components/buttons/PendingInvitationsButtons.tsx
+++ b/client/app/bundles/course/user-invitations/components/buttons/PendingInvitationsButtons.tsx
@@ -1,12 +1,12 @@
import { FC, memo, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import equal from 'fast-deep-equal';
import { InvitationRowData } from 'types/course/userInvitations';
import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import EmailButton from 'lib/components/core/buttons/EmailButton';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { deleteInvitation, resendInvitationEmail } from '../../operations';
diff --git a/client/app/bundles/course/user-invitations/components/buttons/ResendAllInvitationsButton.tsx b/client/app/bundles/course/user-invitations/components/buttons/ResendAllInvitationsButton.tsx
index 05a8f0279e5..d9507d3f833 100644
--- a/client/app/bundles/course/user-invitations/components/buttons/ResendAllInvitationsButton.tsx
+++ b/client/app/bundles/course/user-invitations/components/buttons/ResendAllInvitationsButton.tsx
@@ -1,9 +1,9 @@
import { FC, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { LoadingButton } from '@mui/lab';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { resendAllInvitations } from '../../operations';
diff --git a/client/app/bundles/course/user-invitations/components/forms/IndividualInviteForm.tsx b/client/app/bundles/course/user-invitations/components/forms/IndividualInviteForm.tsx
index f0cf37bcdd1..15a0f44e09e 100644
--- a/client/app/bundles/course/user-invitations/components/forms/IndividualInviteForm.tsx
+++ b/client/app/bundles/course/user-invitations/components/forms/IndividualInviteForm.tsx
@@ -1,7 +1,6 @@
import { FC, useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { yupResolver } from '@hookform/resolvers/yup';
import {
IndividualInvites,
@@ -12,6 +11,7 @@ import * as yup from 'yup';
import ErrorText from 'lib/components/core/ErrorText';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import formTranslations from 'lib/translations/form';
import messagesTranslations from 'lib/translations/messages';
diff --git a/client/app/bundles/course/user-invitations/pages/InvitationsIndex/index.tsx b/client/app/bundles/course/user-invitations/pages/InvitationsIndex/index.tsx
index cd35ada3db0..4653392bc7d 100644
--- a/client/app/bundles/course/user-invitations/pages/InvitationsIndex/index.tsx
+++ b/client/app/bundles/course/user-invitations/pages/InvitationsIndex/index.tsx
@@ -1,11 +1,11 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Box, Typography } from '@mui/material';
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import UserManagementTabs from '../../../users/components/navigation/UserManagementTabs';
import PendingInvitationsButtons from '../../components/buttons/PendingInvitationsButtons';
diff --git a/client/app/bundles/course/user-invitations/pages/InviteUsers/index.tsx b/client/app/bundles/course/user-invitations/pages/InviteUsers/index.tsx
index 697d87607bf..c302246e1ac 100644
--- a/client/app/bundles/course/user-invitations/pages/InviteUsers/index.tsx
+++ b/client/app/bundles/course/user-invitations/pages/InviteUsers/index.tsx
@@ -1,12 +1,12 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Grid, Typography } from '@mui/material';
import { InvitationResult } from 'types/course/userInvitations';
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import UserManagementTabs from '../../../users/components/navigation/UserManagementTabs';
import RegistrationCodeButton from '../../components/buttons/RegistrationCodeButton';
diff --git a/client/app/bundles/course/user-invitations/pages/InviteUsersFileUpload/index.tsx b/client/app/bundles/course/user-invitations/pages/InviteUsersFileUpload/index.tsx
index ee3a37b0fe6..42a4c915cc3 100644
--- a/client/app/bundles/course/user-invitations/pages/InviteUsersFileUpload/index.tsx
+++ b/client/app/bundles/course/user-invitations/pages/InviteUsersFileUpload/index.tsx
@@ -1,6 +1,5 @@
import { FC, ReactNode, useEffect, useState } from 'react';
import { defineMessages, FormattedMessage } from 'react-intl';
-import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/Download';
import { Typography } from '@mui/material';
import { InvitationResult } from 'types/course/userInvitations';
@@ -8,6 +7,7 @@ import { InvitationResult } from 'types/course/userInvitations';
import CourseAPI from 'api/course';
import Link from 'lib/components/core/Link';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import FileUploadForm from '../../components/forms/InviteUsersFileUploadForm';
diff --git a/client/app/bundles/course/user-invitations/pages/InviteUsersRegistrationCode/index.tsx b/client/app/bundles/course/user-invitations/pages/InviteUsersRegistrationCode/index.tsx
index a9a0d97c475..d89d21a63ef 100644
--- a/client/app/bundles/course/user-invitations/pages/InviteUsersRegistrationCode/index.tsx
+++ b/client/app/bundles/course/user-invitations/pages/InviteUsersRegistrationCode/index.tsx
@@ -1,6 +1,5 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { LoadingButton } from '@mui/lab';
import {
Alert,
@@ -15,6 +14,7 @@ import {
} from '@mui/material';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import {
fetchRegistrationCode,
diff --git a/client/app/bundles/course/user-notification/operations.ts b/client/app/bundles/course/user-notification/operations.ts
index 12a973f3c16..c97cd22d65f 100644
--- a/client/app/bundles/course/user-notification/operations.ts
+++ b/client/app/bundles/course/user-notification/operations.ts
@@ -1,27 +1,12 @@
-import { AxiosError } from 'axios';
import { UserNotificationData } from 'types/course/userNotifications';
import CourseAPI from 'api/course';
-/**
- * Fetches the current user's notifications in the course, if available.
- *
- * If the current user is not a course user, the network request will fail with
- * status code 403. In this case, `undefined` will be returned and no errors will
- * be thrown.
- */
export const fetchNotifications = async (): Promise<
UserNotificationData | undefined
> => {
- try {
- const response = await CourseAPI.userNotifications.fetch();
- return response.data;
- } catch (error) {
- if (!(error instanceof AxiosError && error.response?.status === 403))
- throw error;
-
- return undefined;
- }
+ const response = await CourseAPI.userNotifications.fetch();
+ return response.data ?? undefined;
};
export const markAsRead = async (
diff --git a/client/app/bundles/course/users/components/buttons/PointManagementButtons.tsx b/client/app/bundles/course/users/components/buttons/PointManagementButtons.tsx
index 71438547a4e..ad16ffd45da 100644
--- a/client/app/bundles/course/users/components/buttons/PointManagementButtons.tsx
+++ b/client/app/bundles/course/users/components/buttons/PointManagementButtons.tsx
@@ -1,6 +1,5 @@
import { FC, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import {
ExperiencePointsRecordPermissions,
ExperiencePointsRowData,
@@ -9,6 +8,7 @@ import {
import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import SaveButton from 'lib/components/core/buttons/SaveButton';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import {
deleteExperiencePointsRecord,
diff --git a/client/app/bundles/course/users/components/buttons/UserManagementButtons.tsx b/client/app/bundles/course/users/components/buttons/UserManagementButtons.tsx
index 6d14d9fee57..5133e22aaac 100644
--- a/client/app/bundles/course/users/components/buttons/UserManagementButtons.tsx
+++ b/client/app/bundles/course/users/components/buttons/UserManagementButtons.tsx
@@ -1,12 +1,12 @@
import { FC, memo, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import equal from 'fast-deep-equal';
import { CourseUserMiniEntity } from 'types/course/courseUsers';
import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import { COURSE_USER_ROLES } from 'lib/constants/sharedConstants';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { deleteUser } from '../../operations';
diff --git a/client/app/bundles/course/users/components/misc/PersonalTimeEditor.tsx b/client/app/bundles/course/users/components/misc/PersonalTimeEditor.tsx
index 554badb7c20..a6dbaae8794 100644
--- a/client/app/bundles/course/users/components/misc/PersonalTimeEditor.tsx
+++ b/client/app/bundles/course/users/components/misc/PersonalTimeEditor.tsx
@@ -7,7 +7,6 @@ import {
WrappedComponentProps,
} from 'react-intl';
import { useParams } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { yupResolver } from '@hookform/resolvers/yup';
import Add from '@mui/icons-material/Add';
import LockOpenOutlined from '@mui/icons-material/LockOpenOutlined';
@@ -23,6 +22,7 @@ import FormCheckboxField from 'lib/components/form/fields/CheckboxField';
import FormDateTimePickerField from 'lib/components/form/fields/DateTimePickerField';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import formTranslations from 'lib/translations/form';
import tableTranslations from 'lib/translations/table';
diff --git a/client/app/bundles/course/users/components/misc/UpgradeToStaff.tsx b/client/app/bundles/course/users/components/misc/UpgradeToStaff.tsx
index 02e04453707..c769460892a 100644
--- a/client/app/bundles/course/users/components/misc/UpgradeToStaff.tsx
+++ b/client/app/bundles/course/users/components/misc/UpgradeToStaff.tsx
@@ -1,6 +1,5 @@
import { FC, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import CheckBoxIcon from '@mui/icons-material/CheckBox';
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import { LoadingButton } from '@mui/lab';
@@ -20,6 +19,7 @@ import {
import { STAFF_ROLES } from 'lib/constants/sharedConstants';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { upgradeToStaff } from '../../operations';
import { getStudentOptionMiniEntities } from '../../selectors';
diff --git a/client/app/bundles/course/users/components/misc/UserProfileAchievements.tsx b/client/app/bundles/course/users/components/misc/UserProfileAchievements.tsx
index aa11f620b46..a1773601284 100644
--- a/client/app/bundles/course/users/components/misc/UserProfileAchievements.tsx
+++ b/client/app/bundles/course/users/components/misc/UserProfileAchievements.tsx
@@ -38,7 +38,7 @@ const translations = defineMessages({
const UserProfileAchievements: FC = ({ achievements, intl }: Props) => {
return (
<>
-
+
{intl.formatMessage(translations.achivementsHeader)}
{achievements.length > 0 ? (
diff --git a/client/app/bundles/course/users/components/misc/UserProfileCard.tsx b/client/app/bundles/course/users/components/misc/UserProfileCard.tsx
index 234ac0bd099..874cc8709b2 100644
--- a/client/app/bundles/course/users/components/misc/UserProfileCard.tsx
+++ b/client/app/bundles/course/users/components/misc/UserProfileCard.tsx
@@ -109,7 +109,7 @@ const UserProfileCard: FC = ({ user, intl }) => {
direction="column"
item
>
- {user.name}
+ {user.name}
{COURSE_USER_ROLES[user.role]}
diff --git a/client/app/bundles/course/users/components/misc/UserProfileSkills.tsx b/client/app/bundles/course/users/components/misc/UserProfileSkills.tsx
index 65f1521e9d0..2502f83e43a 100644
--- a/client/app/bundles/course/users/components/misc/UserProfileSkills.tsx
+++ b/client/app/bundles/course/users/components/misc/UserProfileSkills.tsx
@@ -41,7 +41,7 @@ const UserProfileSkills: FC = ({ skillBranches, intl }: Props) => {
return (
<>
-
+
{intl.formatMessage(translations.topicMasteryHeader)}
diff --git a/client/app/bundles/course/users/components/tables/ExperiencePointsTable.tsx b/client/app/bundles/course/users/components/tables/ExperiencePointsTable.tsx
index 9658246151e..01ef1d59c51 100644
--- a/client/app/bundles/course/users/components/tables/ExperiencePointsTable.tsx
+++ b/client/app/bundles/course/users/components/tables/ExperiencePointsTable.tsx
@@ -1,6 +1,5 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import {
Paper,
Table,
@@ -13,6 +12,7 @@ import {
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { getCourseUserId } from 'lib/helpers/url-helpers';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import tableTranslations from 'lib/translations/table';
import { fetchExperiencePointsRecord } from '../../operations';
diff --git a/client/app/bundles/course/users/components/tables/ManageUsersTable/AlgorithmMenu.tsx b/client/app/bundles/course/users/components/tables/ManageUsersTable/AlgorithmMenu.tsx
index 8f75b5483c8..2309c7efec3 100644
--- a/client/app/bundles/course/users/components/tables/ManageUsersTable/AlgorithmMenu.tsx
+++ b/client/app/bundles/course/users/components/tables/ManageUsersTable/AlgorithmMenu.tsx
@@ -1,5 +1,4 @@
import { memo } from 'react';
-import { toast } from 'react-toastify';
import { MenuItem, TextField } from '@mui/material';
import equal from 'fast-deep-equal';
import { CourseUserMiniEntity } from 'types/course/courseUsers';
@@ -8,6 +7,7 @@ import { TimelineAlgorithm } from 'types/course/personalTimes';
import { updateUser } from 'bundles/course/users/operations';
import { TIMELINE_ALGORITHMS } from 'lib/constants/sharedConstants';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import translations from './translations';
diff --git a/client/app/bundles/course/users/components/tables/ManageUsersTable/BulkAssignTimelineButton.tsx b/client/app/bundles/course/users/components/tables/ManageUsersTable/BulkAssignTimelineButton.tsx
index 0653a43f24f..360c08e1356 100644
--- a/client/app/bundles/course/users/components/tables/ManageUsersTable/BulkAssignTimelineButton.tsx
+++ b/client/app/bundles/course/users/components/tables/ManageUsersTable/BulkAssignTimelineButton.tsx
@@ -1,5 +1,4 @@
import { useState } from 'react';
-import { toast } from 'react-toastify';
import { ExpandMore } from '@mui/icons-material';
import { Button, Menu, MenuItem } from '@mui/material';
import { CourseUserMiniEntity } from 'types/course/courseUsers';
@@ -7,6 +6,7 @@ import { TimelineData } from 'types/course/referenceTimelines';
import { assignToTimeline } from 'bundles/course/users/operations';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import translations from './translations';
diff --git a/client/app/bundles/course/users/components/tables/ManageUsersTable/PhantomSwitch.tsx b/client/app/bundles/course/users/components/tables/ManageUsersTable/PhantomSwitch.tsx
index 8e4300995e5..4a385f6e1df 100644
--- a/client/app/bundles/course/users/components/tables/ManageUsersTable/PhantomSwitch.tsx
+++ b/client/app/bundles/course/users/components/tables/ManageUsersTable/PhantomSwitch.tsx
@@ -1,11 +1,11 @@
import { memo } from 'react';
-import { toast } from 'react-toastify';
import { Switch } from '@mui/material';
import equal from 'fast-deep-equal';
import { CourseUserMiniEntity } from 'types/course/courseUsers';
import { updateUser } from 'bundles/course/users/operations';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import translations from './translations';
diff --git a/client/app/bundles/course/users/components/tables/ManageUsersTable/RoleMenu.tsx b/client/app/bundles/course/users/components/tables/ManageUsersTable/RoleMenu.tsx
index 2934ff7b37a..9809579837d 100644
--- a/client/app/bundles/course/users/components/tables/ManageUsersTable/RoleMenu.tsx
+++ b/client/app/bundles/course/users/components/tables/ManageUsersTable/RoleMenu.tsx
@@ -1,5 +1,4 @@
import { memo } from 'react';
-import { toast } from 'react-toastify';
import { MenuItem, TextField } from '@mui/material';
import equal from 'fast-deep-equal';
import {
@@ -10,6 +9,7 @@ import {
import { updateUser } from 'bundles/course/users/operations';
import { COURSE_USER_ROLES } from 'lib/constants/sharedConstants';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import translations from './translations';
diff --git a/client/app/bundles/course/users/components/tables/ManageUsersTable/TimelineMenu.tsx b/client/app/bundles/course/users/components/tables/ManageUsersTable/TimelineMenu.tsx
index f061cf1d33f..9ee88423f71 100644
--- a/client/app/bundles/course/users/components/tables/ManageUsersTable/TimelineMenu.tsx
+++ b/client/app/bundles/course/users/components/tables/ManageUsersTable/TimelineMenu.tsx
@@ -1,11 +1,11 @@
import { memo } from 'react';
-import { toast } from 'react-toastify';
import { TextField } from '@mui/material';
import equal from 'fast-deep-equal';
import { CourseUserMiniEntity } from 'types/course/courseUsers';
import { updateUser } from 'bundles/course/users/operations';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import translations from './translations';
diff --git a/client/app/bundles/course/users/components/tables/ManageUsersTable/UserNameField.tsx b/client/app/bundles/course/users/components/tables/ManageUsersTable/UserNameField.tsx
index 3ed17199947..b6c773ae58b 100644
--- a/client/app/bundles/course/users/components/tables/ManageUsersTable/UserNameField.tsx
+++ b/client/app/bundles/course/users/components/tables/ManageUsersTable/UserNameField.tsx
@@ -1,11 +1,11 @@
import { memo } from 'react';
-import { toast } from 'react-toastify';
import equal from 'fast-deep-equal';
import { CourseUserMiniEntity } from 'types/course/courseUsers';
import { updateUser } from 'bundles/course/users/operations';
import InlineEditTextField from 'lib/components/form/fields/DataTableInlineEditable/TextField';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import translations from './translations';
diff --git a/client/app/bundles/course/users/pages/ManageStaff/index.tsx b/client/app/bundles/course/users/pages/ManageStaff/index.tsx
index d539c19ce25..1f17105999f 100644
--- a/client/app/bundles/course/users/pages/ManageStaff/index.tsx
+++ b/client/app/bundles/course/users/pages/ManageStaff/index.tsx
@@ -1,11 +1,11 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import Note from 'lib/components/core/Note';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import manageUsersTranslations from 'lib/translations/course/users/index';
import UserManagementButtons from '../../components/buttons/UserManagementButtons';
diff --git a/client/app/bundles/course/users/pages/ManageStudents/index.tsx b/client/app/bundles/course/users/pages/ManageStudents/index.tsx
index 2ad6f6a4041..9f71f90cbc1 100644
--- a/client/app/bundles/course/users/pages/ManageStudents/index.tsx
+++ b/client/app/bundles/course/users/pages/ManageStudents/index.tsx
@@ -1,11 +1,11 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import Note from 'lib/components/core/Note';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import manageUsersTranslations from 'lib/translations/course/users/index';
import UserManagementButtons from '../../components/buttons/UserManagementButtons';
diff --git a/client/app/bundles/course/users/pages/PersonalTimes/index.tsx b/client/app/bundles/course/users/pages/PersonalTimes/index.tsx
index 9e7df44ccb1..7e61055eb95 100644
--- a/client/app/bundles/course/users/pages/PersonalTimes/index.tsx
+++ b/client/app/bundles/course/users/pages/PersonalTimes/index.tsx
@@ -1,11 +1,11 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Typography } from '@mui/material';
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import SelectCourseUser from '../../components/misc/SelectCourseUser';
import UserManagementTabs from '../../components/navigation/UserManagementTabs';
diff --git a/client/app/bundles/course/users/pages/PersonalTimesShow/index.tsx b/client/app/bundles/course/users/pages/PersonalTimesShow/index.tsx
index 76ef1073292..0cc39fca190 100644
--- a/client/app/bundles/course/users/pages/PersonalTimesShow/index.tsx
+++ b/client/app/bundles/course/users/pages/PersonalTimesShow/index.tsx
@@ -1,7 +1,6 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
import { useParams } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { LoadingButton } from '@mui/lab';
import { Grid, MenuItem, Stack, TextField, Typography } from '@mui/material';
import { CourseUserEntity } from 'types/course/courseUsers';
@@ -11,6 +10,7 @@ import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { TIMELINE_ALGORITHMS } from 'lib/constants/sharedConstants';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import SelectCourseUser from '../../components/misc/SelectCourseUser';
import UserManagementTabs from '../../components/navigation/UserManagementTabs';
diff --git a/client/app/bundles/course/users/pages/UserStatistics/LearningRateRecords/index.tsx b/client/app/bundles/course/users/pages/UserStatistics/LearningRateRecords/index.tsx
index d54aa8ad252..2c781b48dbc 100644
--- a/client/app/bundles/course/users/pages/UserStatistics/LearningRateRecords/index.tsx
+++ b/client/app/bundles/course/users/pages/UserStatistics/LearningRateRecords/index.tsx
@@ -20,7 +20,7 @@ const LearningRateRecords: FC = () => {
const { t } = useTranslation();
return (
<>
- {t(translations.header)}
+ {t(translations.header)}
}
while={fetchCourseUserLearningRateData}
diff --git a/client/app/bundles/course/users/pages/UsersIndex/index.tsx b/client/app/bundles/course/users/pages/UsersIndex/index.tsx
index fca28d3dad9..ee8141a1d21 100644
--- a/client/app/bundles/course/users/pages/UsersIndex/index.tsx
+++ b/client/app/bundles/course/users/pages/UsersIndex/index.tsx
@@ -1,6 +1,5 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Avatar, Grid, Typography } from '@mui/material';
import Page from 'lib/components/core/layouts/Page';
@@ -9,6 +8,7 @@ import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { getCourseUserURL } from 'lib/helpers/url-builders';
import { getCourseId } from 'lib/helpers/url-helpers';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { fetchUsers } from '../../operations';
import { getAllStudentMiniEntities } from '../../selectors';
diff --git a/client/app/bundles/course/video-submissions/pages/UserVideoSubmissionsIndex/index.tsx b/client/app/bundles/course/video-submissions/pages/UserVideoSubmissionsIndex/index.tsx
index b7b3d960735..8256b8db128 100644
--- a/client/app/bundles/course/video-submissions/pages/UserVideoSubmissionsIndex/index.tsx
+++ b/client/app/bundles/course/video-submissions/pages/UserVideoSubmissionsIndex/index.tsx
@@ -1,10 +1,10 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { VideoSubmissionListData } from 'types/course/videoSubmissions';
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
+import toast from 'lib/hooks/toast';
import UserVideoSubmissionTable from '../../components/tables/UserVideoSubmissionTable';
import { fetchVideoSubmissions } from '../../operations';
diff --git a/client/app/bundles/course/video/attemptLoader.ts b/client/app/bundles/course/video/attemptLoader.ts
new file mode 100644
index 00000000000..c5328ef44ba
--- /dev/null
+++ b/client/app/bundles/course/video/attemptLoader.ts
@@ -0,0 +1,51 @@
+import { defineMessages } from 'react-intl';
+import { LoaderFunction, Params, redirect } from 'react-router-dom';
+import { getIdFromUnknown } from 'utilities';
+
+import CourseAPI from 'api/course';
+import toast from 'lib/hooks/toast';
+import { Translated } from 'lib/hooks/useTranslation';
+
+const translations = defineMessages({
+ errorWatchVideo: {
+ id: 'client.video.attemptLoader.errorWatchVideo',
+ defaultMessage:
+ 'An error occurred while attempting to watch this video. Try again later.',
+ },
+});
+
+const getSubmissionURL = (
+ params: Params,
+ submissionId: number,
+): string | null => {
+ const courseId = getIdFromUnknown(params?.courseId);
+ const videoId = getIdFromUnknown(params?.videoId);
+ if (!courseId || !videoId) return null;
+
+ return `/courses/${courseId}/videos/${videoId}/submissions/${submissionId}/edit`;
+};
+
+const videoAttemptLoader: Translated =
+ (t) =>
+ async ({ params }) => {
+ try {
+ const videoId = getIdFromUnknown(params?.videoId);
+ if (!videoId) return redirect('/');
+
+ const { data } = await CourseAPI.video.submissions.create(videoId);
+
+ const url = getSubmissionURL(params, data.submissionId);
+ if (!url) return redirect('/');
+
+ return redirect(url);
+ } catch {
+ toast.error(t(translations.errorWatchVideo));
+
+ const { courseId } = params;
+ if (!courseId) return redirect('/');
+
+ return redirect(`/courses/${courseId}/videos`);
+ }
+ };
+
+export default videoAttemptLoader;
diff --git a/client/app/bundles/course/video/components/buttons/VideoManagementButtons.tsx b/client/app/bundles/course/video/components/buttons/VideoManagementButtons.tsx
index 031a9fa31cb..956edd27895 100644
--- a/client/app/bundles/course/video/components/buttons/VideoManagementButtons.tsx
+++ b/client/app/bundles/course/video/components/buttons/VideoManagementButtons.tsx
@@ -1,7 +1,6 @@
import { FC, useState } from 'react';
import { defineMessages } from 'react-intl';
import { useNavigate } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { VideoListData } from 'types/course/videos';
import DeleteButton from 'lib/components/core/buttons/DeleteButton';
@@ -9,6 +8,7 @@ import EditButton from 'lib/components/core/buttons/EditButton';
import { getVideosURL } from 'lib/helpers/url-builders';
import { getCourseId } from 'lib/helpers/url-helpers';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { deleteVideo } from '../../operations';
diff --git a/client/app/bundles/course/video/components/buttons/WatchVideoButton.tsx b/client/app/bundles/course/video/components/buttons/WatchVideoButton.tsx
index 8ffc39a250f..e1a6d077f2c 100644
--- a/client/app/bundles/course/video/components/buttons/WatchVideoButton.tsx
+++ b/client/app/bundles/course/video/components/buttons/WatchVideoButton.tsx
@@ -1,13 +1,10 @@
import { FC } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { useNavigate } from 'react-router-dom';
-import { toast } from 'react-toastify';
+import { useParams } from 'react-router-dom';
import { Button } from '@mui/material';
import { VideoListData } from 'types/course/videos';
-import CourseAPI from 'api/course';
-import { getEditVideoSubmissionURL } from 'lib/helpers/url-builders';
-import { getCourseId } from 'lib/helpers/url-helpers';
+import Link from 'lib/components/core/Link';
interface Props extends WrappedComponentProps {
video: VideoListData;
@@ -30,61 +27,27 @@ const translations = defineMessages({
const WatchVideoButton: FC = (props) => {
const { video, intl } = props;
- const navigate = useNavigate();
+
+ const { courseId } = useParams();
if (video.permissions.canAttempt) {
if (video.videoSubmissionId) {
return (
-
- navigate(
- getEditVideoSubmissionURL(
- getCourseId(),
- video.id,
- video.videoSubmissionId,
- ),
- )
- }
- variant="outlined"
+
- {intl.formatMessage(translations.reWatch)}
-
+
+ {intl.formatMessage(translations.reWatch)}
+
+
);
}
return (
- {
- CourseAPI.video.submissions
- .create(video.id)
- .then((response) =>
- navigate(
- getEditVideoSubmissionURL(
- getCourseId(),
- video.id,
- response.data.submissionId,
- ),
- ),
- )
- .catch((error) => {
- const errorMessage = error.response?.data?.errors
- ? error.response.data.errors
- : '';
-
- toast.error(
- intl.formatMessage(translations.attemptFailure, {
- error: errorMessage,
- }),
- );
- });
- }}
- variant="outlined"
- >
- {intl.formatMessage(translations.watch)}
-
+
+
+ {intl.formatMessage(translations.watch)}
+
+
);
}
return (
diff --git a/client/app/bundles/course/video/pages/VideoEdit/index.tsx b/client/app/bundles/course/video/pages/VideoEdit/index.tsx
index a41f944bdb7..8aee4b75092 100644
--- a/client/app/bundles/course/video/pages/VideoEdit/index.tsx
+++ b/client/app/bundles/course/video/pages/VideoEdit/index.tsx
@@ -1,10 +1,10 @@
import { FC } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { VideoFormData, VideoListData } from 'types/course/videos';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import VideoForm from '../../components/forms/VideoForm';
diff --git a/client/app/bundles/course/video/pages/VideoNew/index.tsx b/client/app/bundles/course/video/pages/VideoNew/index.tsx
index a31be84ca61..a2bcc746129 100644
--- a/client/app/bundles/course/video/pages/VideoNew/index.tsx
+++ b/client/app/bundles/course/video/pages/VideoNew/index.tsx
@@ -1,10 +1,10 @@
import { FC, memo } from 'react';
import { defineMessages } from 'react-intl';
import { useSearchParams } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import VideoForm from '../../components/forms/VideoForm';
diff --git a/client/app/bundles/course/video/pages/VideoShow/index.tsx b/client/app/bundles/course/video/pages/VideoShow/index.tsx
index de73ab1136c..150b808e616 100644
--- a/client/app/bundles/course/video/pages/VideoShow/index.tsx
+++ b/client/app/bundles/course/video/pages/VideoShow/index.tsx
@@ -1,7 +1,6 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
import { useParams } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { Card, CardContent, CardHeader } from '@mui/material';
import DescriptionCard from 'lib/components/core/DescriptionCard';
@@ -10,6 +9,7 @@ import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { getVideosURL } from 'lib/helpers/url-builders';
import { getCourseId } from 'lib/helpers/url-helpers';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import VideoManagementButtons from '../../components/buttons/VideoManagementButtons';
import WatchVideoButton from '../../components/buttons/WatchVideoButton';
diff --git a/client/app/bundles/course/video/pages/VideosIndex/index.tsx b/client/app/bundles/course/video/pages/VideosIndex/index.tsx
index 96e67249762..ab398e734c2 100644
--- a/client/app/bundles/course/video/pages/VideosIndex/index.tsx
+++ b/client/app/bundles/course/video/pages/VideosIndex/index.tsx
@@ -1,12 +1,12 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { defineMessages } from 'react-intl';
import { useSearchParams } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { Button } from '@mui/material';
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import VideoTabs from '../../components/misc/VideoTabs';
diff --git a/client/app/bundles/course/video/submission/actions/__test__/video.test.js b/client/app/bundles/course/video/submission/actions/__test__/video.test.js
index 467deee3b4e..76a36aa167a 100644
--- a/client/app/bundles/course/video/submission/actions/__test__/video.test.js
+++ b/client/app/bundles/course/video/submission/actions/__test__/video.test.js
@@ -1,5 +1,5 @@
-import MockAdapter from 'axios-mock-adapter';
import { List as makeImmutableList, Map as makeImmutableMap } from 'immutable';
+import { createMockAdapter } from 'mocks/axiosMock';
import CourseAPI from 'api/course';
import { playerStates } from 'lib/constants/videoConstants';
@@ -10,7 +10,7 @@ import { changePlayerState, endSession, sendEvents } from '../video';
const videoId = '1';
const client = CourseAPI.video.sessions.client;
-const mock = new MockAdapter(client, { delayResponse: 0 });
+const mock = createMockAdapter(client, { delayResponse: 0 });
const oldSessionsFixtures = makeImmutableMap({
25: {
diff --git a/client/app/bundles/course/video/submission/actions/discussion.js b/client/app/bundles/course/video/submission/actions/discussion.js
index 48c5eef9439..77419197bd6 100644
--- a/client/app/bundles/course/video/submission/actions/discussion.js
+++ b/client/app/bundles/course/video/submission/actions/discussion.js
@@ -1,10 +1,9 @@
-import { toast } from 'react-toastify';
-
import CourseAPI from 'api/course';
import {
discussionActionTypes,
postRequestingStatuses,
} from 'lib/constants/videoConstants';
+import toast from 'lib/hooks/toast';
/**
* Creates an action to update the new post being created with the main comment box.
diff --git a/client/app/bundles/course/video/submission/containers/VideoControls/NextVideoButton.jsx b/client/app/bundles/course/video/submission/containers/VideoControls/NextVideoButton.jsx
index a3ec855a522..62f25f1c8d4 100644
--- a/client/app/bundles/course/video/submission/containers/VideoControls/NextVideoButton.jsx
+++ b/client/app/bundles/course/video/submission/containers/VideoControls/NextVideoButton.jsx
@@ -1,11 +1,10 @@
-import { useState } from 'react';
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import SkipNext from '@mui/icons-material/SkipNext';
import { IconButton, Tooltip } from '@mui/material';
import PropTypes from 'prop-types';
-import axios from 'lib/axios';
+import Link from 'lib/components/core/Link';
import translations from '../../translations';
@@ -14,11 +13,9 @@ import styles from '../VideoPlayer.scss';
const propTypes = {
intl: PropTypes.object.isRequired,
url: PropTypes.string,
- nextVideoSubmissionExists: PropTypes.bool,
};
const NextVideoButton = (props) => {
- const [isLoading, setIsLoading] = useState(false);
if (!props.url) {
return (
@@ -33,22 +30,11 @@ const NextVideoButton = (props) => {
return (
- {
- setIsLoading(true);
- if (!props.nextVideoSubmissionExists) {
- axios.get(props.url).then((response) => {
- window.location.href = response.data.submissionUrl;
- });
- } else {
- window.location.href = props.url;
- }
- }}
- >
-
-
+
+
+
+
+
);
};
@@ -58,7 +44,6 @@ NextVideoButton.propTypes = propTypes;
function mapStateToProps(state) {
return {
url: state.video.watchNextVideoUrl,
- nextVideoSubmissionExists: state.video.nextVideoSubmissionExists,
};
}
diff --git a/client/app/bundles/course/video/submission/pages/VideoSubmissionEdit/index.tsx b/client/app/bundles/course/video/submission/pages/VideoSubmissionEdit/index.tsx
index 3f3936a1278..b88100b72dc 100644
--- a/client/app/bundles/course/video/submission/pages/VideoSubmissionEdit/index.tsx
+++ b/client/app/bundles/course/video/submission/pages/VideoSubmissionEdit/index.tsx
@@ -1,13 +1,13 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
import { useParams } from 'react-router-dom';
-import { toast } from 'react-toastify';
import { VideoEditSubmissionData } from 'types/course/video/submissions';
import CourseAPI from 'api/course';
import DescriptionCard from 'lib/components/core/DescriptionCard';
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
+import toast from 'lib/hooks/toast';
import SubmissionEditWithStore from './SubmissionEditWithStore';
@@ -38,7 +38,7 @@ const VideoSubmissionEdit: FC = (props) => {
useEffect(() => {
if (submissionId) {
CourseAPI.video.submissions
- .edit(submissionId)
+ .edit(+submissionId)
.then((response) => {
setEditVideoSubmission(response.data);
setIsLoading(false);
diff --git a/client/app/bundles/course/video/submission/pages/VideoSubmissionShow/index.tsx b/client/app/bundles/course/video/submission/pages/VideoSubmissionShow/index.tsx
index f59bd11bfea..acb12271c8c 100644
--- a/client/app/bundles/course/video/submission/pages/VideoSubmissionShow/index.tsx
+++ b/client/app/bundles/course/video/submission/pages/VideoSubmissionShow/index.tsx
@@ -1,7 +1,6 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
import { useParams } from 'react-router-dom';
-import { toast } from 'react-toastify';
import {
Card,
CardContent,
@@ -20,6 +19,7 @@ import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import Note from 'lib/components/core/Note';
import { getVideoSubmissionsURL } from 'lib/helpers/url-builders';
import { getCourseId, getVideoId } from 'lib/helpers/url-helpers';
+import toast from 'lib/hooks/toast';
import { formatLongDateTime } from 'lib/moment';
import StatisticsWithStore from './StatisticsWithStore';
@@ -84,7 +84,7 @@ const VideoSubmissionShow: FC = (props) => {
useEffect(() => {
if (submissionId) {
CourseAPI.video.submissions
- .fetch(submissionId)
+ .fetch(+submissionId)
.then((response) => {
setVideoSubmission(response.data);
setIsLoading(false);
diff --git a/client/app/bundles/course/video/submission/pages/VideoSubmissionsIndex/index.tsx b/client/app/bundles/course/video/submission/pages/VideoSubmissionsIndex/index.tsx
index 86325a9b1a8..8b209a224c8 100644
--- a/client/app/bundles/course/video/submission/pages/VideoSubmissionsIndex/index.tsx
+++ b/client/app/bundles/course/video/submission/pages/VideoSubmissionsIndex/index.tsx
@@ -1,6 +1,5 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { VideoSubmission } from 'types/course/video/submissions';
import CourseAPI from 'api/course';
@@ -9,6 +8,7 @@ import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import Note from 'lib/components/core/Note';
import { getVideosURL } from 'lib/helpers/url-builders';
import { getCourseId } from 'lib/helpers/url-helpers';
+import toast from 'lib/hooks/toast';
import VideoSubmissionsTable from '../../components/tables/VideoSubmissionsTable';
diff --git a/client/app/bundles/system/admin/admin/components/buttons/CoursesButtons.tsx b/client/app/bundles/system/admin/admin/components/buttons/CoursesButtons.tsx
index a7388fb5c8f..9b28c0a98c1 100644
--- a/client/app/bundles/system/admin/admin/components/buttons/CoursesButtons.tsx
+++ b/client/app/bundles/system/admin/admin/components/buttons/CoursesButtons.tsx
@@ -1,6 +1,5 @@
import { FC, memo, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Delete } from '@mui/icons-material';
import { IconButton } from '@mui/material';
import equal from 'fast-deep-equal';
@@ -9,6 +8,7 @@ import { CourseMiniEntity } from 'types/system/courses';
import DeleteCoursePrompt from 'bundles/course/admin/pages/CourseSettings/DeleteCoursePrompt';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
interface Props extends WrappedComponentProps {
course: CourseMiniEntity;
diff --git a/client/app/bundles/system/admin/admin/components/buttons/InstancesButtons.tsx b/client/app/bundles/system/admin/admin/components/buttons/InstancesButtons.tsx
index 79847d542d6..655348d0323 100644
--- a/client/app/bundles/system/admin/admin/components/buttons/InstancesButtons.tsx
+++ b/client/app/bundles/system/admin/admin/components/buttons/InstancesButtons.tsx
@@ -1,11 +1,11 @@
import { FC, memo, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import equal from 'fast-deep-equal';
import { InstanceMiniEntity } from 'types/system/instances';
import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { deleteInstance } from '../../operations';
diff --git a/client/app/bundles/system/admin/admin/components/buttons/UsersButtons.tsx b/client/app/bundles/system/admin/admin/components/buttons/UsersButtons.tsx
index 9514971e03a..23ffab47329 100644
--- a/client/app/bundles/system/admin/admin/components/buttons/UsersButtons.tsx
+++ b/client/app/bundles/system/admin/admin/components/buttons/UsersButtons.tsx
@@ -1,6 +1,5 @@
import { FC, memo, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import equal from 'fast-deep-equal';
import { UserMiniEntity } from 'types/users';
@@ -8,6 +7,7 @@ import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import MasqueradeButton from 'lib/components/core/buttons/MasqueradeButton';
import { USER_ROLES } from 'lib/constants/sharedConstants';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { deleteUser } from '../../operations';
diff --git a/client/app/bundles/system/admin/admin/components/tables/InstancesTable/InstanceField.tsx b/client/app/bundles/system/admin/admin/components/tables/InstancesTable/InstanceField.tsx
index 63aafa35ccc..77114fc3bb4 100644
--- a/client/app/bundles/system/admin/admin/components/tables/InstancesTable/InstanceField.tsx
+++ b/client/app/bundles/system/admin/admin/components/tables/InstancesTable/InstanceField.tsx
@@ -1,9 +1,9 @@
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { InstanceMiniEntity } from 'types/system/instances';
import InlineEditTextField from 'lib/components/form/fields/DataTableInlineEditable/TextField';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { updateInstance } from '../../../operations';
diff --git a/client/app/bundles/system/admin/admin/components/tables/UsersTable.tsx b/client/app/bundles/system/admin/admin/components/tables/UsersTable.tsx
index 568b13c3a96..620a6e4df9d 100644
--- a/client/app/bundles/system/admin/admin/components/tables/UsersTable.tsx
+++ b/client/app/bundles/system/admin/admin/components/tables/UsersTable.tsx
@@ -1,6 +1,5 @@
import { FC, ReactElement, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import {
CircularProgress,
MenuItem,
@@ -25,6 +24,7 @@ import {
} from 'lib/constants/sharedConstants';
import rebuildObjectFromRow from 'lib/helpers/mui-datatables-helpers';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import tableTranslations from 'lib/translations/table';
import { indexUsers, updateUser } from '../../operations';
diff --git a/client/app/bundles/system/admin/admin/pages/AnnouncementsIndex.tsx b/client/app/bundles/system/admin/admin/pages/AnnouncementsIndex.tsx
index ed691181d85..1785d03813a 100644
--- a/client/app/bundles/system/admin/admin/pages/AnnouncementsIndex.tsx
+++ b/client/app/bundles/system/admin/admin/pages/AnnouncementsIndex.tsx
@@ -1,6 +1,5 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Button } from '@mui/material';
import AnnouncementsDisplay from 'bundles/course/announcements/components/misc/AnnouncementsDisplay';
@@ -8,6 +7,7 @@ import AnnouncementNew from 'bundles/course/announcements/pages/AnnouncementNew'
import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import {
createAnnouncement,
diff --git a/client/app/bundles/system/admin/admin/pages/CoursesIndex.tsx b/client/app/bundles/system/admin/admin/pages/CoursesIndex.tsx
index 5ce0586e975..7fa97c19ef0 100644
--- a/client/app/bundles/system/admin/admin/pages/CoursesIndex.tsx
+++ b/client/app/bundles/system/admin/admin/pages/CoursesIndex.tsx
@@ -1,6 +1,5 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Typography } from '@mui/material';
import SummaryCard from 'lib/components/core/layouts/SummaryCard';
@@ -8,6 +7,7 @@ import Link from 'lib/components/core/Link';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import CoursesButtons from '../components/buttons/CoursesButtons';
import CoursesTable from '../components/tables/CoursesTable';
diff --git a/client/app/bundles/system/admin/admin/pages/InstanceNew.tsx b/client/app/bundles/system/admin/admin/pages/InstanceNew.tsx
index 376b42f2749..c8c606794ee 100644
--- a/client/app/bundles/system/admin/admin/pages/InstanceNew.tsx
+++ b/client/app/bundles/system/admin/admin/pages/InstanceNew.tsx
@@ -1,10 +1,10 @@
import { FC } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { InstanceFormData } from 'types/system/instances';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import InstanceForm from '../components/forms/InstanceForm';
diff --git a/client/app/bundles/system/admin/admin/pages/InstancesIndex.tsx b/client/app/bundles/system/admin/admin/pages/InstancesIndex.tsx
index 21ebef3fb27..657d0e17b9a 100644
--- a/client/app/bundles/system/admin/admin/pages/InstancesIndex.tsx
+++ b/client/app/bundles/system/admin/admin/pages/InstancesIndex.tsx
@@ -1,10 +1,10 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { Button } from '@mui/material';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import InstancesButtons from '../components/buttons/InstancesButtons';
diff --git a/client/app/bundles/system/admin/admin/pages/UsersIndex.tsx b/client/app/bundles/system/admin/admin/pages/UsersIndex.tsx
index b7edc57fbcc..a57c1ee9841 100644
--- a/client/app/bundles/system/admin/admin/pages/UsersIndex.tsx
+++ b/client/app/bundles/system/admin/admin/pages/UsersIndex.tsx
@@ -1,6 +1,5 @@
import { FC, useEffect, useMemo, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Typography } from '@mui/material';
import SummaryCard from 'lib/components/core/layouts/SummaryCard';
@@ -8,6 +7,7 @@ import Link from 'lib/components/core/Link';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import UsersButtons from '../components/buttons/UsersButtons';
import UsersTable from '../components/tables/UsersTable';
diff --git a/client/app/bundles/system/admin/instance/instance/components/buttons/PendingInvitationsButtons.tsx b/client/app/bundles/system/admin/instance/instance/components/buttons/PendingInvitationsButtons.tsx
index cdcb099f3cd..bf5374c7f2a 100644
--- a/client/app/bundles/system/admin/instance/instance/components/buttons/PendingInvitationsButtons.tsx
+++ b/client/app/bundles/system/admin/instance/instance/components/buttons/PendingInvitationsButtons.tsx
@@ -1,12 +1,12 @@
import { FC, memo, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import equal from 'fast-deep-equal';
import { InvitationRowData } from 'types/system/instance/invitations';
import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import EmailButton from 'lib/components/core/buttons/EmailButton';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { deleteInvitation, resendInvitationEmail } from '../../operations';
diff --git a/client/app/bundles/system/admin/instance/instance/components/buttons/PendingRoleRequestsButtons.tsx b/client/app/bundles/system/admin/instance/instance/components/buttons/PendingRoleRequestsButtons.tsx
index b46de1eebf2..f84819b2a46 100644
--- a/client/app/bundles/system/admin/instance/instance/components/buttons/PendingRoleRequestsButtons.tsx
+++ b/client/app/bundles/system/admin/instance/instance/components/buttons/PendingRoleRequestsButtons.tsx
@@ -1,6 +1,5 @@
import { FC, memo, useState } from 'react';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import equal from 'fast-deep-equal';
import { RoleRequestRowData } from 'types/system/instance/roleRequests';
@@ -9,6 +8,7 @@ import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import EmailButton from 'lib/components/core/buttons/EmailButton';
import { ROLE_REQUEST_ROLES } from 'lib/constants/sharedConstants';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import { approveRoleRequest, rejectRoleRequest } from '../../operations';
diff --git a/client/app/bundles/system/admin/instance/instance/components/buttons/ResendAllInvitationsButton.tsx b/client/app/bundles/system/admin/instance/instance/components/buttons/ResendAllInvitationsButton.tsx
index 3792b9f024a..a598e22de5f 100644
--- a/client/app/bundles/system/admin/instance/instance/components/buttons/ResendAllInvitationsButton.tsx
+++ b/client/app/bundles/system/admin/instance/instance/components/buttons/ResendAllInvitationsButton.tsx
@@ -1,9 +1,9 @@
import { FC, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { LoadingButton } from '@mui/lab';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { resendAllInvitations } from '../../operations';
diff --git a/client/app/bundles/system/admin/instance/instance/components/buttons/UsersButtons.tsx b/client/app/bundles/system/admin/instance/instance/components/buttons/UsersButtons.tsx
index 01ceee9793d..d12289dc1fa 100644
--- a/client/app/bundles/system/admin/instance/instance/components/buttons/UsersButtons.tsx
+++ b/client/app/bundles/system/admin/instance/instance/components/buttons/UsersButtons.tsx
@@ -1,12 +1,12 @@
import { FC, memo, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import equal from 'fast-deep-equal';
import { InstanceUserMiniEntity } from 'types/system/instance/users';
import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import { USER_ROLES } from 'lib/constants/sharedConstants';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { deleteUser } from '../../operations';
diff --git a/client/app/bundles/system/admin/instance/instance/components/forms/IndividualInviteForm.tsx b/client/app/bundles/system/admin/instance/instance/components/forms/IndividualInviteForm.tsx
index 81b1c3782e1..8e664f922eb 100644
--- a/client/app/bundles/system/admin/instance/instance/components/forms/IndividualInviteForm.tsx
+++ b/client/app/bundles/system/admin/instance/instance/components/forms/IndividualInviteForm.tsx
@@ -1,7 +1,6 @@
import { FC, useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { yupResolver } from '@hookform/resolvers/yup';
import {
IndividualInvites,
@@ -12,6 +11,7 @@ import * as yup from 'yup';
import ErrorText from 'lib/components/core/ErrorText';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import formTranslations from 'lib/translations/form';
import messagesTranslations from 'lib/translations/messages';
diff --git a/client/app/bundles/system/admin/instance/instance/components/forms/InstanceUserRoleRequestForm.tsx b/client/app/bundles/system/admin/instance/instance/components/forms/InstanceUserRoleRequestForm.tsx
index 59dfd800e1a..f6e9b998f7a 100644
--- a/client/app/bundles/system/admin/instance/instance/components/forms/InstanceUserRoleRequestForm.tsx
+++ b/client/app/bundles/system/admin/instance/instance/components/forms/InstanceUserRoleRequestForm.tsx
@@ -1,7 +1,6 @@
import { FC } from 'react';
import { Controller } from 'react-hook-form';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import {
RoleRequestBasicListData,
UserRoleRequestForm,
@@ -12,6 +11,7 @@ import FormDialog from 'lib/components/form/dialog/FormDialog';
import FormTextField from 'lib/components/form/fields/TextField';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import tableTranslations from 'lib/translations/table';
diff --git a/client/app/bundles/system/admin/instance/instance/components/forms/RejectWithMessageForm.tsx b/client/app/bundles/system/admin/instance/instance/components/forms/RejectWithMessageForm.tsx
index 40b1dcdf61f..bbf530521bc 100644
--- a/client/app/bundles/system/admin/instance/instance/components/forms/RejectWithMessageForm.tsx
+++ b/client/app/bundles/system/admin/instance/instance/components/forms/RejectWithMessageForm.tsx
@@ -1,13 +1,13 @@
import { FC } from 'react';
import { Controller } from 'react-hook-form';
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { TextField } from '@mui/material';
import { RoleRequestRowData } from 'types/system/instance/roleRequests';
import FormDialog from 'lib/components/form/dialog/FormDialog';
import FormTextField from 'lib/components/form/fields/TextField';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import tableTranslations from 'lib/translations/table';
diff --git a/client/app/bundles/system/admin/instance/instance/components/tables/UsersTable.tsx b/client/app/bundles/system/admin/instance/instance/components/tables/UsersTable.tsx
index 2c4e1328a5a..69919198556 100644
--- a/client/app/bundles/system/admin/instance/instance/components/tables/UsersTable.tsx
+++ b/client/app/bundles/system/admin/instance/instance/components/tables/UsersTable.tsx
@@ -1,6 +1,5 @@
import { FC, ReactElement, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import {
CircularProgress,
MenuItem,
@@ -28,6 +27,7 @@ import {
} from 'lib/constants/sharedConstants';
import rebuildObjectFromRow from 'lib/helpers/mui-datatables-helpers';
import { useAppDispatch } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import tableTranslations from 'lib/translations/table';
import { indexUsers, updateUser } from '../../operations';
diff --git a/client/app/bundles/system/admin/instance/instance/pages/InstanceAnnouncementsIndex.tsx b/client/app/bundles/system/admin/instance/instance/pages/InstanceAnnouncementsIndex.tsx
index c870fa5df8d..f5631126b04 100644
--- a/client/app/bundles/system/admin/instance/instance/pages/InstanceAnnouncementsIndex.tsx
+++ b/client/app/bundles/system/admin/instance/instance/pages/InstanceAnnouncementsIndex.tsx
@@ -1,6 +1,5 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Button } from '@mui/material';
import AnnouncementsDisplay from 'bundles/course/announcements/components/misc/AnnouncementsDisplay';
@@ -9,6 +8,7 @@ import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import Note from 'lib/components/core/Note';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import {
createAnnouncement,
diff --git a/client/app/bundles/system/admin/instance/instance/pages/InstanceComponentsIndex.tsx b/client/app/bundles/system/admin/instance/instance/pages/InstanceComponentsIndex.tsx
index 28ab532db30..fd1e8216f88 100644
--- a/client/app/bundles/system/admin/instance/instance/pages/InstanceComponentsIndex.tsx
+++ b/client/app/bundles/system/admin/instance/instance/pages/InstanceComponentsIndex.tsx
@@ -1,6 +1,5 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import {
Switch,
Table,
@@ -12,6 +11,7 @@ import {
import { ComponentData } from 'types/system/instance/components';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
+import toast from 'lib/hooks/toast';
import tableTranslations from 'lib/translations/table';
import { indexComponents, updateComponents } from '../operations';
diff --git a/client/app/bundles/system/admin/instance/instance/pages/InstanceCoursesIndex.tsx b/client/app/bundles/system/admin/instance/instance/pages/InstanceCoursesIndex.tsx
index 25eb256107b..2de35fd3bff 100644
--- a/client/app/bundles/system/admin/instance/instance/pages/InstanceCoursesIndex.tsx
+++ b/client/app/bundles/system/admin/instance/instance/pages/InstanceCoursesIndex.tsx
@@ -1,6 +1,5 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Typography } from '@mui/material';
import CoursesButtons from 'bundles/system/admin/admin/components/buttons/CoursesButtons';
@@ -10,6 +9,7 @@ import Link from 'lib/components/core/Link';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import { deleteCourse, indexCourses } from '../operations';
import { getAdminCounts, getAllCourseMiniEntities } from '../selectors';
diff --git a/client/app/bundles/system/admin/instance/instance/pages/InstanceUserRoleRequestsIndex.tsx b/client/app/bundles/system/admin/instance/instance/pages/InstanceUserRoleRequestsIndex.tsx
index 734789853f1..209ea39df2f 100644
--- a/client/app/bundles/system/admin/instance/instance/pages/InstanceUserRoleRequestsIndex.tsx
+++ b/client/app/bundles/system/admin/instance/instance/pages/InstanceUserRoleRequestsIndex.tsx
@@ -1,9 +1,9 @@
import { FC, useEffect, useMemo, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import PendingRoleRequestsButtons from '../components/buttons/PendingRoleRequestsButtons';
import InstanceUserRoleRequestsTable from '../components/tables/InstanceUserRoleRequestsTable';
diff --git a/client/app/bundles/system/admin/instance/instance/pages/InstanceUsersIndex.tsx b/client/app/bundles/system/admin/instance/instance/pages/InstanceUsersIndex.tsx
index 8725c0920e6..2d8667a24bd 100644
--- a/client/app/bundles/system/admin/instance/instance/pages/InstanceUsersIndex.tsx
+++ b/client/app/bundles/system/admin/instance/instance/pages/InstanceUsersIndex.tsx
@@ -1,6 +1,5 @@
import { FC, useEffect, useMemo, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import { Typography } from '@mui/material';
import SummaryCard from 'lib/components/core/layouts/SummaryCard';
@@ -8,6 +7,7 @@ import Link from 'lib/components/core/Link';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import UsersButtons from '../components/buttons/UsersButtons';
import InstanceUsersTabs from '../components/navigation/InstanceUsersTabs';
diff --git a/client/app/bundles/system/admin/instance/instance/pages/InstanceUsersInvitations.tsx b/client/app/bundles/system/admin/instance/instance/pages/InstanceUsersInvitations.tsx
index f88b319b043..9b1f092009e 100644
--- a/client/app/bundles/system/admin/instance/instance/pages/InstanceUsersInvitations.tsx
+++ b/client/app/bundles/system/admin/instance/instance/pages/InstanceUsersInvitations.tsx
@@ -1,9 +1,9 @@
import { FC, useEffect, useState } from 'react';
import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
-import { toast } from 'react-toastify';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
+import toast from 'lib/hooks/toast';
import PendingInvitationsButtons from '../components/buttons/PendingInvitationsButtons';
import InstanceUsersTabs from '../components/navigation/InstanceUsersTabs';
diff --git a/client/app/bundles/user/AccountSettings/AccountSettingsForm.tsx b/client/app/bundles/user/AccountSettings/AccountSettingsForm.tsx
index 1538625a1c1..4684f4ea519 100644
--- a/client/app/bundles/user/AccountSettings/AccountSettingsForm.tsx
+++ b/client/app/bundles/user/AccountSettings/AccountSettingsForm.tsx
@@ -61,7 +61,7 @@ const AccountSettingsForm = (props: AccountSettingsFormProps): JSX.Element => {
object().shape(
{
name: string().required(t(translations.nameRequired)),
- timezone: string().required(t(translations.timeZoneRequired)),
+ timeZone: string().required(t(translations.timeZoneRequired)),
locale: string().required(t(translations.localeRequired)),
currentPassword: string()
.optional()
@@ -171,7 +171,7 @@ const AccountSettingsForm = (props: AccountSettingsFormProps): JSX.Element => {
(
=> {
export const updateProfile = async (
data: Partial,
): Promise | undefined> => {
- if (!data.name && !data.timezone && !data.locale) return undefined;
+ if (!data.name && !data.timeZone && !data.locale) return undefined;
const adaptedData: ProfilePostData = {
user: {
name: data.name,
- time_zone: data.timezone,
+ time_zone: data.timeZone,
locale: data.locale,
},
};
@@ -54,7 +54,7 @@ export const updateProfile = async (
const response = await GlobalAPI.users.updateProfile(adaptedData);
return {
name: response.data.name,
- timezone: response.data.timezone,
+ timeZone: response.data.timeZone,
locale: response.data.locale,
};
} catch (error) {
@@ -159,7 +159,7 @@ export const resendConfirmationEmail = async (
url: NonNullable,
): Promise => {
try {
- await GlobalAPI.users.resendConfirmationEmail(url);
+ await GlobalAPI.users.resendConfirmationEmailByURL(url);
} catch (error) {
if (error instanceof AxiosError) throw error.response?.data?.errors;
throw error;
diff --git a/client/app/bundles/users/components/Widget.tsx b/client/app/bundles/users/components/Widget.tsx
new file mode 100644
index 00000000000..430be424f7d
--- /dev/null
+++ b/client/app/bundles/users/components/Widget.tsx
@@ -0,0 +1,47 @@
+import { ReactNode } from 'react';
+import { Typography } from '@mui/material';
+
+interface ContainerProps {
+ children?: ReactNode;
+ className?: string;
+}
+
+interface WidgetProps extends ContainerProps {
+ title: string;
+ subtitle?: string;
+}
+
+const Widget = (props: WidgetProps): JSX.Element => (
+ e.preventDefault()}
+ >
+
+ {props.title}
+
+ {props.subtitle && (
+ {props.subtitle}
+ )}
+
+
+ {props.children}
+
+);
+
+const WidgetBody = (props: ContainerProps): JSX.Element => (
+
+);
+
+const WidgetFoot = (props: ContainerProps): JSX.Element => (
+
+);
+
+export default Object.assign(Widget, { Body: WidgetBody, Foot: WidgetFoot });
diff --git a/client/app/bundles/users/components/tables/CoursesTable.tsx b/client/app/bundles/users/components/tables/CoursesTable.tsx
index 37dc18e610f..173dffa975f 100644
--- a/client/app/bundles/users/components/tables/CoursesTable.tsx
+++ b/client/app/bundles/users/components/tables/CoursesTable.tsx
@@ -52,8 +52,8 @@ const CoursesTable: FC = ({ title, courses, intl }: Props) => {
{course.title}
@@ -62,8 +62,8 @@ const CoursesTable: FC = ({ title, courses, intl }: Props) => {
{course.courseUserName}
diff --git a/client/app/bundles/users/masqueradeLoader.ts b/client/app/bundles/users/masqueradeLoader.ts
new file mode 100644
index 00000000000..c10f63133b8
--- /dev/null
+++ b/client/app/bundles/users/masqueradeLoader.ts
@@ -0,0 +1,42 @@
+import { defineMessages } from 'react-intl';
+import { LoaderFunction, redirect } from 'react-router-dom';
+
+import GlobalAPI from 'api';
+import toast from 'lib/hooks/toast';
+import { Translated } from 'lib/hooks/useTranslation';
+
+const translations = defineMessages({
+ errorMasquerading: {
+ id: 'client.users.masqueradeLoader.errorMasquerading',
+ defaultMessage: 'An error occurred while masquerading. Try again later.',
+ },
+ errorStoppingMasquerade: {
+ id: 'client.users.masqueradeLoader.errorStoppingMasquerade',
+ defaultMessage:
+ 'An error occurred while stopping masquerade. Try again later.',
+ },
+});
+
+export const masqueradeLoader: Translated =
+ (t) =>
+ async ({ request }) => {
+ try {
+ await GlobalAPI.users.masquerade(request.url);
+ return redirect('/');
+ } catch {
+ toast.error(t(translations.errorMasquerading));
+ return redirect('/');
+ }
+ };
+
+export const stopMasqueradeLoader: Translated =
+ (t) =>
+ async ({ request }) => {
+ try {
+ await GlobalAPI.users.stopMasquerade(request.url);
+ return redirect('/admin/users');
+ } catch {
+ toast.error(t(translations.errorStoppingMasquerade));
+ return redirect('/');
+ }
+ };
diff --git a/client/app/bundles/users/pages/ConfirmEmailPage.tsx b/client/app/bundles/users/pages/ConfirmEmailPage.tsx
new file mode 100644
index 00000000000..92b55664d09
--- /dev/null
+++ b/client/app/bundles/users/pages/ConfirmEmailPage.tsx
@@ -0,0 +1,77 @@
+import {
+ LoaderFunction,
+ Navigate,
+ redirect,
+ useLoaderData,
+} from 'react-router-dom';
+import { Button, Typography } from '@mui/material';
+
+import GlobalAPI from 'api';
+import Link from 'lib/components/core/Link';
+import { useEmailFromAuthPagesContext } from 'lib/containers/AuthPagesContainer';
+import toast from 'lib/hooks/toast';
+import useEffectOnce from 'lib/hooks/useEffectOnce';
+import useTranslation from 'lib/hooks/useTranslation';
+
+import Widget from '../components/Widget';
+import translations from '../translations';
+
+const ConfirmEmailPage = (): JSX.Element => {
+ const { t } = useTranslation();
+
+ const email = useLoaderData() as string;
+
+ const [, setEmail] = useEmailFromAuthPagesContext();
+
+ return (
+ {chunk} ,
+ })}
+ title={t(translations.emailConfirmed)}
+ >
+
+ setEmail(email)}
+ type="submit"
+ variant="contained"
+ >
+ {t(translations.signIn)}
+
+
+
+
+
+ {t(translations.manageAllEmailsInAccountSettings, {
+ link: (chunk) => {chunk},
+ })}
+
+
+
+ );
+};
+
+const loader: LoaderFunction = async ({ request }) => {
+ const token = new URL(request.url).searchParams.get('confirmation_token');
+ if (!token) return redirect('/users/confirmation/new');
+
+ const { data } = await GlobalAPI.users.confirmEmail(token);
+ return data.email;
+};
+
+const ConfirmEmailInvalidRedirect = (): JSX.Element => {
+ const { t } = useTranslation();
+
+ useEffectOnce(() => {
+ toast.error(t(translations.confirmEmailLinkInvalidOrExpired));
+ });
+
+ return ;
+};
+
+export default Object.assign(ConfirmEmailPage, {
+ loader,
+ InvalidRedirect: ConfirmEmailInvalidRedirect,
+});
diff --git a/client/app/bundles/users/pages/ForgotPasswordLandingPage.tsx b/client/app/bundles/users/pages/ForgotPasswordLandingPage.tsx
new file mode 100644
index 00000000000..86419a3fbbf
--- /dev/null
+++ b/client/app/bundles/users/pages/ForgotPasswordLandingPage.tsx
@@ -0,0 +1,36 @@
+import { Navigate } from 'react-router-dom';
+import { Typography } from '@mui/material';
+
+import Link from 'lib/components/core/Link';
+import { useEmailFromLocationState } from 'lib/containers/AuthPagesContainer';
+import useTranslation from 'lib/hooks/useTranslation';
+
+import Widget from '../components/Widget';
+import translations from '../translations';
+
+const ForgotPasswordLandingPage = (): JSX.Element => {
+ const { t } = useTranslation();
+
+ const email = useEmailFromLocationState();
+ if (!email) return ;
+
+ return (
+ {chunk} ,
+ })}
+ title={t(translations.checkYourEmail)}
+ >
+
+
+ {t(translations.suddenlyRememberPassword)}
+
+
+ {t(translations.signIn)}
+
+
+ );
+};
+
+export default ForgotPasswordLandingPage;
diff --git a/client/app/bundles/users/pages/ForgotPasswordPage.tsx b/client/app/bundles/users/pages/ForgotPasswordPage.tsx
new file mode 100644
index 00000000000..cef73e4fd36
--- /dev/null
+++ b/client/app/bundles/users/pages/ForgotPasswordPage.tsx
@@ -0,0 +1,117 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { LoadingButton } from '@mui/lab';
+import { Typography } from '@mui/material';
+import { AxiosError } from 'axios';
+import { ValidationError } from 'yup';
+
+import GlobalAPI from 'api';
+import TextField from 'lib/components/core/fields/TextField';
+import Link from 'lib/components/core/Link';
+import { useEmailFromAuthPagesContext } from 'lib/containers/AuthPagesContainer';
+import toast from 'lib/hooks/toast';
+import useTranslation from 'lib/hooks/useTranslation';
+
+import Widget from '../components/Widget';
+import translations from '../translations';
+import { emailValidationSchema } from '../validations';
+
+const ForgotPasswordPage = (): JSX.Element => {
+ const { t } = useTranslation();
+
+ const [email, setEmail] = useEmailFromAuthPagesContext();
+
+ const [submitting, setSubmitting] = useState(false);
+ const [errorMessage, setErrorMessage] = useState();
+
+ const navigate = useNavigate();
+
+ const handleRequestResetPassword = async (): Promise => {
+ setSubmitting(true);
+ setErrorMessage(undefined);
+
+ try {
+ const validatedEmail = await emailValidationSchema(t).validate(email);
+ if (!validatedEmail)
+ throw new Error(`validatedEmail is ${validatedEmail}`);
+
+ await GlobalAPI.users.requestResetPassword(validatedEmail);
+ navigate('completed', { state: validatedEmail });
+ } catch (error) {
+ if (error instanceof ValidationError) {
+ setErrorMessage(error.message);
+ return;
+ }
+
+ if (error instanceof AxiosError) {
+ setErrorMessage(error.response?.data?.errors?.email);
+ toast.error(t(translations.errorRequestingResetPassword));
+ return;
+ }
+
+ throw error;
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+ setEmail(e.target.value)}
+ onPressEnter={handleRequestResetPassword}
+ required
+ trims
+ type="email"
+ value={email}
+ variant="filled"
+ />
+
+
+
+ {t(translations.requestToResetPassword)}
+
+
+
+
+ {t(translations.suddenlyRememberPassword)}
+
+
+
+ {t(translations.signInAgain)}
+
+
+
+
+
+ {t(translations.dontYetHaveAnAccount)}
+
+
+
+ {t(translations.signUp)}
+
+
+
+ );
+};
+
+export default ForgotPasswordPage;
diff --git a/client/app/bundles/users/pages/ResendConfirmationEmailLandingPage.tsx b/client/app/bundles/users/pages/ResendConfirmationEmailLandingPage.tsx
new file mode 100644
index 00000000000..b77fbbdadb5
--- /dev/null
+++ b/client/app/bundles/users/pages/ResendConfirmationEmailLandingPage.tsx
@@ -0,0 +1,56 @@
+import { Navigate } from 'react-router-dom';
+import { Typography } from '@mui/material';
+
+import Link from 'lib/components/core/Link';
+import { SUPPORT_EMAIL } from 'lib/constants/sharedConstants';
+import { useEmailFromLocationState } from 'lib/containers/AuthPagesContainer';
+import useTranslation from 'lib/hooks/useTranslation';
+
+import Widget from '../components/Widget';
+import translations from '../translations';
+
+const ResendConfirmationEmailLandingPage = (): JSX.Element => {
+ const { t } = useTranslation();
+
+ const email = useEmailFromLocationState();
+ if (!email) return ;
+
+ return (
+ {chunk} ,
+ })}
+ title={t(translations.checkYourEmail)}
+ >
+
+ {t(translations.resendConfirmationEmailIfIssuePersistsContactUs, {
+ supportEmail: SUPPORT_EMAIL,
+ link: (chunk) => (
+
+ {chunk}
+
+ ),
+ })}
+
+
+
+
+ {t(translations.confirmedYourEmail)}
+
+
+ {t(translations.signIn)}
+
+
+
+
+ {t(translations.dontYetHaveAnAccount)}
+
+
+ {t(translations.signUp)}
+
+
+ );
+};
+
+export default ResendConfirmationEmailLandingPage;
diff --git a/client/app/bundles/users/pages/ResendConfirmationEmailPage.tsx b/client/app/bundles/users/pages/ResendConfirmationEmailPage.tsx
new file mode 100644
index 00000000000..e3fee0de2fc
--- /dev/null
+++ b/client/app/bundles/users/pages/ResendConfirmationEmailPage.tsx
@@ -0,0 +1,122 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { LightbulbOutlined } from '@mui/icons-material';
+import { LoadingButton } from '@mui/lab';
+import { Alert, Typography } from '@mui/material';
+import { AxiosError } from 'axios';
+import { ValidationError } from 'yup';
+
+import GlobalAPI from 'api';
+import TextField from 'lib/components/core/fields/TextField';
+import Link from 'lib/components/core/Link';
+import { useEmailFromAuthPagesContext } from 'lib/containers/AuthPagesContainer';
+import toast from 'lib/hooks/toast';
+import useTranslation from 'lib/hooks/useTranslation';
+
+import Widget from '../components/Widget';
+import translations from '../translations';
+import { emailValidationSchema } from '../validations';
+
+const ResendConfirmationEmailPage = (): JSX.Element => {
+ const { t } = useTranslation();
+
+ const [email, setEmail] = useEmailFromAuthPagesContext();
+
+ const [submitting, setSubmitting] = useState(false);
+ const [errorMessage, setErrorMessage] = useState();
+
+ const navigate = useNavigate();
+
+ const handleResendConfirmationEmail = async (): Promise => {
+ setSubmitting(true);
+ setErrorMessage(undefined);
+
+ try {
+ const validatedEmail = await emailValidationSchema(t).validate(email);
+ if (!validatedEmail)
+ throw new Error(`validatedEmail is ${validatedEmail}`);
+
+ await GlobalAPI.users.resendConfirmationEmail(validatedEmail);
+ navigate('completed', { state: validatedEmail });
+ } catch (error) {
+ if (error instanceof ValidationError) {
+ setErrorMessage(error.message);
+ return;
+ }
+
+ if (error instanceof AxiosError) {
+ setErrorMessage(error.response?.data?.errors?.email);
+ toast.error(t(translations.errorRequestingResetPassword));
+ return;
+ }
+
+ throw error;
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+ } severity="info">
+ {t(translations.checkSpamBeforeRequestNewConfirmationEmail)}
+
+
+ setEmail(e.target.value)}
+ onPressEnter={handleResendConfirmationEmail}
+ required
+ trims
+ type="email"
+ value={email}
+ variant="filled"
+ />
+
+
+
+ {t(translations.resendConfirmationEmail)}
+
+
+
+
+ {t(translations.alreadyHaveAnAccount)}
+
+
+
+ {t(translations.signIn)}
+
+
+
+
+
+ {t(translations.dontYetHaveAnAccount)}
+
+
+
+ {t(translations.signUp)}
+
+
+
+ );
+};
+
+export default ResendConfirmationEmailPage;
diff --git a/client/app/bundles/users/pages/ResetPasswordPage.tsx b/client/app/bundles/users/pages/ResetPasswordPage.tsx
new file mode 100644
index 00000000000..4c3d7801ae5
--- /dev/null
+++ b/client/app/bundles/users/pages/ResetPasswordPage.tsx
@@ -0,0 +1,192 @@
+import { useState } from 'react';
+import {
+ LoaderFunction,
+ Navigate,
+ redirect,
+ useLoaderData,
+ useNavigate,
+} from 'react-router-dom';
+import { LoadingButton } from '@mui/lab';
+import { Typography } from '@mui/material';
+import { AxiosError } from 'axios';
+import { ValidationError } from 'yup';
+
+import GlobalAPI from 'api';
+import PasswordTextField from 'lib/components/core/fields/PasswordTextField';
+import TextField from 'lib/components/core/fields/TextField';
+import Link from 'lib/components/core/Link';
+import toast from 'lib/hooks/toast';
+import useEffectOnce from 'lib/hooks/useEffectOnce';
+import useTranslation from 'lib/hooks/useTranslation';
+
+import Widget from '../components/Widget';
+import translations from '../translations';
+import { getValidationErrors, passwordValidationSchema } from '../validations';
+
+interface ResetPasswordLoaderData {
+ email: string;
+ token: string;
+}
+
+const ResetPasswordPage = (): JSX.Element => {
+ const { t } = useTranslation();
+
+ const { email, token } = useLoaderData() as ResetPasswordLoaderData;
+
+ const [password, setPassword] = useState('');
+ const [passwordConfirmation, setPasswordConfirmation] = useState('');
+ const [requirePasswordConfirmation, setRequirePasswordConfirmation] =
+ useState(true);
+
+ const [submitting, setSubmitting] = useState(false);
+ const [errors, setErrors] = useState>({});
+
+ const navigate = useNavigate();
+
+ const handleResetPassword = async (): Promise => {
+ setSubmitting(true);
+ setErrors({});
+
+ const data = { password, passwordConfirmation };
+ try {
+ const validatedData = await passwordValidationSchema(t).validate(data, {
+ abortEarly: false,
+ context: { requirePasswordConfirmation },
+ });
+
+ await GlobalAPI.users.resetPassword(token, validatedData.password);
+
+ toast.success(t(translations.passwordSuccessfullyReset));
+ navigate('/users/sign_in');
+ } catch (error) {
+ if (error instanceof ValidationError) {
+ setErrors(getValidationErrors(error));
+ return;
+ }
+
+ if (error instanceof AxiosError && error.response?.status === 422) {
+ const responseErrors = error.response.data?.errors;
+
+ if (responseErrors?.reset_password_token) {
+ toast.error(t(translations.resetPasswordLinkInvalidOrExpired));
+ navigate('/users/password/new');
+ } else {
+ toast.error(t(translations.errorResettingPassword));
+ }
+
+ return;
+ }
+
+ throw error;
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+ setPassword(e.target.value)}
+ onChangePasswordVisibility={(visible): void =>
+ setRequirePasswordConfirmation(!visible)
+ }
+ onPressEnter={handleResetPassword}
+ required
+ type="password"
+ value={password}
+ variant="filled"
+ />
+
+ {requirePasswordConfirmation && (
+ setPasswordConfirmation(e.target.value)}
+ onCopy={(e): void => e.preventDefault()}
+ onCut={(e): void => e.preventDefault()}
+ onPaste={(e): void => e.preventDefault()}
+ onPressEnter={handleResetPassword}
+ required
+ type="password"
+ value={passwordConfirmation}
+ variant="filled"
+ />
+ )}
+
+
+
+ {t(translations.resetPassword)}
+
+
+
+
+ {t(translations.suddenlyRememberPassword)}
+
+
+
+ {t(translations.signInAgain)}
+
+
+
+ );
+};
+
+const loader: LoaderFunction = async ({ request }) => {
+ const token = new URL(request.url).searchParams.get('reset_password_token');
+ if (!token) return redirect('/users/password/new');
+
+ const { data } = await GlobalAPI.users.verifyResetPasswordToken(token);
+ return { email: data.email, token } satisfies ResetPasswordLoaderData;
+};
+
+const ResetPasswordInvalidRedirect = (): JSX.Element => {
+ const { t } = useTranslation();
+
+ useEffectOnce(() => {
+ toast.error(t(translations.resetPasswordLinkInvalidOrExpired));
+ });
+
+ return ;
+};
+
+export default Object.assign(ResetPasswordPage, {
+ loader,
+ InvalidRedirect: ResetPasswordInvalidRedirect,
+});
diff --git a/client/app/bundles/users/pages/SignInPage.tsx b/client/app/bundles/users/pages/SignInPage.tsx
new file mode 100644
index 00000000000..895cc2a2c06
--- /dev/null
+++ b/client/app/bundles/users/pages/SignInPage.tsx
@@ -0,0 +1,160 @@
+import { useState } from 'react';
+import { LoadingButton } from '@mui/lab';
+import { Alert, Typography } from '@mui/material';
+import { AxiosError } from 'axios';
+import { ValidationError } from 'yup';
+
+import GlobalAPI from 'api';
+import Checkbox from 'lib/components/core/buttons/Checkbox';
+import PasswordTextField from 'lib/components/core/fields/PasswordTextField';
+import TextField from 'lib/components/core/fields/TextField';
+import Link from 'lib/components/core/Link';
+import { useEmailFromAuthPagesContext } from 'lib/containers/AuthPagesContainer';
+import { useRedirectable } from 'lib/hooks/router/redirect';
+import { useAuthenticator } from 'lib/hooks/session';
+import toast from 'lib/hooks/toast';
+import useTranslation from 'lib/hooks/useTranslation';
+
+import Widget from '../components/Widget';
+import translations from '../translations';
+import { emailValidationSchema } from '../validations';
+
+const SignInPage = (): JSX.Element => {
+ const { t } = useTranslation();
+
+ const [email, setEmail] = useEmailFromAuthPagesContext();
+
+ const [password, setPassword] = useState('');
+ const [rememberMe, setRememberMe] = useState(false);
+ const [errored, setErrored] = useState(false);
+ const [emailError, setEmailError] = useState();
+ const [submitting, setSubmitting] = useState(false);
+
+ const { redirectable, expired } = useRedirectable();
+ const { authenticate } = useAuthenticator();
+
+ const handleSignIn = async (): Promise => {
+ setSubmitting(true);
+ setErrored(false);
+ setEmailError(undefined);
+
+ try {
+ const validatedEmail = await emailValidationSchema(t).validate(email);
+ if (!validatedEmail) throw new Error('Email validation failed');
+
+ await GlobalAPI.users.signIn(validatedEmail, password, rememberMe);
+ authenticate();
+ } catch (error) {
+ if (error instanceof ValidationError) {
+ setEmailError(error.message);
+ return;
+ }
+
+ if (!(error instanceof AxiosError)) throw error;
+
+ if (error.response?.status === 401) {
+ setErrored(true);
+ toast.error(t(translations.invalidEmailOrPassword));
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+ {redirectable && (
+
+ {expired
+ ? t(translations.sessionExpiredSignInToContinue)
+ : t(translations.mustSignInToAccessPage)}
+
+ )}
+
+ setEmail(e.target.value)}
+ onPressEnter={handleSignIn}
+ trims
+ type="email"
+ value={email}
+ variant="filled"
+ />
+
+ setPassword(e.target.value)}
+ onPressEnter={handleSignIn}
+ trims
+ type="password"
+ value={password}
+ variant="filled"
+ />
+
+ setRememberMe(value)}
+ size="small"
+ value={rememberMe}
+ />
+
+
+
+ {t(translations.signIn)}
+
+
+
+
+ {t(translations.dontYetHaveAnAccount)}
+
+
+
+ {t(translations.signUp)}
+
+
+
+
+
+ {t(translations.troubleSigningIn)}
+
+
+
+
+ {t(translations.forgotPassword)}
+
+
+
+ {t(translations.resendConfirmationEmail)}
+
+
+
+
+ );
+};
+
+export default SignInPage;
diff --git a/client/app/bundles/users/pages/SignUpLandingPage.tsx b/client/app/bundles/users/pages/SignUpLandingPage.tsx
new file mode 100644
index 00000000000..8b5161f7555
--- /dev/null
+++ b/client/app/bundles/users/pages/SignUpLandingPage.tsx
@@ -0,0 +1,46 @@
+import { Navigate } from 'react-router-dom';
+import { Typography } from '@mui/material';
+
+import Link from 'lib/components/core/Link';
+import { useEmailFromLocationState } from 'lib/containers/AuthPagesContainer';
+import useTranslation from 'lib/hooks/useTranslation';
+
+import Widget from '../components/Widget';
+import translations from '../translations';
+
+const SignUpLandingPage = (): JSX.Element | null => {
+ const { t } = useTranslation();
+
+ const email = useEmailFromLocationState();
+ if (!email) return ;
+
+ return (
+ {chunk} ,
+ })}
+ title={t(translations.checkYourEmail)}
+ >
+
+
+ {t(translations.confirmedYourEmail)}
+
+
+ {t(translations.signIn)}
+
+
+
+
+ {t(translations.didntReceiveConfirmationEmail)}
+
+
+
+ {t(translations.resendConfirmationEmail)}
+
+
+
+ );
+};
+
+export default SignUpLandingPage;
diff --git a/client/app/bundles/users/pages/SignUpPage.tsx b/client/app/bundles/users/pages/SignUpPage.tsx
new file mode 100644
index 00000000000..0ea2872a932
--- /dev/null
+++ b/client/app/bundles/users/pages/SignUpPage.tsx
@@ -0,0 +1,287 @@
+import { ComponentRef, useRef, useState } from 'react';
+import { LoaderFunction, useLoaderData, useNavigate } from 'react-router-dom';
+import { LoadingButton } from '@mui/lab';
+import { Alert, Typography } from '@mui/material';
+import { AxiosError } from 'axios';
+import { InvitedSignUpData } from 'types/users';
+import { ValidationError } from 'yup';
+
+import GlobalAPI from 'api';
+import CAPTCHAField from 'lib/components/core/fields/CAPTCHAField';
+import PasswordTextField from 'lib/components/core/fields/PasswordTextField';
+import TextField from 'lib/components/core/fields/TextField';
+import Link from 'lib/components/core/Link';
+import { useEmailFromAuthPagesContext } from 'lib/containers/AuthPagesContainer';
+import { useAuthenticator } from 'lib/hooks/session';
+import toast from 'lib/hooks/toast';
+import useTranslation from 'lib/hooks/useTranslation';
+
+import Widget from '../components/Widget';
+import translations from '../translations';
+import { getValidationErrors, signUpValidationSchema } from '../validations';
+
+type InvitedSignUpLoaderData = null | (InvitedSignUpData & { token: string });
+
+const SignUpPage = (): JSX.Element => {
+ const { t } = useTranslation();
+
+ const [email, setEmail] = useEmailFromAuthPagesContext();
+
+ const invitation = useLoaderData() as InvitedSignUpLoaderData;
+ if (invitation?.email) setEmail(invitation.email);
+
+ const [name, setName] = useState(invitation?.name ?? '');
+ const [password, setPassword] = useState('');
+ const [passwordConfirmation, setPasswordConfirmation] = useState('');
+ const [requirePasswordConfirmation, setRequirePasswordConfirmation] =
+ useState(true);
+
+ const captchaRef = useRef>(null);
+ const [captchaResponse, setCaptchaResponse] = useState(null);
+
+ const [submitting, setSubmitting] = useState(false);
+ const [errors, setErrors] = useState>({});
+
+ const navigate = useNavigate();
+ const { authenticate } = useAuthenticator();
+
+ const resetCaptcha = (): void => {
+ captchaRef.current?.reset();
+ };
+
+ const handleSignUp = async (): Promise => {
+ if (!captchaResponse) {
+ setErrors({ recaptcha: t(translations.errorRecaptcha) });
+ resetCaptcha();
+ toast.error(t(translations.errorSigningUp));
+ return;
+ }
+
+ setErrors({});
+ setSubmitting(true);
+
+ const data = { name, email, password, passwordConfirmation };
+
+ try {
+ const validatedData = await signUpValidationSchema(t).validate(data, {
+ abortEarly: false,
+ context: { requirePasswordConfirmation },
+ });
+
+ const { data: result } = await GlobalAPI.users.signUp(
+ validatedData.name,
+ validatedData.email,
+ validatedData.password,
+ captchaResponse,
+ invitation?.token,
+ );
+
+ if (!result.id) {
+ toast.error(t(translations.errorSigningUp));
+ return;
+ }
+
+ if (invitation) {
+ authenticate();
+ navigate(`/courses/${invitation.courseId}`);
+ toast.success(
+ t(translations.signUpWelcomeToCourse, {
+ course: invitation.courseTitle,
+ }),
+ );
+
+ return;
+ }
+
+ if (!result.confirmed) {
+ navigate('completed', { state: validatedData.email });
+ } else {
+ navigate('/');
+ toast.success(t(translations.signUpSuccessful));
+ }
+ } catch (error) {
+ if (error instanceof ValidationError) {
+ setErrors(getValidationErrors(error));
+ return;
+ }
+
+ if (error instanceof AxiosError && error.response?.status === 422) {
+ toast.error(t(translations.errorSigningUp));
+ setErrors(error.response.data?.errors);
+ return;
+ }
+
+ throw error;
+ } finally {
+ resetCaptcha();
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+ {invitation && (
+
+ {t(translations.completeSignUpToJoinCourse, {
+ course: invitation.courseTitle,
+ strong: (chunk) => {chunk} ,
+ })}
+
+ )}
+
+ setName(e.target.value)}
+ onPressEnter={handleSignUp}
+ required
+ trims
+ type="text"
+ value={name}
+ variant="filled"
+ />
+
+ setEmail(e.target.value)}
+ onPressEnter={handleSignUp}
+ required
+ trims
+ type="email"
+ value={email}
+ variant="filled"
+ />
+
+ setPassword(e.target.value)}
+ onChangePasswordVisibility={(visible): void =>
+ setRequirePasswordConfirmation(!visible)
+ }
+ onPressEnter={handleSignUp}
+ required
+ type="password"
+ value={password}
+ variant="filled"
+ />
+
+ {requirePasswordConfirmation && (
+ setPasswordConfirmation(e.target.value)}
+ onCopy={(e): void => e.preventDefault()}
+ onCut={(e): void => e.preventDefault()}
+ onPaste={(e): void => e.preventDefault()}
+ onPressEnter={handleSignUp}
+ required
+ type="password"
+ value={passwordConfirmation}
+ variant="filled"
+ />
+ )}
+
+
+
+
+
+ {t(translations.signUp)}
+
+
+
+ {t(translations.signUpAgreement, {
+ tos: (chunk) => (
+
+ {chunk}
+
+ ),
+ pp: (chunk) => (
+
+ {chunk}
+
+ ),
+ })}
+
+
+
+
+ {t(translations.alreadyHaveAnAccount)}
+
+
+
+ {t(translations.signIn)}
+
+
+
+ );
+};
+
+const loader: LoaderFunction = async ({ request }) => {
+ const token = new URL(request.url).searchParams.get('invitation');
+ if (!token) return null;
+
+ try {
+ const { data } = await GlobalAPI.users.verifyInvitationToken(token);
+ if (!data) return null;
+
+ return { ...data, token } satisfies InvitedSignUpLoaderData;
+ } catch (error) {
+ if (error instanceof AxiosError && error.response?.status === 409)
+ toast.error(error.response.data?.message);
+
+ return null;
+ }
+};
+
+export default Object.assign(SignUpPage, { loader });
diff --git a/client/app/bundles/users/pages/UserShow.tsx b/client/app/bundles/users/pages/UserShow.tsx
index c2fdd1381d5..6ccbfaf1a6e 100644
--- a/client/app/bundles/users/pages/UserShow.tsx
+++ b/client/app/bundles/users/pages/UserShow.tsx
@@ -3,6 +3,7 @@ import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl';
import { useParams } from 'react-router-dom';
import { Avatar, Grid, Typography } from '@mui/material';
+import Page from 'lib/components/core/layouts/Page';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
@@ -63,7 +64,7 @@ const UserShow: FC = (props) => {
}
return (
- <>
+
= (props) => {
item
justifyContent={{ xs: 'center', sm: 'start' }}
>
- {user.name}
+ {user.name}
{currentCourses.length > 0 && (
@@ -112,7 +113,7 @@ const UserShow: FC = (props) => {
title={intl.formatMessage(translations.otherInstances)}
/>
)}
- >
+
);
};
diff --git a/client/app/bundles/users/translations.ts b/client/app/bundles/users/translations.ts
new file mode 100644
index 00000000000..0b9040a84f2
--- /dev/null
+++ b/client/app/bundles/users/translations.ts
@@ -0,0 +1,240 @@
+import { defineMessages } from 'react-intl';
+
+const translations = defineMessages({
+ emailAddress: {
+ id: 'users.emailAddress',
+ defaultMessage: 'Email address',
+ },
+ password: {
+ id: 'users.password',
+ defaultMessage: 'Password',
+ },
+ signInToYourAccount: {
+ id: 'users.signInToYourAccount',
+ defaultMessage: 'Sign in to Coursemology',
+ },
+ signIn: {
+ id: 'users.signIn',
+ defaultMessage: 'Sign in',
+ },
+ dontYetHaveAnAccount: {
+ id: 'users.dontYetHaveAnAccount',
+ defaultMessage: "Don't yet have an account?",
+ },
+ signUp: {
+ id: 'users.signUp',
+ defaultMessage: 'Sign up',
+ },
+ forgotPassword: {
+ id: 'users.forgotPassword',
+ defaultMessage: 'Forgot password',
+ },
+ resendConfirmationEmail: {
+ id: 'users.resendConfirmationEmail',
+ defaultMessage: 'Resend confirmation email',
+ },
+ troubleSigningIn: {
+ id: 'users.troubleSigningIn',
+ defaultMessage: 'Trouble signing in?',
+ },
+ alreadyHaveAnAccount: {
+ id: 'users.alreadyHaveAnAccount',
+ defaultMessage: 'Already have an account?',
+ },
+ createAnAccount: {
+ id: 'users.createAnAccount',
+ defaultMessage: 'Create a new account',
+ },
+ createAnAccountSubtitle: {
+ id: 'users.createAnAccountSubtitle',
+ defaultMessage:
+ 'Join students and teachers in a universe of fun online education!',
+ },
+ name: {
+ id: 'users.name',
+ defaultMessage: 'Name',
+ },
+ confirmPassword: {
+ id: 'users.confirmPassword',
+ defaultMessage: 'Confirm password',
+ },
+ rememberMe: {
+ id: 'users.rememberMe',
+ defaultMessage: 'Remember me on this device',
+ },
+ rememberMeHint: {
+ id: 'users.rememberMeHint',
+ defaultMessage: 'Only use this on your personal devices.',
+ },
+ signUpAgreement: {
+ id: 'users.signUpAgreement',
+ defaultMessage:
+ 'By signing up, you agree to our Terms of Service and that you have read our Privacy Policy .',
+ },
+ requestToResetPassword: {
+ id: 'users.requestToResetPassword',
+ defaultMessage: 'Request to reset password',
+ },
+ forgotPasswordSubtitle: {
+ id: 'users.forgotPasswordSubtitle',
+ defaultMessage:
+ 'Recover access to your account by resetting your password.',
+ },
+ suddenlyRememberPassword: {
+ id: 'users.suddenlyRememberPassword',
+ defaultMessage: 'Suddenly remembered?',
+ },
+ signInAgain: {
+ id: 'users.signInAgain',
+ defaultMessage: 'Try signing in again',
+ },
+ resetPassword: {
+ id: 'users.resetPassword',
+ defaultMessage: 'Reset password',
+ },
+ resetPasswordSubtitle: {
+ id: 'users.resetPasswordSubtitle',
+ defaultMessage:
+ 'One more step: choose a new password for your account. Better remember it this time!',
+ },
+ resendConfirmationEmailSubtitle: {
+ id: 'users.resendConfirmationEmailSubtitle',
+ defaultMessage:
+ "If you have created an account but haven't received a confirmation email, you can request a new one here.",
+ },
+ checkSpamBeforeRequestNewConfirmationEmail: {
+ id: 'users.checkSpamBeforeRequestNewConfirmationEmail',
+ defaultMessage:
+ 'You may want to check your spam folder for the email before requesting a new one.',
+ },
+ invalidEmailOrPassword: {
+ id: 'users.invalidEmailOrPassword',
+ defaultMessage:
+ 'Oops, invalid email or password. Check your email or password and try again.',
+ },
+ checkYourEmail: {
+ id: 'users.checkYourEmail',
+ defaultMessage: 'Almost there; check your email!',
+ },
+ signUpCheckYourEmailSubtitle: {
+ id: 'users.signUpCheckYourEmailSubtitle',
+ defaultMessage:
+ "Your account has been created, but you'll need to confirm your email before you can use it. Follow the " +
+ "instructions we've sent to {email} to proceed.",
+ },
+ confirmedYourEmail: {
+ id: 'users.confirmedYourEmail',
+ defaultMessage: 'Confirmed your email?',
+ },
+ didntReceiveConfirmationEmail: {
+ id: 'users.didntReceiveConfirmationEmail',
+ defaultMessage: "Didn't receive the email?",
+ },
+ passwordMinCharacters: {
+ id: 'users.passwordMinCharacters',
+ defaultMessage: 'Your password must be at least 8 characters long.',
+ },
+ passwordConfirmationRequired: {
+ id: 'users.passwordConfirmationRequired',
+ defaultMessage: 'Please confirm your password here.',
+ },
+ passwordConfirmationMustMatch: {
+ id: 'users.passwordConfirmationMustMatch',
+ defaultMessage:
+ 'Your password confirmation does not match your password above.',
+ },
+ errorRecaptcha: {
+ id: 'users.errorRecaptcha',
+ defaultMessage:
+ 'There was an error with the reCAPTCHA below, please try again.',
+ },
+ errorSigningUp: {
+ id: 'users.errorSigningUp',
+ defaultMessage: 'An error occurred while creating your account.',
+ },
+ errorRequestingResetPassword: {
+ id: 'users.errorRequestingResetPassword',
+ defaultMessage: 'An error occurred while requesting a password reset.',
+ },
+ forgotPasswordCheckYourEmailSubtitle: {
+ id: 'users.forgotPasswordCheckYourEmailSubtitle',
+ defaultMessage:
+ "Follow the instructions we've sent to {email} to reset your password. " +
+ 'Until then, you can still use your old password if you still remember it.',
+ },
+ errorResendConfirmationEmail: {
+ id: 'users.errorResendConfirmationEmail',
+ defaultMessage:
+ 'An error occurred while requesting to resend confirmation email.',
+ },
+ resendConfirmationEmailCheckYourEmailSubtitle: {
+ id: 'users.resendConfirmationEmailCheckYourEmailSubtitle',
+ defaultMessage:
+ "Follow the instructions we've sent to {email} to confirm your email. " +
+ 'Remember to check your spam folder before requesting another one.',
+ },
+ resendConfirmationEmailIfIssuePersistsContactUs: {
+ id: 'users.resendConfirmationEmailIfIssuePersistsContactUs',
+ defaultMessage:
+ "If you still consistently don't receive the email, please contact us at {supportEmail}.",
+ },
+ resetPasswordLinkInvalidOrExpired: {
+ id: 'users.resetPasswordTokenInvalidOrExpired',
+ defaultMessage:
+ "The password reset link you've used is either has expired or is invalid. Please use the correct one from " +
+ 'your email or request to reset again.',
+ },
+ errorResettingPassword: {
+ id: 'users.errorResettingPassword',
+ defaultMessage: 'An error occurred while resetting your password.',
+ },
+ passwordSuccessfullyReset: {
+ id: 'users.passwordSuccessfullyReset',
+ defaultMessage:
+ 'Your password was successfully reset. You may now sign in with your new password.',
+ },
+ confirmEmailLinkInvalidOrExpired: {
+ id: 'users.confirmEmailLinkInvalidOrExpired',
+ defaultMessage:
+ "The email confirmation link you've used is either has expired or is invalid. Please use the correct one from " +
+ 'your email or request to resend another confirmation email.',
+ },
+ emailConfirmed: {
+ id: 'users.emailConfirmed',
+ defaultMessage: 'Email has been confirmed!',
+ },
+ emailConfirmedSubtitle: {
+ id: 'users.emailConfirmedSubtitle',
+ defaultMessage:
+ 'You can now sign in to your account with {email} .',
+ },
+ manageAllEmailsInAccountSettings: {
+ id: 'users.manageAllEmailsInAccountSettings',
+ defaultMessage:
+ 'Manage all your email addresses in Account Settings.',
+ },
+ completeSignUpToJoinCourse: {
+ id: 'users.completeSignUpToJoinCourse',
+ defaultMessage:
+ 'Almost there! Complete your sign up to join {course} .',
+ },
+ signUpWelcomeToCourse: {
+ id: 'users.signUpWelcomeToCourse',
+ defaultMessage: 'Welcome to {course}!',
+ },
+ signUpSuccessful: {
+ id: 'users.signUpSuccessful',
+ defaultMessage: 'Your account was successfully created.',
+ },
+ mustSignInToAccessPage: {
+ id: 'users.mustSignInToAccessPage',
+ defaultMessage: "You'll need to sign in to access this page.",
+ },
+ sessionExpiredSignInToContinue: {
+ id: 'users.sessionExpiredSignInToContinue',
+ defaultMessage:
+ 'Your session has expired. Please sign in again to continue.',
+ },
+});
+
+export default translations;
diff --git a/client/app/bundles/users/validations.ts b/client/app/bundles/users/validations.ts
new file mode 100644
index 00000000000..96e81b1f55a
--- /dev/null
+++ b/client/app/bundles/users/validations.ts
@@ -0,0 +1,53 @@
+import {
+ AnyObjectSchema,
+ object,
+ ref,
+ string,
+ StringSchema,
+ ValidationError,
+} from 'yup';
+
+import { Translated } from 'lib/hooks/useTranslation';
+import formTranslations from 'lib/translations/form';
+
+import translations from './translations';
+
+export const emailValidationSchema: Translated = (t) =>
+ string()
+ .email(t(formTranslations.email))
+ .required(t(formTranslations.required));
+
+const passwordValidationSchemaObject: Translated<
+ Record
+> = (t) => ({
+ password: string()
+ .required(t(formTranslations.required))
+ .min(8, t(translations.passwordMinCharacters)),
+ passwordConfirmation: string().when('$requirePasswordConfirmation', {
+ is: true,
+ then: string()
+ .equals([ref('password')], t(translations.passwordConfirmationMustMatch))
+ .required(t(translations.passwordConfirmationRequired)),
+ otherwise: string().optional(),
+ }),
+});
+
+export const passwordValidationSchema: Translated = (t) =>
+ object(passwordValidationSchemaObject(t));
+
+export const signUpValidationSchema: Translated = (t) =>
+ object({
+ name: string().required(t(formTranslations.required)),
+ email: emailValidationSchema(t),
+ ...passwordValidationSchemaObject(t),
+ });
+
+export const getValidationErrors = (
+ errors: ValidationError,
+): Record =>
+ errors.inner.reduce>((result, { path, message }) => {
+ if (!path) return result;
+
+ result[path] = message;
+ return result;
+ }, {});
diff --git a/client/app/declaration.d.ts b/client/app/declaration.d.ts
index b816d455761..98cb0aa0378 100644
--- a/client/app/declaration.d.ts
+++ b/client/app/declaration.d.ts
@@ -17,3 +17,12 @@ declare module '*.svg?url' {
declare const FIRST_BUILD_YEAR: string;
declare const LATEST_BUILD_YEAR: string;
+
+declare module '*.md' {
+ const markdown: string;
+ export default markdown;
+}
+
+interface Window {
+ _CSRF_TOKEN?: string;
+}
diff --git a/client/app/index.tsx b/client/app/index.tsx
index 68dad386232..4f9a06b92d1 100644
--- a/client/app/index.tsx
+++ b/client/app/index.tsx
@@ -6,15 +6,10 @@ import './initializers';
import App from './App';
import 'theme/index.css';
-$(() => {
- const node = document.getElementById('app-root');
- if (!node) return;
+const root = createRoot(document.getElementById('root') as HTMLElement);
- const root = createRoot(node);
-
- root.render(
-
-
- ,
- );
-});
+root.render(
+
+
+ ,
+);
diff --git a/client/app/initializers.js b/client/app/initializers.js
index 013e4c8294c..eb9d60a4816 100644
--- a/client/app/initializers.js
+++ b/client/app/initializers.js
@@ -1,10 +1,6 @@
/* eslint-disable global-require */
function loadModules() {
- if (module.hot) {
- module.hot.accept();
- }
- // Initializers
require('lib/initializers/ace-editor');
// Require web font last so that it doesn't block the load of current module.
require('lib/initializers/webfont');
diff --git a/client/app/lib/axios.js b/client/app/lib/axios.js
deleted file mode 100644
index 3aa2d4665ef..00000000000
--- a/client/app/lib/axios.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import originAxios from 'axios';
-
-import { csrfToken } from 'lib/helpers/server-context';
-
-const headers = { Accept: 'application/json', 'X-CSRF-Token': csrfToken };
-const params = { format: 'json' };
-
-const axios = originAxios.create({ headers, params });
-export default axios;
diff --git a/client/app/lib/components/core/AvatarSelector.tsx b/client/app/lib/components/core/AvatarSelector.tsx
index 8c19004f513..e9ddfc7e7ba 100644
--- a/client/app/lib/components/core/AvatarSelector.tsx
+++ b/client/app/lib/components/core/AvatarSelector.tsx
@@ -1,11 +1,11 @@
import { ChangeEventHandler, useState } from 'react';
-import { toast } from 'react-toastify';
import { Create } from '@mui/icons-material';
import { Avatar, Button } from '@mui/material';
import translations from 'bundles/user/translations';
import ImageCropDialog from 'lib/components/core/dialogs/ImageCropDialog';
import Subsection from 'lib/components/core/layouts/Subsection';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import messagesTranslations from 'lib/translations/messages';
diff --git a/client/app/lib/components/core/DescriptionCard.tsx b/client/app/lib/components/core/DescriptionCard.tsx
index b856e95d099..e1d978ad512 100644
--- a/client/app/lib/components/core/DescriptionCard.tsx
+++ b/client/app/lib/components/core/DescriptionCard.tsx
@@ -22,15 +22,18 @@ const DescriptionCard: FC = (props) => {
return (
-
-
-
-
+
+
+
+
);
diff --git a/client/app/lib/components/core/ErrorCard.jsx b/client/app/lib/components/core/ErrorCard.jsx
index 4e3d196bf7a..7a355995eb6 100644
--- a/client/app/lib/components/core/ErrorCard.jsx
+++ b/client/app/lib/components/core/ErrorCard.jsx
@@ -1,4 +1,4 @@
-import { Card, CardContent, CardHeader } from '@mui/material';
+import { Card, CardContent, CardHeader, Typography } from '@mui/material';
import { red } from '@mui/material/colors';
import PropTypes from 'prop-types';
@@ -26,7 +26,9 @@ const ErrorCard = ({ message, cardStyles, headerStyles, messageStyles }) => (
title="Error"
titleTypographyProps={{ variant: 'body2', style: styles.headerTitle }}
/>
- {message}
+
+ {message}
+
);
diff --git a/client/app/lib/components/core/Link.tsx b/client/app/lib/components/core/Link.tsx
index b9efd93ab87..4f1a21fa845 100644
--- a/client/app/lib/components/core/Link.tsx
+++ b/client/app/lib/components/core/Link.tsx
@@ -8,25 +8,34 @@ interface LinkProps extends ComponentProps {
reloads?: boolean;
opensInNewTab?: boolean;
external?: boolean;
+ disabled?: boolean;
}
type LinkRef = HTMLAnchorElement;
const Link = forwardRef((props, ref): JSX.Element => {
- const { opensInNewTab, external, to: route, reloads, ...linkProps } = props;
+ const {
+ opensInNewTab,
+ external,
+ to: route,
+ reloads,
+ disabled,
+ ...linkProps
+ } = props;
const children = (
<>
{props.children}
- {external && }
+ {external && }
>
);
- if (!route && !props.href && !props.onClick)
+ if (disabled || (!route && !props.href && !props.onClick))
return (
{
);
};
-export default LoadingIndicator;
+interface DelayedLoadingIndicatorProps extends LoadingIndicatorProps {
+ delayedForMS: number;
+}
+const DelayedLoadingIndicator: FC = (props) => {
+ const { delayedForMS, ...otherProps } = props;
+ const [isVisible, setIsVisible] = useState(false);
+
+ useEffect(() => {
+ const timeoutId = setTimeout(() => {
+ setIsVisible(true);
+ }, delayedForMS);
+
+ return () => clearTimeout(timeoutId);
+ }, []);
+
+ return isVisible ? : undefined;
+};
+
+export default Object.assign(LoadingIndicator, {
+ Delayed: DelayedLoadingIndicator,
+});
diff --git a/client/app/lib/components/core/PopupMenu.tsx b/client/app/lib/components/core/PopupMenu.tsx
index 389717615a0..eb3240b234f 100644
--- a/client/app/lib/components/core/PopupMenu.tsx
+++ b/client/app/lib/components/core/PopupMenu.tsx
@@ -43,7 +43,7 @@ const PopupMenu = (props: PopupMenuProps): JSX.Element => {
anchorEl={anchorEl}
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
classes={{
- paper: 'max-w-[50rem] sm:max-w-full rounded-xl shadow-lg mr-[1.6rem]',
+ paper: 'max-w-[50rem] sm:max-w-full rounded-xl shadow-lg',
}}
onClose={onClose}
open={Boolean(anchorEl)}
@@ -59,14 +59,16 @@ const PopupMenu = (props: PopupMenuProps): JSX.Element => {
interface PopupMenuButtonProps {
onClick?: () => void;
- to?: string;
+ linkProps?: ComponentProps;
children?: ReactNode;
textProps?: ComponentProps;
disabled?: boolean;
+ secondary?: ReactNode;
+ secondaryAction?: ReactNode;
}
const PopupMenuButton = (props: PopupMenuButtonProps): JSX.Element => {
- const { to: href } = props;
+ const { linkProps } = props;
const { close } = useContext(PopupMenuContext);
@@ -76,17 +78,25 @@ const PopupMenuButton = (props: PopupMenuButtonProps): JSX.Element => {
};
const button = (
-
-
-
+
+
+
{props.children}
);
- return href && !props.disabled ? (
-
+ return linkProps && !props.disabled ? (
+
{button}
) : (
diff --git a/client/app/lib/components/core/buttons/MasqueradeButton.tsx b/client/app/lib/components/core/buttons/MasqueradeButton.tsx
index cf0d4c8d341..16cc657f01e 100644
--- a/client/app/lib/components/core/buttons/MasqueradeButton.tsx
+++ b/client/app/lib/components/core/buttons/MasqueradeButton.tsx
@@ -2,6 +2,7 @@ import { defineMessages } from 'react-intl';
import TheaterComedy from '@mui/icons-material/TheaterComedy';
import { IconButton, IconButtonProps, Tooltip } from '@mui/material';
+import Link from 'lib/components/core/Link';
import useTranslation from 'lib/hooks/useTranslation';
interface Props extends IconButtonProps {
@@ -22,8 +23,10 @@ const translations = defineMessages({
});
const MasqueradeButton = (props: Props): JSX.Element => {
- const { canMasquerade, ...otherProps } = props;
+ const { canMasquerade, href, ...otherProps } = props;
+
const { t } = useTranslation();
+
return (
{
: t(translations.masqueradeDisabledTooltip)
}
>
-
+
-
+
);
};
diff --git a/client/app/lib/components/core/dialogs/ConfirmationDialog.jsx b/client/app/lib/components/core/dialogs/ConfirmationDialog.jsx
index 7ec0e585282..c3139ed2dec 100644
--- a/client/app/lib/components/core/dialogs/ConfirmationDialog.jsx
+++ b/client/app/lib/components/core/dialogs/ConfirmationDialog.jsx
@@ -1,7 +1,13 @@
import { Component } from 'react';
import { injectIntl } from 'react-intl';
import { LoadingButton } from '@mui/lab';
-import { Button, Dialog, DialogActions, DialogContent } from '@mui/material';
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ Typography,
+} from '@mui/material';
import PropTypes from 'prop-types';
import formTranslations from 'lib/translations/form';
@@ -125,7 +131,9 @@ class ConfirmationDialog extends Component {
open={open}
style={{ zIndex: 9999 }}
>
- {confirmationMessage}
+
+ {confirmationMessage}
+
{actions}
);
diff --git a/client/app/lib/components/core/fields/CAPTCHAField.tsx b/client/app/lib/components/core/fields/CAPTCHAField.tsx
new file mode 100644
index 00000000000..017e31ad7fc
--- /dev/null
+++ b/client/app/lib/components/core/fields/CAPTCHAField.tsx
@@ -0,0 +1,43 @@
+import { forwardRef, useImperativeHandle, useRef } from 'react';
+import ReCAPTCHA from 'react-google-recaptcha';
+import { FormHelperText } from '@mui/material';
+
+interface CAPTCHAFieldProps {
+ error: boolean;
+ helperText: string;
+ onChange?: (value: string | null) => void;
+}
+
+interface CAPTCHAFieldRef {
+ reset: () => void;
+}
+
+const SITEKEY = process.env.GOOGLE_RECAPTCHA_SITE_KEY;
+
+const CAPTCHAField = forwardRef(
+ (props, ref): JSX.Element => {
+ const { error, helperText, onChange } = props;
+ const captchaRef = useRef(null);
+ useImperativeHandle(ref, () => ({
+ reset: (): void => {
+ captchaRef.current?.reset();
+ onChange?.(null);
+ },
+ }));
+
+ if (!SITEKEY) throw new Error('GOOGLE_RECAPTCHA_SITE_KEY is not set');
+
+ return (
+ <>
+ {helperText && (
+ {helperText}
+ )}
+
+ >
+ );
+ },
+);
+
+CAPTCHAField.displayName = 'CAPTCHAField';
+
+export default CAPTCHAField;
diff --git a/client/app/lib/components/core/fields/CKEditorRichText.tsx b/client/app/lib/components/core/fields/CKEditorRichText.tsx
index a7005ba9f48..d8ccd1e6def 100644
--- a/client/app/lib/components/core/fields/CKEditorRichText.tsx
+++ b/client/app/lib/components/core/fields/CKEditorRichText.tsx
@@ -5,7 +5,7 @@ import { CKEditor } from '@ckeditor/ckeditor5-react';
import { FormHelperText, InputLabel } from '@mui/material';
import { cyan } from '@mui/material/colors';
-import axios from 'lib/axios';
+import attachmentsAPI from 'api/Attachments';
import './CKEditor.css';
@@ -28,13 +28,9 @@ const uploadAdapter = (loader) => {
return {
upload: () =>
new Promise((resolve, reject) => {
- const formData = new FormData();
-
- loader.file.then((file) => {
- formData.append('file', file);
- formData.append('name', file.name);
- axios
- .post('/attachments', formData)
+ loader.file.then((file: File) => {
+ attachmentsAPI
+ .create(file)
.then((response) => response.data)
.then((data) => {
if (data.success) {
diff --git a/client/app/lib/components/core/fields/SearchField.tsx b/client/app/lib/components/core/fields/SearchField.tsx
index ac67041121e..6ff8d14881f 100644
--- a/client/app/lib/components/core/fields/SearchField.tsx
+++ b/client/app/lib/components/core/fields/SearchField.tsx
@@ -57,7 +57,7 @@ const SearchField = (props: SearchFieldProps): JSX.Element => {
{isPending && }
{keyword && (
-
+
)}
diff --git a/client/app/lib/components/core/fields/TextField.tsx b/client/app/lib/components/core/fields/TextField.tsx
index b965f390496..ed9b4ed85e3 100644
--- a/client/app/lib/components/core/fields/TextField.tsx
+++ b/client/app/lib/components/core/fields/TextField.tsx
@@ -44,6 +44,8 @@ const TextField = forwardRef(
e.preventDefault();
onPressEscape();
}
+
+ return props.onKeyDown?.(e);
};
return (
diff --git a/client/app/lib/components/core/layouts/Accordion.tsx b/client/app/lib/components/core/layouts/Accordion.tsx
index 083b6a56705..0491efde56a 100644
--- a/client/app/lib/components/core/layouts/Accordion.tsx
+++ b/client/app/lib/components/core/layouts/Accordion.tsx
@@ -12,10 +12,12 @@ interface AccordionProps extends ComponentProps {
children: NonNullable;
subtitle?: string;
disabled?: boolean;
+ icon?: ReactNode;
}
const Accordion = (props: AccordionProps): JSX.Element => {
- const { title, children, subtitle, disabled, ...accordionProps } = props;
+ const { title, children, subtitle, disabled, icon, ...accordionProps } =
+ props;
return (
{
}}
>
}
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ expandIcon={icon || }
>
{title}
diff --git a/client/app/lib/components/core/layouts/Banner.tsx b/client/app/lib/components/core/layouts/Banner.tsx
new file mode 100644
index 00000000000..2b079e731c6
--- /dev/null
+++ b/client/app/lib/components/core/layouts/Banner.tsx
@@ -0,0 +1,29 @@
+import { ReactNode } from 'react';
+import { Typography } from '@mui/material';
+
+interface BannerProps {
+ children: ReactNode;
+ className?: string;
+ icon?: ReactNode;
+ actions?: ReactNode;
+}
+
+const Banner = (props: BannerProps): JSX.Element => {
+ return (
+
+
+ {props.icon}
+
+ {props.children}
+
+
+
{props.actions}
+
+ );
+};
+
+export default Banner;
diff --git a/client/app/lib/components/core/layouts/ContactableErrorAlert.tsx b/client/app/lib/components/core/layouts/ContactableErrorAlert.tsx
index f63773e384b..72ea7ad01ea 100644
--- a/client/app/lib/components/core/layouts/ContactableErrorAlert.tsx
+++ b/client/app/lib/components/core/layouts/ContactableErrorAlert.tsx
@@ -1,8 +1,9 @@
import { defineMessages } from 'react-intl';
-import { toast } from 'react-toastify';
import { Alert, AlertProps, Typography } from '@mui/material';
+import { getMailtoURLWithBody } from 'utilities';
import Link from 'lib/components/core/Link';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
const translations = defineMessages({
@@ -30,12 +31,6 @@ const translations = defineMessages({
},
});
-const getMailtoURL = (email: string, subject: string, body: string): string => {
- const encodedSubject = encodeURIComponent(subject);
- const encodedBody = encodeURIComponent(body);
- return `mailto:${email}?subject=${encodedSubject}&body=${encodedBody}`;
-};
-
interface ContactableErrorAlertProps extends AlertProps {
children: string;
supportEmail: string;
@@ -56,7 +51,7 @@ const ContactableErrorAlert = (
const { t } = useTranslation();
- const emailURL = getMailtoURL(supportEmail, emailSubject, emailBody);
+ const emailURL = getMailtoURLWithBody(supportEmail, emailSubject, emailBody);
const copyEmailBodyToClipboard = (): Promise =>
toast.promise(navigator.clipboard.writeText(emailBody), {
diff --git a/client/app/lib/components/core/layouts/ContextualErrorPage.tsx b/client/app/lib/components/core/layouts/ContextualErrorPage.tsx
new file mode 100644
index 00000000000..f57dc3ddc6f
--- /dev/null
+++ b/client/app/lib/components/core/layouts/ContextualErrorPage.tsx
@@ -0,0 +1,139 @@
+import { ErrorInfo, ReactNode } from 'react';
+import errorIllustration from 'assets/error-illustration.svg?url';
+import { AxiosError } from 'axios';
+import { getMailtoURLWithBody } from 'utilities';
+
+import { SUPPORT_EMAIL } from 'lib/constants/sharedConstants';
+
+interface Message {
+ title: string;
+ subtitle: string;
+}
+
+const messages: Record = {
+ 413: {
+ title: 'Request Entity Too Large (413)',
+ subtitle:
+ 'The size of your attachment or request body is too large. The request size limit is 1 GB.',
+ },
+ 422: {
+ title: 'Unprocessable Entity (422)',
+ subtitle:
+ "The requested change was rejected. Maybe you tried to change something you didn't have access to?",
+ },
+ 500: {
+ title: 'Internal Server Error (500)',
+ subtitle: 'Something went wrong when processing the request in the server.',
+ },
+ 504: {
+ title: 'Gateway Timeout (504)',
+ subtitle:
+ 'The gateway or proxy could not contact the upstream server. The server could be undergoing maintenance. Try again later.',
+ },
+};
+
+interface UnrecoverableErrorPageProps {
+ from: Error | null;
+ stack: ErrorInfo | null;
+ children: JSX.Element;
+}
+
+interface BareLinkProps {
+ href: string;
+ children: ReactNode;
+}
+
+const BareLink = (props: BareLinkProps): JSX.Element => (
+
+ {props.children}
+
+);
+
+const BareFooter = (): JSX.Element => (
+
+
+ Graphic of earth is created by
+ Storyset
+ from
+ www.storyset.com , with
+ modifications.
+
+ Graphic of a fire ball is created by
+ Storyset
+ from
+ www.storyset.com , with
+ modifications.
+
+ © {FIRST_BUILD_YEAR}–{LATEST_BUILD_YEAR} Coursemology.
+
+
+);
+
+const getStackMessage = (error: string, component?: string): string => {
+ let message = `Page URL:\n${window.location.href}\n`;
+ message += `\nError Stack:\n${error}`;
+ if (component) message += `\n\nComponent Stack:${component}`;
+ return message;
+};
+
+/**
+ * Renders a contextual network error page, if explicitly defined. Otherwise, renders `children` as a fallback.
+ *
+ * This page is designed to be friendly for only some known network errors, and should not be used as a generic error
+ * page because it is uninformative.
+ *
+ * Keep the amount of third-party dependencies in this component at a minimum. This component is used by `ErrorBoundary`,
+ * so it shouldn't consume any providers or some fancy hook mechanisms.
+ */
+const ContextualErrorPage = (
+ props: UnrecoverableErrorPageProps,
+): JSX.Element => {
+ const { from: error, stack } = props;
+
+ if (!(error instanceof AxiosError)) return props.children;
+
+ const status = error.response?.status;
+ const message = status && messages[status];
+
+ if (!message) return props.children;
+
+ const emailURL = getMailtoURLWithBody(
+ SUPPORT_EMAIL,
+ message.title,
+ getStackMessage(error.stack ?? '', stack?.componentStack),
+ );
+
+ return (
+
+
+
+
+ {message.title}
+
+
+ {message.subtitle}
+
+
+
+ Try reloading this page again. If this problem persists,
+ contact us .
+
+ If you are the application owner, check the gateway or server logs.
+
+
+
+
+
+ );
+};
+
+export default ContextualErrorPage;
diff --git a/client/app/lib/components/core/layouts/Footer.tsx b/client/app/lib/components/core/layouts/Footer.tsx
index 7bf7d55cca2..87f18facb34 100644
--- a/client/app/lib/components/core/layouts/Footer.tsx
+++ b/client/app/lib/components/core/layouts/Footer.tsx
@@ -1,7 +1,10 @@
import { defineMessages } from 'react-intl';
+import { FacebookOutlined, GitHub } from '@mui/icons-material';
import { Typography } from '@mui/material';
import Link from 'lib/components/core/Link';
+import { useAttributions } from 'lib/components/wrappers/AttributionsProvider';
+import { SUPPORT_EMAIL } from 'lib/constants/sharedConstants';
import useTranslation from 'lib/hooks/useTranslation';
const translations = defineMessages({
@@ -27,62 +30,84 @@ const translations = defineMessages({
},
copyright: {
id: 'app.Footer.copyright',
- defaultMessage:
- 'Copyright © {from}–{to} Coursemology. All rights reserved.',
+ defaultMessage: '© {from}–{to} Coursemology.',
},
});
const Footer = (): JSX.Element => {
const { t } = useTranslation();
+ const attributions = useAttributions();
+
return (
-
);
};
diff --git a/client/app/lib/components/core/layouts/MarkdownPage.tsx b/client/app/lib/components/core/layouts/MarkdownPage.tsx
new file mode 100644
index 00000000000..021d27aabac
--- /dev/null
+++ b/client/app/lib/components/core/layouts/MarkdownPage.tsx
@@ -0,0 +1,54 @@
+import { ComponentProps } from 'react';
+import ReactMarkdown from 'react-markdown';
+import { Typography } from '@mui/material';
+
+import Link from '../Link';
+
+import Page from './Page';
+
+interface MarkdownPageProps extends ComponentProps {
+ markdown: string;
+}
+
+const MarkdownPage = (props: MarkdownPageProps): JSX.Element => {
+ const { markdown, ...pageProps } = props;
+ return (
+
+ (
+
+ {children}
+
+ ),
+ h3: ({ children }) => (
+
+ {children}
+
+ ),
+ p: ({ children }) => (
+
+ {children}
+
+ ),
+ li: ({ children }) => (
+
+ {children}
+
+ ),
+ ul: ({ children }) => ,
+ a: ({ children, href }) => (
+
+ {children}
+
+ ),
+ }}
+ >
+ {markdown}
+
+
+ {props.children}
+
+ );
+};
+export default MarkdownPage;
diff --git a/client/app/lib/components/core/layouts/Page.tsx b/client/app/lib/components/core/layouts/Page.tsx
index 4ebd6acc482..d3c65be80b6 100644
--- a/client/app/lib/components/core/layouts/Page.tsx
+++ b/client/app/lib/components/core/layouts/Page.tsx
@@ -23,9 +23,9 @@ const Page = (props: PageProps): JSX.Element => {
const navigate = useNavigate();
return (
-
+ <>
{(props.title || props.actions) && (
-
+
{props.title && (
@@ -60,7 +60,7 @@ const Page = (props: PageProps): JSX.Element => {
>
{props.children}
-
+ >
);
};
diff --git a/client/app/lib/components/extensions/StackedBadges.tsx b/client/app/lib/components/extensions/StackedBadges.tsx
index f91a7d6e9c4..ea6abb338c3 100644
--- a/client/app/lib/components/extensions/StackedBadges.tsx
+++ b/client/app/lib/components/extensions/StackedBadges.tsx
@@ -36,16 +36,16 @@ const StackedBadges = (props: StackedBadgesProps): JSX.Element => (
))}
- {props.remainingCount && (
-
+ {Boolean(props.remainingCount) && (
+
(
{props.condition.description}
-
+
{editing ? (
createElement(component, {
condition: props.condition,
diff --git a/client/app/lib/components/extensions/conditions/ConditionsManager.tsx b/client/app/lib/components/extensions/conditions/ConditionsManager.tsx
index 4d0c2afeb95..b7eea88c9ba 100644
--- a/client/app/lib/components/extensions/conditions/ConditionsManager.tsx
+++ b/client/app/lib/components/extensions/conditions/ConditionsManager.tsx
@@ -5,7 +5,6 @@ import {
useRef,
useState,
} from 'react';
-import { toast } from 'react-toastify';
import { Add } from '@mui/icons-material';
import {
Button,
@@ -28,6 +27,7 @@ import {
} from 'types/course/conditions';
import Subsection from 'lib/components/core/layouts/Subsection';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import formTranslations from 'lib/translations/form';
diff --git a/client/app/lib/components/extensions/conditions/conditions/AssessmentCondition.tsx b/client/app/lib/components/extensions/conditions/conditions/AssessmentCondition.tsx
index 0bdf5fc06a0..0d9c8dae133 100644
--- a/client/app/lib/components/extensions/conditions/conditions/AssessmentCondition.tsx
+++ b/client/app/lib/components/extensions/conditions/conditions/AssessmentCondition.tsx
@@ -128,8 +128,8 @@ const AssessmentConditionForm = (
{t(translations.details)}
diff --git a/client/app/lib/components/extensions/conditions/conditions/SurveyCondition.tsx b/client/app/lib/components/extensions/conditions/conditions/SurveyCondition.tsx
index 9847351efe4..609320b56c5 100644
--- a/client/app/lib/components/extensions/conditions/conditions/SurveyCondition.tsx
+++ b/client/app/lib/components/extensions/conditions/conditions/SurveyCondition.tsx
@@ -77,8 +77,8 @@ const SurveyConditionForm = (
{t(translations.details)}
diff --git a/client/app/lib/components/form/Form.tsx b/client/app/lib/components/form/Form.tsx
index 3739a777e83..77e37f6d214 100644
--- a/client/app/lib/components/form/Form.tsx
+++ b/client/app/lib/components/form/Form.tsx
@@ -13,13 +13,13 @@ import {
useForm,
UseFormWatch,
} from 'react-hook-form';
-import { toast } from 'react-toastify';
import { yupResolver } from '@hookform/resolvers/yup';
import { Button, Slide, Typography } from '@mui/material';
import { isEmpty } from 'lodash';
import { AnyObjectSchema } from 'yup';
import { setReactHookFormError } from 'lib/helpers/react-hook-form-helper';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import translations from 'lib/translations/form';
import messagesTranslations from 'lib/translations/messages';
diff --git a/client/app/lib/components/navigation/AdminPopupMenuList.tsx b/client/app/lib/components/navigation/AdminPopupMenuList.tsx
new file mode 100644
index 00000000000..35b6476bfd3
--- /dev/null
+++ b/client/app/lib/components/navigation/AdminPopupMenuList.tsx
@@ -0,0 +1,74 @@
+import { defineMessages } from 'react-intl';
+
+import { useAppContext } from 'lib/containers/AppContainer';
+import useTranslation from 'lib/hooks/useTranslation';
+
+import PopupMenu from '../core/PopupMenu';
+
+const translations = defineMessages({
+ jobsDashboard: {
+ id: 'lib.components.navigation.AdminPopupMenuList.jobsDashboard',
+ defaultMessage: 'Jobs Dashboard',
+ },
+ siteWideAnnouncements: {
+ id: 'lib.components.navigation.AdminPopupMenuList.siteWideAnnouncements',
+ defaultMessage: 'Site-wide Announcements',
+ },
+ adminPanel: {
+ id: 'lib.components.navigation.AdminPopupMenuList.adminPanel',
+ defaultMessage: 'System Admin Panel',
+ },
+ instanceAdminPanel: {
+ id: 'lib.components.navigation.AdminPopupMenuList.instanceAdminPanel',
+ defaultMessage: 'Instance Admin Panel',
+ },
+});
+
+const AdminPopupMenuList = (): JSX.Element | null => {
+ const { t } = useTranslation();
+
+ const { user } = useAppContext();
+
+ const isSuperAdmin = user?.role === 'administrator';
+ const isInstanceAdmin = user?.instanceRole === 'administrator';
+
+ if (!(isSuperAdmin || isInstanceAdmin)) return null;
+
+ return (
+ <>
+ {isSuperAdmin && (
+ <>
+
+
+ {t(translations.adminPanel)}
+
+
+
+ {t(translations.jobsDashboard)}
+
+
+
+ {t(translations.siteWideAnnouncements)}
+
+
+
+
+ >
+ )}
+
+ {(isSuperAdmin || isInstanceAdmin) && (
+
+
+ {t(translations.instanceAdminPanel)}
+
+
+ )}
+
+
+ >
+ );
+};
+
+export default AdminPopupMenuList;
diff --git a/client/app/lib/components/navigation/BrandingHead.tsx b/client/app/lib/components/navigation/BrandingHead.tsx
index ec6cd0d116a..e85b9e23201 100644
--- a/client/app/lib/components/navigation/BrandingHead.tsx
+++ b/client/app/lib/components/navigation/BrandingHead.tsx
@@ -1,13 +1,16 @@
-import { ReactNode, useState } from 'react';
+import { ComponentRef, ReactNode, useRef, useState } from 'react';
import { defineMessages } from 'react-intl';
-import { AdminPanelSettingsOutlined, ChevronRight } from '@mui/icons-material';
-import { Avatar, IconButton, Typography } from '@mui/material';
+import { useLocation } from 'react-router-dom';
+import { ChevronRight, KeyboardArrowDown } from '@mui/icons-material';
+import { Avatar, Button, Typography } from '@mui/material';
import Link from 'lib/components/core/Link';
import PopupMenu from 'lib/components/core/PopupMenu';
import { useAppContext } from 'lib/containers/AppContainer';
import useTranslation from 'lib/hooks/useTranslation';
+import AdminPopupMenuList from './AdminPopupMenuList';
+import CourseSwitcherPopupMenu from './CourseSwitcherPopupMenu';
import UserPopupMenuList from './UserPopupMenuList';
const translations = defineMessages({
@@ -15,76 +18,35 @@ const translations = defineMessages({
id: 'app.BrandingItem.coursemology',
defaultMessage: 'Coursemology',
},
- adminPanel: {
- id: 'course.courses.BrandingItem.adminPanel',
- defaultMessage: 'System Admin Panel',
+ goToOtherCourses: {
+ id: 'app.BrandingItem.goToOtherCourses',
+ defaultMessage: 'Courses',
},
- instanceAdminPanel: {
- id: 'course.courses.BrandingItem.instanceAdminPanel',
- defaultMessage: 'Instance Admin Panel',
- },
- superuser: {
- id: 'course.courses.BrandingItem.superuser',
- defaultMessage: 'Superuser',
+ signIn: {
+ id: 'app.BrandingItem.signIn',
+ defaultMessage: 'Sign in',
},
});
interface BrandingHeadProps {
title?: string | null;
+ withoutCourseSwitcher?: boolean;
+ withoutUserMenu?: boolean;
}
-const AdminMenuButton = (): JSX.Element | null => {
- const { t } = useTranslation();
-
- const { user } = useAppContext();
-
- const isSuperAdmin = user?.role === 'administrator';
- const isInstanceAdmin = user?.instanceRole === 'administrator';
-
- const [anchorElement, setAnchorElement] = useState();
-
- if (!(isSuperAdmin || isInstanceAdmin)) return null;
-
- return (
- <>
- setAnchorElement(e.currentTarget)}
- >
-
-
-
- setAnchorElement(undefined)}
- >
-
- {isSuperAdmin && (
-
- {t(translations.adminPanel)}
-
- )}
-
- {(isSuperAdmin || isInstanceAdmin) && (
-
- {t(translations.instanceAdminPanel)}
-
- )}
-
-
- >
- );
-};
-
const Brand = (): JSX.Element => {
const { t } = useTranslation();
- // TODO: Remove `reloads` once fully SPA
return (
-
- {t(translations.coursemology)}
+
+
+ {t(translations.coursemology)}
+
);
};
@@ -92,33 +54,48 @@ const Brand = (): JSX.Element => {
const UserMenuButton = (): JSX.Element | null => {
const { user } = useAppContext();
+ const { t } = useTranslation();
+
const [anchorElement, setAnchorElement] = useState();
- if (!user) return null;
+ if (!user)
+ return (
+
+ {t(translations.signIn)}
+
+ );
return (
<>
- setAnchorElement(e.currentTarget)}
role="button"
+ src={user.avatarUrl}
tabIndex={0}
- >
-
-
-
- {user.name}
-
-
+ />
setAnchorElement(undefined)}
>
+
+
+ {user.name}
+
+
+
+ {user.primaryEmail}
+
+
+
+
+
+
+
>
@@ -126,35 +103,69 @@ const UserMenuButton = (): JSX.Element | null => {
};
const BrandingHeadContainer = (props: { children: ReactNode }): JSX.Element => (
-
+
{props.children}
);
-const BrandingHead = (props: BrandingHeadProps): JSX.Element => (
-
-
-
+const BrandingHead = (props: BrandingHeadProps): JSX.Element => {
+ const { t } = useTranslation();
+
+ const courseSwitcherRef =
+ useRef
>(null);
+
+ const location = useLocation();
+
+ const { courses } = useAppContext();
- {props.title && (
-
-
-
{props.title}
+ const shouldShowCourseSwitcher =
+ !props.withoutCourseSwitcher &&
+ (Boolean(courses?.length) || location.pathname !== '/courses');
+
+ return (
+ <>
+
+
+
+
+ {props.title && (
+
+
+ {props.title}
+
+ )}
- )}
-
-
-
-);
+
+ {shouldShowCourseSwitcher &&
+ (courses?.length ? (
+ }
+ onClick={(e): void => courseSwitcherRef.current?.open(e)}
+ >
+ {t(translations.goToOtherCourses)}
+
+ ) : (
+
+ {t(translations.goToOtherCourses)}
+
+ ))}
+
+ {!props.withoutUserMenu && }
+
+
+
+ {Boolean(courses?.length) && (
+
+ )}
+ >
+ );
+};
const MiniBrandingHead = (): JSX.Element => (
-
+
);
diff --git a/client/app/lib/components/navigation/CourseSwitcherPopupMenu.tsx b/client/app/lib/components/navigation/CourseSwitcherPopupMenu.tsx
new file mode 100644
index 00000000000..519a4ac9377
--- /dev/null
+++ b/client/app/lib/components/navigation/CourseSwitcherPopupMenu.tsx
@@ -0,0 +1,182 @@
+import {
+ forwardRef,
+ MouseEventHandler,
+ ReactNode,
+ useImperativeHandle,
+ useMemo,
+ useState,
+} from 'react';
+import { defineMessages } from 'react-intl';
+import { Typography } from '@mui/material';
+
+import SearchField from 'lib/components/core/fields/SearchField';
+import PopupMenu from 'lib/components/core/PopupMenu';
+import { useAppContext } from 'lib/containers/AppContainer';
+import { getCourseId } from 'lib/helpers/url-helpers';
+import useTranslation from 'lib/hooks/useTranslation';
+
+const translations = defineMessages({
+ thisCourse: {
+ id: 'lib.components.navigation.CourseSwitcherPopupMenu.thisCourse',
+ defaultMessage: 'This course',
+ },
+ jumpToOtherCourses: {
+ id: 'lib.components.navigation.CourseSwitcherPopupMenu.jumpToOtherCourses',
+ defaultMessage: 'Jump to your other courses',
+ },
+ searchCourses: {
+ id: 'lib.components.navigation.CourseSwitcherPopupMenu.searchCourses',
+ defaultMessage: 'Search your courses',
+ },
+ noCoursesMatch: {
+ id: 'lib.components.navigation.CourseSwitcherPopupMenu.noCoursesMatch',
+ defaultMessage: "Oops, no courses matched ''{keyword}''.",
+ },
+ seeAllPublicCourses: {
+ id: 'lib.components.navigation.CourseSwitcherPopupMenu.seeAllPublicCourses',
+ defaultMessage: 'See all public courses',
+ },
+ seeAllCoursesInAdmin: {
+ id: 'lib.components.navigation.CourseSwitcherPopupMenu.seeAllCoursesInAdmin',
+ defaultMessage: 'See all courses in Coursemology',
+ },
+ seeAllCoursesInInstanceAdmin: {
+ id: 'lib.components.navigation.CourseSwitcherPopupMenu.seeAllCoursesInInstanceAdmin',
+ defaultMessage: 'See all courses in this instance',
+ },
+ createNewCourse: {
+ id: 'lib.components.navigation.CourseSwitcherPopupMenu.createNewCourse',
+ defaultMessage: 'Create a new course',
+ },
+});
+
+interface CourseSwitcherPopupMenuProps {
+ children?: ReactNode;
+}
+
+interface CourseSwitcherPopupMenuRef {
+ open: MouseEventHandler;
+}
+
+const CourseSwitcherPopupMenu = forwardRef<
+ CourseSwitcherPopupMenuRef,
+ CourseSwitcherPopupMenuProps
+>((props, ref): JSX.Element => {
+ const { t } = useTranslation();
+
+ const { courses, user } = useAppContext();
+
+ const [anchorElement, setAnchorElement] = useState();
+
+ useImperativeHandle(ref, () => ({
+ open: (e) => setAnchorElement(e.currentTarget),
+ }));
+
+ const isSuperAdmin = user?.role === 'administrator';
+ const isInstanceAdmin = user?.instanceRole === 'administrator';
+
+ const [filterKeyword, setFilterKeyword] = useState('');
+ const [showCourseIds, setShowCourseIds] = useState(false);
+
+ const filteredCourses = useMemo(() => {
+ if (!filterKeyword) return courses;
+
+ return courses?.filter((course) =>
+ course.title.toLowerCase().includes(filterKeyword.toLowerCase()),
+ );
+ }, [filterKeyword]);
+
+ return (
+ setAnchorElement(undefined)}
+ >
+ {props.children}
+
+ {Boolean(courses?.length) && (
+ <>
+
+
+ {
+ if (e.key === 'Alt') {
+ e.preventDefault();
+ setShowCourseIds(true);
+ }
+ }}
+ onKeyUp={(): void => setShowCourseIds(false)}
+ placeholder={t(translations.searchCourses)}
+ />
+
+
+
+
+ {filteredCourses?.map((course) => (
+
+ {course.id}
+
+ )
+ }
+ textProps={{ className: 'line-clamp-2' }}
+ >
+ {course.title}
+
+ ))}
+
+ {!filteredCourses?.length && (
+
+ {t(translations.noCoursesMatch, {
+ keyword: filterKeyword,
+ })}
+
+ )}
+
+
+
+ >
+ )}
+
+
+
+ {t(translations.seeAllPublicCourses)}
+
+
+ {isSuperAdmin && (
+
+ {t(translations.seeAllCoursesInAdmin)}
+
+ )}
+
+ {(isSuperAdmin || isInstanceAdmin) && (
+
+ {t(translations.seeAllCoursesInInstanceAdmin)}
+
+ )}
+
+ {user?.canCreateNewCourse && (
+
+ {t(translations.createNewCourse)}
+
+ )}
+
+
+ );
+});
+
+CourseSwitcherPopupMenu.displayName = 'CourseSwitcherPopupMenu';
+
+export default CourseSwitcherPopupMenu;
diff --git a/client/app/lib/components/navigation/UserPopupMenuList.tsx b/client/app/lib/components/navigation/UserPopupMenuList.tsx
index a38af961c7c..83c0a92436b 100644
--- a/client/app/lib/components/navigation/UserPopupMenuList.tsx
+++ b/client/app/lib/components/navigation/UserPopupMenuList.tsx
@@ -1,41 +1,57 @@
-import { ComponentProps } from 'react';
import { defineMessages } from 'react-intl';
import GlobalAPI from 'api';
-import PopupMenu from 'lib/components/core/PopupMenu';
import { useAppContext } from 'lib/containers/AppContainer';
+import { useAuthenticator } from 'lib/hooks/session';
import useTranslation from 'lib/hooks/useTranslation';
+import PopupMenu from '../core/PopupMenu';
+
const translations = defineMessages({
accountSettings: {
- id: 'course.courses.CourseUserItem.accountSettings',
+ id: 'lib.component.navigation.UserPopupMenuList.accountSettings',
defaultMessage: 'Account settings',
},
+ accountSettingsSubtitle: {
+ id: 'lib.component.navigation.UserPopupMenuList.accountSettingsSubtitle',
+ defaultMessage: 'Language, emails, and password',
+ },
signOut: {
- id: 'course.courses.CourseUserItem.signOut',
+ id: 'lib.component.navigation.UserPopupMenuList.signOut',
defaultMessage: 'Sign out',
},
+ goToYourSiteWideProfile: {
+ id: 'lib.component.navigation.UserPopupMenuList.goToYourSiteWideProfile',
+ defaultMessage: 'Go to your site-wide profile',
+ },
});
-const UserPopupMenuList = (
- props: Pick, 'header'>,
-): JSX.Element => {
+const UserPopupMenuList = (): JSX.Element | null => {
+ const { user } = useAppContext();
+
const { t } = useTranslation();
- const { signOutUrl } = useAppContext();
+ const { deauthenticate } = useAuthenticator();
- const signOut = async (): Promise => {
- if (!signOutUrl) return;
+ if (!user) return null;
- await GlobalAPI.users.signOut(signOutUrl);
+ const signOut = async (): Promise => {
+ await GlobalAPI.users.signOut();
- // TODO: Reset Redux store and navigate via React Router once SPA.
- window.location.href = '/';
+ deauthenticate();
+ window.location.href = '/users/sign_in';
};
return (
-
-
+
+
+ {t(translations.goToYourSiteWideProfile)}
+
+
+
{t(translations.accountSettings)}
diff --git a/client/app/lib/components/wrappers/AttributionsProvider.tsx b/client/app/lib/components/wrappers/AttributionsProvider.tsx
new file mode 100644
index 00000000000..3b9e56773df
--- /dev/null
+++ b/client/app/lib/components/wrappers/AttributionsProvider.tsx
@@ -0,0 +1,54 @@
+import {
+ createContext,
+ ReactNode,
+ useContext,
+ useEffect,
+ useState,
+} from 'react';
+import { useLocation } from 'react-router-dom';
+
+interface AttributionsProviderProps {
+ children: ReactNode;
+}
+
+export interface Attribution {
+ name: string;
+ content: ReactNode;
+}
+
+export type Attributions = Attribution[];
+
+type AttributionsUpdater = (attributions: Attributions) => void;
+
+const AttributionsContext = createContext([]);
+const AttributionsSetterContext = createContext(() => {});
+
+const AttributionsProvider = (
+ props: AttributionsProviderProps,
+): JSX.Element => {
+ const [attributions, setAttributions] = useState([]);
+
+ return (
+
+
+ {props.children}
+
+
+ );
+};
+
+export const useAttributions = (): Attributions =>
+ useContext(AttributionsContext);
+
+export const useSetAttributions = (attributions?: Attributions): void => {
+ const setAttributions = useContext(AttributionsSetterContext);
+ const location = useLocation();
+
+ useEffect(() => {
+ setAttributions(attributions ?? []);
+
+ return () => setAttributions([]);
+ }, [location.pathname]);
+};
+
+export default AttributionsProvider;
diff --git a/client/app/lib/components/wrappers/ErrorBoundary.jsx b/client/app/lib/components/wrappers/ErrorBoundary.jsx
deleted file mode 100644
index f8341b77462..00000000000
--- a/client/app/lib/components/wrappers/ErrorBoundary.jsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Component } from 'react';
-import PropTypes from 'prop-types';
-
-const propTypes = {
- children: PropTypes.element.isRequired,
-};
-
-class ErrorBoundary extends Component {
- constructor(props) {
- super(props);
- this.state = { hasError: false, error: null, info: null };
- }
-
- componentDidCatch(error, info) {
- this.setState({ hasError: true });
- this.setState({ error, info });
- }
-
- render() {
- if (this.state.hasError) {
- // You can render any custom fallback UI
- return (
- <>
- Something went wrong.
- {this.state.error.toString()}
- {this.state.info.componentStack}
- >
- );
- }
- return this.props.children;
- }
-}
-
-ErrorBoundary.propTypes = propTypes;
-
-export default ErrorBoundary;
diff --git a/client/app/lib/components/wrappers/ErrorBoundary.tsx b/client/app/lib/components/wrappers/ErrorBoundary.tsx
new file mode 100644
index 00000000000..ad8fdb49b99
--- /dev/null
+++ b/client/app/lib/components/wrappers/ErrorBoundary.tsx
@@ -0,0 +1,51 @@
+import { Component, ErrorInfo, ReactNode } from 'react';
+
+import ContextualErrorPage from 'lib/components/core/layouts/ContextualErrorPage';
+
+interface ErrorBoundaryProps {
+ children: ReactNode;
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+ error: Error | null;
+ info: ErrorInfo | null;
+}
+
+class ErrorBoundary extends Component {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+
+ this.state = {
+ hasError: false,
+ error: null,
+ info: null,
+ };
+ }
+
+ override componentDidCatch(error: Error, info: ErrorInfo): void {
+ this.setState({ hasError: true, error, info });
+ }
+
+ override render(): ReactNode {
+ const { hasError, error, info } = this.state;
+
+ if (!hasError) return this.props.children;
+
+ return (
+
+
+ Something went wrong.
+ {error?.toString()}
+
+ Component Stack
+ {info?.componentStack}
+
+ {error?.stack}
+
+
+ );
+ }
+}
+
+export default ErrorBoundary;
diff --git a/client/app/lib/components/wrappers/I18nProvider.tsx b/client/app/lib/components/wrappers/I18nProvider.tsx
index 4c8f2f0d134..368c9cd044c 100644
--- a/client/app/lib/components/wrappers/I18nProvider.tsx
+++ b/client/app/lib/components/wrappers/I18nProvider.tsx
@@ -1,7 +1,12 @@
-import { ReactNode } from 'react';
+import { ReactNode, useEffect } from 'react';
import { IntlProvider } from 'react-intl';
-import { i18nLocale } from 'lib/helpers/server-context';
+import {
+ DEFAULT_LOCALE,
+ DEFAULT_TIME_ZONE,
+} from 'lib/constants/sharedConstants';
+import { useI18nConfig } from 'lib/hooks/session';
+import moment from 'lib/moment';
import translations from '../../../../build/locales/locales.json';
@@ -9,19 +14,31 @@ interface I18nProviderProps {
children: ReactNode;
}
-const I18nProvider = (props: I18nProviderProps): JSX.Element => {
- if (!i18nLocale) throw new Error(`Illegal i18nLocale: ${i18nLocale}`);
+const getLocaleWithoutRegionCode = (locale: string): string =>
+ locale.toLowerCase().split(/[_-]+/)[0];
+
+const getMessages = (locale: string): Record | undefined => {
+ const localeWithoutRegionCode = getLocaleWithoutRegionCode(locale);
- const localeWithoutRegionCode = i18nLocale.toLowerCase().split(/[_-]+/)[0];
+ return localeWithoutRegionCode !== DEFAULT_LOCALE
+ ? translations[localeWithoutRegionCode] || translations[locale]
+ : undefined;
+};
+
+const I18nProvider = (props: I18nProviderProps): JSX.Element => {
+ const { locale, timeZone } = useI18nConfig();
- let messages;
- if (localeWithoutRegionCode !== 'en') {
- messages =
- translations[localeWithoutRegionCode] || translations[i18nLocale];
- }
+ useEffect(() => {
+ moment.tz.setDefault(timeZone?.trim() || DEFAULT_TIME_ZONE);
+ }, [timeZone]);
return (
-
+
{props.children}
);
diff --git a/client/app/lib/components/wrappers/Preload.tsx b/client/app/lib/components/wrappers/Preload.tsx
index 43cd7887f92..340313d3f10 100644
--- a/client/app/lib/components/wrappers/Preload.tsx
+++ b/client/app/lib/components/wrappers/Preload.tsx
@@ -1,8 +1,8 @@
import { DependencyList, useEffect, useState } from 'react';
-import { toast } from 'react-toastify';
import { AxiosError } from 'axios';
import ErrorCard from 'lib/components/core/ErrorCard';
+import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';
import messagesTranslations from 'lib/translations/messages';
diff --git a/client/app/lib/components/wrappers/Providers.tsx b/client/app/lib/components/wrappers/Providers.tsx
index 6d61626e395..e96864a9e0d 100644
--- a/client/app/lib/components/wrappers/Providers.tsx
+++ b/client/app/lib/components/wrappers/Providers.tsx
@@ -1,5 +1,6 @@
import { ReactNode } from 'react';
+import AttributionsProvider from './AttributionsProvider';
import ErrorBoundary from './ErrorBoundary';
import I18nProvider from './I18nProvider';
import RollbarProvider from './RollbarWrapper';
@@ -17,7 +18,9 @@ const Providers = (props: ProvidersProps): JSX.Element => (
- {props.children}
+
+ {props.children}
+
diff --git a/client/app/lib/components/wrappers/ThemeProvider.tsx b/client/app/lib/components/wrappers/ThemeProvider.tsx
index e80b857720a..ab988ed7fa9 100644
--- a/client/app/lib/components/wrappers/ThemeProvider.tsx
+++ b/client/app/lib/components/wrappers/ThemeProvider.tsx
@@ -1,4 +1,6 @@
import { ReactNode } from 'react';
+import type {} from '@mui/lab/themeAugmentation';
+import { CssBaseline } from '@mui/material';
import {
createTheme,
StyledEngineProvider,
@@ -22,9 +24,6 @@ const pxToNumber = (pixels: string): number =>
parseInt(pixels.replace('px', ''), 10);
const ThemeProvider = (props: ThemeProviderProps): JSX.Element => {
- // TODO: Replace with React's createRoot once true SPA is ready
- const rootElement = document.getElementById('root');
-
const theme = createTheme({
palette,
// https://material-ui.com/customization/themes/#typography---html-font-size
@@ -32,6 +31,16 @@ const ThemeProvider = (props: ThemeProviderProps): JSX.Element => {
typography: {
htmlFontSize: 10,
fontFamily: `'Inter', 'sans-serif'`,
+ h1: undefined,
+ h2: undefined,
+ h3: {
+ fontWeight: 800,
+ letterSpacing: '-0.05em',
+ },
+ h4: {
+ fontWeight: 700,
+ letterSpacing: '-0.04em',
+ },
},
breakpoints: {
values: {
@@ -51,42 +60,34 @@ const ThemeProvider = (props: ThemeProviderProps): JSX.Element => {
MuiButton: {
defaultProps: {
disableElevation: true,
- classes: { root: 'rounded-full px-4 py-2' },
+ classes: {
+ root: 'rounded-full',
+ },
},
styleOverrides: {
root: { textTransform: 'none' },
},
},
+ MuiLoadingButton: {
+ defaultProps: {
+ disableElevation: true,
+ classes: {
+ root: 'rounded-full',
+ sizeLarge: 'px-5 py-3',
+ sizeMedium: 'px-3 py-2',
+ sizeSmall: 'min-w-[6rem] px-1 py-1',
+ },
+ },
+ },
MuiDialog: {
defaultProps: {
- container: rootElement,
PaperProps: {
className: 'rounded-2xl shadow-2xl',
},
},
},
- MuiPopover: {
- // TODO: *Must* remove once SPA is ready
- // Popover elements, e.g., Menu, MenuItem, attaches an `overflow: hidden` style to this `container` to
- // prevent scrolling and having the Popover floating senselessly in the `container`. Usually, this `container`
- // defaults to `body`. This time, we set it to `rootElement` which is NOT `body` nor the viewport. This causes
- // the senseless floating issue while the page scrolls.
- defaultProps: { container: rootElement },
- },
- MuiPopper: {
- defaultProps: { container: rootElement },
- },
MuiCard: { styleOverrides: { root: { overflow: 'visible' } } },
MuiMenuItem: { styleOverrides: { root: { height: '48px' } } },
- MuiDialogContent: {
- styleOverrides: {
- root: {
- color: 'black',
- fontSize: '16px',
- fontFamily: `'Roboto', 'sans-serif'`,
- },
- },
- },
MuiAccordionSummary: {
styleOverrides: {
root: { width: '100%' },
@@ -126,11 +127,32 @@ const ThemeProvider = (props: ThemeProviderProps): JSX.Element => {
root: { '&:last-child td, &:last-child th': { border: 0 } },
},
},
+ MuiFilledInput: {
+ defaultProps: {
+ disableUnderline: true,
+ classes: {
+ root: 'rounded-xl overflow-hidden',
+ focused: 'ring-2 ring-primary',
+ error: 'ring-2 ring-red-400',
+ },
+ },
+ },
+ MuiAlert: {
+ defaultProps: {
+ classes: {
+ // For some reasons, `Alert`s with `error` and `success` severities
+ // is shown with the faintest of colour that's almost invisible.
+ standardError: 'bg-red-100/70',
+ standardSuccess: 'bg-lime-50',
+ },
+ },
+ },
},
});
return (
+
{props.children}
);
diff --git a/client/app/lib/components/wrappers/ToastProvider.tsx b/client/app/lib/components/wrappers/ToastProvider.tsx
index ff7d8aea3ce..81a570ad825 100644
--- a/client/app/lib/components/wrappers/ToastProvider.tsx
+++ b/client/app/lib/components/wrappers/ToastProvider.tsx
@@ -1,19 +1,42 @@
import { ReactNode } from 'react';
-import { ToastContainer } from 'react-toastify';
+import { ToastContainer, TypeOptions } from 'react-toastify';
import { injectStyle } from 'react-toastify/dist/inject-style';
+import { Close } from '@mui/icons-material';
injectStyle();
+export const DEFAULT_TOAST_TIMEOUT_MS = 5000 as const;
+
interface ToastProviderProps {
children: ReactNode;
}
+const colors: Record = {
+ default: 'bg-neutral-800',
+ success: 'bg-green-600',
+ warning: 'bg-amber-600',
+ error: 'bg-red-700',
+ info: 'bg-sky-600',
+};
+
const ToastProvider = (props: ToastProviderProps): JSX.Element => {
return (
<>
{props.children}
-
+ 'flex'}
+ closeButton={ }
+ draggable={false}
+ hideProgressBar
+ position="bottom-center"
+ toastClassName={(toast): string =>
+ `relative shadow-xl rounded-lg mb-4 flex p-5 items-start justify-between ${
+ colors[toast?.type ?? 'default']
+ }`
+ }
+ />
>
);
};
diff --git a/client/app/lib/constants/sharedConstants.ts b/client/app/lib/constants/sharedConstants.ts
index 83a9da2244d..ba86304ab88 100644
--- a/client/app/lib/constants/sharedConstants.ts
+++ b/client/app/lib/constants/sharedConstants.ts
@@ -61,3 +61,11 @@ export default {
STAFF_ROLES,
AVAILABLE_LOCALES,
};
+
+export const SUPPORT_EMAIL =
+ process.env.SUPPORT_EMAIL ?? 'coursemology@gmail.com';
+
+export const DEFAULT_LOCALE = process.env.DEFAULT_LOCALE ?? 'en';
+
+export const DEFAULT_TIME_ZONE =
+ process.env.DEFAULT_TIME_ZONE ?? 'Asia/Singapore';
diff --git a/client/app/lib/containers/AppContainer/AppContainer.tsx b/client/app/lib/containers/AppContainer/AppContainer.tsx
index 896073ccbc6..1e5cd304593 100644
--- a/client/app/lib/containers/AppContainer/AppContainer.tsx
+++ b/client/app/lib/containers/AppContainer/AppContainer.tsx
@@ -1,11 +1,11 @@
-import { Outlet } from 'react-router-dom';
+import { Outlet, useRouteError } from 'react-router-dom';
import NotificationPopup from 'lib/containers/NotificationPopup';
import { loader, useAppLoader } from './AppLoader';
import GlobalAnnouncements from './GlobalAnnouncements';
-import IfRailsSaysSafeToRender from './IfRailsSaysSafeToRender';
import MasqueradeBanner from './MasqueradeBanner';
+import ServerUnreachableBanner from './ServerUnreachableBanner';
const AppContainer = (): JSX.Element => {
const data = useAppLoader();
@@ -13,6 +13,8 @@ const AppContainer = (): JSX.Element => {
return (
+ {data.serverErroring && }
+
{homeData.stopMasqueradingUrl && homeData.masqueradeUserName && (
{
)}
{Boolean(data.announcements?.length) && (
-
+
)}
-
-
-
+
);
};
-export default Object.assign(AppContainer, { loader });
+/**
+ * Rethrows the error from React Router so that `ErrorBoundary` can catch it
+ * and generate the `componentStack` from `ErrorInfo`.
+ *
+ * As of React Router 6.14.1, there is no way to get `componentStack` without
+ * `componentDidCatch` from a proper `ErrorBoundary`.
+ */
+const AppErrorBubbler = (): JSX.Element => {
+ throw useRouteError();
+};
+
+export default Object.assign(AppContainer, {
+ loader,
+ ErrorBoundary: AppErrorBubbler,
+});
diff --git a/client/app/lib/containers/AppContainer/AppLoader.ts b/client/app/lib/containers/AppContainer/AppLoader.ts
index db439035ac2..51f16c597b8 100644
--- a/client/app/lib/containers/AppContainer/AppLoader.ts
+++ b/client/app/lib/containers/AppContainer/AppLoader.ts
@@ -1,18 +1,55 @@
import { useLoaderData, useOutletContext } from 'react-router-dom';
+import { AxiosError } from 'axios';
import { AnnouncementMiniEntity } from 'types/course/announcements';
import { HomeLayoutData } from 'types/home';
import GlobalAPI from 'api';
+import {
+ DEFAULT_LOCALE,
+ DEFAULT_TIME_ZONE,
+} from 'lib/constants/sharedConstants';
+import { imperativeAuthenticator, setI18nConfig } from 'lib/hooks/session';
interface AppLoaderData {
home: HomeLayoutData;
- announcements: AnnouncementMiniEntity[];
+ announcements?: AnnouncementMiniEntity[];
+ serverErroring?: boolean;
}
-export const loader = async (): Promise => ({
- home: (await GlobalAPI.home.fetch()).data,
- announcements: (await GlobalAPI.announcements.index(true)).data.announcements,
-});
+export const loader = async (): Promise => {
+ try {
+ const { data: home } = await GlobalAPI.home.fetch();
+ const { data: announcements } = await GlobalAPI.announcements.index(true);
+
+ setI18nConfig({
+ locale: home.locale,
+ timeZone: home.timeZone ?? undefined,
+ });
+
+ if (home.user) {
+ imperativeAuthenticator.authenticate();
+ } else {
+ imperativeAuthenticator.deauthenticate();
+ }
+
+ return { home, announcements: announcements.announcements };
+ } catch (error) {
+ if (
+ error instanceof AxiosError &&
+ error.response &&
+ error.response?.status >= 500
+ )
+ return {
+ home: {
+ locale: DEFAULT_LOCALE,
+ timeZone: DEFAULT_TIME_ZONE,
+ },
+ serverErroring: true,
+ };
+
+ throw error;
+ }
+};
export const useAppLoader = (): AppLoaderData =>
useLoaderData() as AppLoaderData;
diff --git a/client/app/lib/containers/AppContainer/IfRailsSaysSafeToRender.tsx b/client/app/lib/containers/AppContainer/IfRailsSaysSafeToRender.tsx
deleted file mode 100644
index 70c2a1e0d3e..00000000000
--- a/client/app/lib/containers/AppContainer/IfRailsSaysSafeToRender.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-import { ComponentType, ReactNode, useEffect } from 'react';
-import { defineMessages } from 'react-intl';
-import {
- ArrowBack,
- ConfirmationNumberOutlined,
- DoNotDisturbOnOutlined,
- SvgIconComponent,
-} from '@mui/icons-material';
-import { Typography } from '@mui/material';
-
-import Link from 'lib/components/core/Link';
-import useTranslation from 'lib/hooks/useTranslation';
-
-const translations = defineMessages({
- accessDenied: {
- id: 'app.containers.AppContainer.AppErrorPage.accessDenied',
- defaultMessage: 'Oops, access denied!',
- },
- accessDeniedDescription: {
- id: 'app.containers.AppContainer.AppErrorPage.accessDeniedDescription',
- defaultMessage: 'You are not authorised to access this page.',
- },
- goBack: {
- id: 'app.containers.AppContainer.AppErrorPage.goBack',
- defaultMessage: 'Go back',
- },
- sessionExpired: {
- id: 'app.containers.AppContainer.AppErrorPage.sessionExpired',
- defaultMessage: "You'll need to sign in again.",
- },
- sessionExpiredDescription: {
- id: 'app.containers.AppContainer.AppErrorPage.sessionExpiredDescription',
- defaultMessage:
- "Your previous session is expired. We're redirecting you to the sign in page.",
- },
- goToSignInPage: {
- id: 'app.containers.AppContainer.AppErrorPage.goToSignInPage',
- defaultMessage: 'Go to the sign in page now',
- },
-});
-
-interface MessageBodyProps {
- Icon: SvgIconComponent;
- iconClassName?: string;
- title: string;
- description: string;
- children?: ReactNode;
-}
-
-const MessageBody = ({ Icon, ...props }: MessageBodyProps): JSX.Element => (
-
-
-
-
- {props.title}
-
- {props.description}
-
-
- {props.children}
-
-);
-
-const ForbiddenMessageBody = (): JSX.Element => {
- const { t } = useTranslation();
-
- return (
-
- {
- window.history.back();
- }}
- underline="hover"
- >
-
- {t(translations.goBack)}
-
-
- );
-};
-
-const SessionExpiredMessageBody = (): JSX.Element => {
- const { t } = useTranslation();
-
- useEffect(() => {
- const redirectTimeout = setTimeout(() => {
- window.location.href = '/users/sign_in';
- }, 2000);
-
- return () => clearTimeout(redirectTimeout);
- }, []);
-
- return (
-
-
- {t(translations.goToSignInPage)}
-
-
- );
-};
-
-const messageBodies: Record = {
- 403: ForbiddenMessageBody,
- 401: SessionExpiredMessageBody,
-};
-
-/**
- * Returns the HTTP status code from the Rails' router, if found.
- *
- * This works in unison with `default.slim.html` defining a `meta` tag with the response
- * HTTP status code as integer.
- */
-const getRailsResponseStatusCode = (): number | undefined => {
- const statusMeta = document.querySelector('meta[name="status"]');
- const content = statusMeta?.getAttribute('content');
- if (!content) return undefined;
-
- return parseInt(content, 10);
-};
-
-/**
- * Renders the `children` if Rails' router says that the page is safe to render.
- *
- * Since Rails' router still renders parts of the page when we get a 403, the mounted React
- * app will still load the React page and make a HTTP request (if needed), and would probably
- * fail. This component is a TEMPORARY workaround to prevent the (rest of the) React app from
- * rendering if Rails' deem our request NOT to be safe.
- *
- * "Safe" here means that Rails' HTML render response has an HTTP status code listed in
- * `keyof messageBodies`.
- */
-const IfRailsSaysSafeToRender = (props: {
- children: JSX.Element;
-}): JSX.Element => {
- const status = getRailsResponseStatusCode();
-
- const StatusMessageBody = status && messageBodies[status];
- if (!StatusMessageBody) return props.children;
-
- return (
-
-
-
- );
-};
-
-export default IfRailsSaysSafeToRender;
diff --git a/client/app/lib/containers/AppContainer/MasqueradeBanner.tsx b/client/app/lib/containers/AppContainer/MasqueradeBanner.tsx
index 9dd01bc390b..d5b4470468d 100644
--- a/client/app/lib/containers/AppContainer/MasqueradeBanner.tsx
+++ b/client/app/lib/containers/AppContainer/MasqueradeBanner.tsx
@@ -1,7 +1,7 @@
import { defineMessages } from 'react-intl';
import { TheaterComedy } from '@mui/icons-material';
-import { Typography } from '@mui/material';
+import Banner from 'lib/components/core/layouts/Banner';
import Link from 'lib/components/core/Link';
import useTranslation from 'lib/hooks/useTranslation';
@@ -27,28 +27,24 @@ const MasqueradeBanner = (props: MasqueradeBannerProps): JSX.Element => {
const { t } = useTranslation();
return (
-
-
-
-
-
- {t(translations.masquerading, {
- as: userName,
- strong: (chunk) => (
- {chunk}
- ),
- })}
-
-
-
-
- {t(translations.stopMasquerading)}
-
-
+
+ {t(translations.stopMasquerading)}
+
+ }
+ className="bg-fuchsia-700 text-white border-only-b-fuchsia-200"
+ icon={ }
+ >
+ {t(translations.masquerading, {
+ as: userName,
+ strong: (chunk) => {chunk} ,
+ })}
+
);
};
diff --git a/client/app/lib/containers/AppContainer/ServerUnreachableBanner.tsx b/client/app/lib/containers/AppContainer/ServerUnreachableBanner.tsx
new file mode 100644
index 00000000000..fe25684c3ac
--- /dev/null
+++ b/client/app/lib/containers/AppContainer/ServerUnreachableBanner.tsx
@@ -0,0 +1,41 @@
+import { defineMessages } from 'react-intl';
+import { AppsOutageRounded } from '@mui/icons-material';
+
+import Banner from 'lib/components/core/layouts/Banner';
+import Link from 'lib/components/core/Link';
+import useTranslation from 'lib/hooks/useTranslation';
+
+const translations = defineMessages({
+ refreshPage: {
+ id: 'lib.components.core.banners.ServerUnreachableBanner.refreshPage',
+ defaultMessage: 'Refresh page',
+ },
+ serverIsUnreachable: {
+ id: 'lib.components.core.banners.ServerUnreachableBanner.serverIsUnreachable',
+ defaultMessage: 'The server is unreachable. Some actions may not work.',
+ },
+});
+
+const ServerUnreachableBanner = (): JSX.Element => {
+ const { t } = useTranslation();
+
+ return (
+ window.location.reload()}
+ underline="hover"
+ >
+ {t(translations.refreshPage)}
+
+ }
+ className="bg-red-500 text-white"
+ icon={ }
+ >
+ {t(translations.serverIsUnreachable)}
+
+ );
+};
+
+export default ServerUnreachableBanner;
diff --git a/client/app/lib/containers/AuthPagesContainer.tsx b/client/app/lib/containers/AuthPagesContainer.tsx
new file mode 100644
index 00000000000..17bfdc1cb78
--- /dev/null
+++ b/client/app/lib/containers/AuthPagesContainer.tsx
@@ -0,0 +1,29 @@
+import { Dispatch, SetStateAction, useState } from 'react';
+import { Outlet, useLocation, useOutletContext } from 'react-router-dom';
+import { isString } from 'lodash';
+
+import Page from 'lib/components/core/layouts/Page';
+
+const AuthPagesContainer = (): JSX.Element => {
+ const emailState = useState('');
+
+ return (
+
+
+
+ );
+};
+
+export const useEmailFromAuthPagesContext = (): [
+ string,
+ Dispatch>,
+] => useOutletContext();
+
+export const useEmailFromLocationState = (): string | null => {
+ const location = useLocation();
+ const maybeEmail = location.state;
+
+ return isString(maybeEmail) ? maybeEmail.trim() : null;
+};
+
+export default AuthPagesContainer;
diff --git a/client/app/lib/containers/CourselessContainer.tsx b/client/app/lib/containers/CourselessContainer.tsx
index 79a42d3ab4d..97a1efa6503 100644
--- a/client/app/lib/containers/CourselessContainer.tsx
+++ b/client/app/lib/containers/CourselessContainer.tsx
@@ -10,6 +10,8 @@ import useTranslation, { translatable } from 'lib/hooks/useTranslation';
import BrandingHead from '../components/navigation/BrandingHead';
+import { useAppContext } from './AppContainer';
+
const getLastCrumbTitle = (crumbs: CrumbData[]): CrumbTitle | null => {
const content = crumbs.at(-1)?.content;
if (!content) return null;
@@ -20,12 +22,16 @@ const getLastCrumbTitle = (crumbs: CrumbData[]): CrumbTitle | null => {
return actualContent.title;
};
-/**
- * Container for non-course pages. Pending name and design.
- */
-const CourselessContainer = (): JSX.Element => {
+interface CourselessContainerProps {
+ withoutCourseSwitcher?: boolean;
+ withoutUserMenu?: boolean;
+}
+
+const CourselessContainer = (props: CourselessContainerProps): JSX.Element => {
const { t } = useTranslation();
+ const context = useAppContext();
+
const { crumbs } = useDynamicNest();
const crumbTitle = getLastCrumbTitle(crumbs);
@@ -33,14 +39,18 @@ const CourselessContainer = (): JSX.Element => {
return (
-
-
+
diff --git a/client/app/lib/helpers/server-context.js b/client/app/lib/helpers/server-context.js
deleted file mode 100644
index 029478be94d..00000000000
--- a/client/app/lib/helpers/server-context.js
+++ /dev/null
@@ -1,16 +0,0 @@
-const serverContext = document.querySelector("meta[name='server-context']");
-
-function getServerContextAttribute(attributeName) {
- return serverContext ? serverContext.getAttribute(attributeName) : null;
-}
-
-const controllerName = getServerContextAttribute('data-controller-name');
-const modulePath = controllerName && controllerName.replace(/_/g, '-');
-
-const i18nLocale = getServerContextAttribute('data-i18n-locale');
-const timeZone = getServerContextAttribute('data-time-zone');
-
-const csrfTag = document.querySelector('meta[name="csrf-token"]');
-const csrfToken = csrfTag ? csrfTag.getAttribute('content') : null;
-
-export { csrfToken, i18nLocale, modulePath, timeZone };
diff --git a/client/app/lib/hooks/router/redirect.tsx b/client/app/lib/hooks/router/redirect.tsx
new file mode 100644
index 00000000000..0ce11bf9df8
--- /dev/null
+++ b/client/app/lib/hooks/router/redirect.tsx
@@ -0,0 +1,96 @@
+import { Navigate, useSearchParams } from 'react-router-dom';
+
+const NEXT_URL_SEARCH_PARAM = 'next';
+const EXPIRED_SESSION_SEARCH_PARAM = 'expired';
+const FORBIDDEN_SOURCE_URL_SEARCH_PARAM = 'from';
+
+/**
+ * Defensively parse a URL, returning `null` if a valid URL cannot be created. This
+ * is because the `URL` constructor throws a `TypeError` if the URL is invalid. We
+ * don't want to block page load just because of an invalid URL.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/URL/URL#exceptions
+ */
+const defensivelyParseURL = (rawURL: string): string | null => {
+ try {
+ const url = new URL(rawURL, window.location.origin);
+ return url.pathname + url.search;
+ } catch {
+ return null;
+ }
+};
+
+const getCurrentURL = (): string =>
+ window.location.pathname + window.location.search;
+
+const getAuthenticatableURL = (nextURL?: string, expired?: boolean): string => {
+ const url = new URL('/users/sign_in', window.location.origin);
+ if (nextURL) url.searchParams.append(NEXT_URL_SEARCH_PARAM, nextURL);
+ if (expired) url.searchParams.append(EXPIRED_SESSION_SEARCH_PARAM, 'true');
+ return url.pathname + url.search;
+};
+
+const getForbiddenURL = (): string => {
+ const url = new URL('/forbidden', window.location.origin);
+ url.searchParams.append(FORBIDDEN_SOURCE_URL_SEARCH_PARAM, getCurrentURL());
+ return url.pathname + url.search;
+};
+
+const useNextURL = (): { nextURL: string | null; expired: boolean } => {
+ const [searchParams] = useSearchParams();
+ const nextRawURL = searchParams.get(NEXT_URL_SEARCH_PARAM);
+ const expired = searchParams.get(EXPIRED_SESSION_SEARCH_PARAM);
+
+ return {
+ nextURL: nextRawURL && defensivelyParseURL(nextRawURL),
+ expired: Boolean(expired),
+ };
+};
+
+/**
+ * Redirects to the sign in page with the current URL as the next URL. To be used
+ * in scopes outside React and/or React Router, e.g., Axios interceptors.
+ *
+ * @param expired Whether this redirect is caused by an expired session.
+ */
+export const redirectToSignIn = (expired?: boolean): void => {
+ window.location.href = getAuthenticatableURL(getCurrentURL(), expired);
+};
+
+export const redirectToForbidden = (): void => {
+ window.location.href = getForbiddenURL();
+};
+
+export const redirectToNotFound = (): void => {
+ window.location.href = '/404';
+};
+
+export const getForbiddenSourceURL = (rawURL: string): string | null => {
+ const url = new URL(rawURL);
+ return url.searchParams.get(FORBIDDEN_SOURCE_URL_SEARCH_PARAM);
+};
+
+/**
+ * Redirects to the next URL if it exists, otherwise redirects to the home page.
+ */
+export const Redirectable = (): JSX.Element => {
+ const { nextURL } = useNextURL();
+ return ;
+};
+
+/**
+ * Redirects to the sign in page with the current intercepted URL as the next URL.
+ */
+export const Authenticatable = (): JSX.Element => {
+ const redirectURL = getAuthenticatableURL(getCurrentURL());
+ return ;
+};
+
+export const useRedirectable = (): {
+ redirectable: boolean;
+ expired: boolean;
+} => {
+ const { nextURL, expired } = useNextURL();
+
+ return { redirectable: Boolean(nextURL?.trim()), expired };
+};
diff --git a/client/app/lib/hooks/session.ts b/client/app/lib/hooks/session.ts
new file mode 100644
index 00000000000..de3ee5b4ab2
--- /dev/null
+++ b/client/app/lib/hooks/session.ts
@@ -0,0 +1,73 @@
+import { createSelector, Dispatch } from '@reduxjs/toolkit';
+import { AppState, dispatch as imperativeDispatch, store } from 'store';
+
+import { actions, SessionState } from 'bundles/common/store';
+
+import { useAppDispatch, useAppSelector } from './store';
+
+const selectSessionStore = (state: AppState): SessionState => state.session;
+
+const selectAuthState = createSelector(
+ selectSessionStore,
+ (session) => session.authenticated,
+);
+
+const selectI18nConfig = createSelector(selectSessionStore, (session) => ({
+ locale: session.locale,
+ timeZone: session.timeZone,
+}));
+
+export const useAuthState = (): boolean => useAppSelector(selectAuthState);
+
+interface UseAuthenticatorHook {
+ authenticate: () => void;
+ deauthenticate: () => void;
+}
+
+/**
+ * NEVER export this method or attempt to use it anywhere else without good reasons.
+ * Ideally, developers should seek to use `useAuthState` where possible. This is
+ * an internal implementation to prevent repeated dispatches.
+ */
+const getAuthState = (): boolean => store.getState()?.session?.authenticated;
+
+const createAuthenticator = (dispatch: Dispatch): UseAuthenticatorHook => ({
+ authenticate: (): void => {
+ if (getAuthState()) return;
+ dispatch(actions.setAuthenticated(true));
+ },
+ deauthenticate: (): void => {
+ if (!getAuthState()) return;
+ dispatch(actions.setAuthenticated(false));
+ },
+});
+
+export const useAuthenticator = (): UseAuthenticatorHook => {
+ const dispatch = useAppDispatch();
+
+ return createAuthenticator(dispatch);
+};
+
+/**
+ * Ideally, developers should seek to use `useAuthenticator` where possible. This
+ * authenticator is only used for internal logic outside React, e.g., Axios requests,
+ * React Router loaders, etc.
+ */
+export const imperativeAuthenticator = createAuthenticator(imperativeDispatch);
+
+interface I18nConfig {
+ locale: string;
+ timeZone: string;
+}
+
+export const useI18nConfig = (): I18nConfig => useAppSelector(selectI18nConfig);
+
+export const setI18nConfig = (config: Partial): void => {
+ const session = store.getState()?.session;
+ const currentLocale = session?.locale;
+ const currentTimeZone = session?.timeZone;
+ if (currentLocale === config.locale && currentTimeZone === config.timeZone)
+ return;
+
+ imperativeDispatch(actions.setI18nConfig(config));
+};
diff --git a/client/app/lib/hooks/toast/index.ts b/client/app/lib/hooks/toast/index.ts
new file mode 100644
index 00000000000..e66dcf94f45
--- /dev/null
+++ b/client/app/lib/hooks/toast/index.ts
@@ -0,0 +1,2 @@
+export { default as loadingToast } from './loadingToast';
+export { default } from './toast';
diff --git a/client/app/lib/hooks/loadingToast.ts b/client/app/lib/hooks/toast/loadingToast.ts
similarity index 81%
rename from client/app/lib/hooks/loadingToast.ts
rename to client/app/lib/hooks/toast/loadingToast.ts
index b7527a296d1..52768b27d7c 100644
--- a/client/app/lib/hooks/loadingToast.ts
+++ b/client/app/lib/hooks/toast/loadingToast.ts
@@ -1,6 +1,6 @@
-import { toast } from 'react-toastify';
+import { DEFAULT_TOAST_TIMEOUT_MS } from 'lib/components/wrappers/ToastProvider';
-const DEFAULT_TOAST_TIMEOUT_MS = 5000 as const;
+import toast from './toast';
type Updater = (message: string) => void;
@@ -21,6 +21,7 @@ const loadingToast = (loadingMessage: string): LoadingToast => {
isLoading: false,
autoClose: DEFAULT_TOAST_TIMEOUT_MS,
render: message,
+ closeButton: true,
}),
error: (message) =>
toast.update(id, {
@@ -28,6 +29,7 @@ const loadingToast = (loadingMessage: string): LoadingToast => {
isLoading: false,
autoClose: DEFAULT_TOAST_TIMEOUT_MS,
render: message,
+ closeButton: true,
}),
};
};
diff --git a/client/app/lib/hooks/toast/toast.tsx b/client/app/lib/hooks/toast/toast.tsx
new file mode 100644
index 00000000000..65d9eb728b8
--- /dev/null
+++ b/client/app/lib/hooks/toast/toast.tsx
@@ -0,0 +1,145 @@
+import { ReactNode } from 'react';
+import {
+ Id,
+ toast as toastify,
+ ToastOptions,
+ TypeOptions,
+ UpdateOptions,
+} from 'react-toastify';
+import {
+ ErrorOutline,
+ InfoOutlined,
+ SvgIconComponent,
+ TaskAlt,
+ WarningAmber,
+} from '@mui/icons-material';
+import { Typography } from '@mui/material';
+import { produce } from 'immer';
+
+type Toaster = (message: string, options?: ToastOptions) => Id;
+
+interface PromisedToastMessages {
+ pending?: ReactNode;
+ error?: ReactNode;
+ success?: ReactNode;
+}
+
+type PromisedToaster = (
+ data: Promise,
+ messages: PromisedToastMessages,
+ options?: ToastOptions,
+) => Promise;
+
+/**
+ * `UpdateOptions` also allows for `render` to be a function of type
+ * `(props: ToastContentProps
) => ReactNode`. Here, we define
+ * a stricter version of `UpdateOptions` to simplify our adapter, and
+ * since we only usually pass string `message`s.
+ */
+interface NodeOnlyUpdateOptions extends UpdateOptions {
+ render?: ReactNode;
+}
+
+const icons: Partial> = {
+ error: ErrorOutline,
+ info: InfoOutlined,
+ success: TaskAlt,
+ warning: WarningAmber,
+};
+
+const getIconForToastType = (type: TypeOptions): JSX.Element | undefined => {
+ const Icon = icons[type];
+ if (!Icon) return undefined;
+
+ return ;
+};
+
+const formattedMessage = (message: ReactNode): JSX.Element => (
+ {message}
+);
+
+const isUpdateOptions = (
+ options?: NodeOnlyUpdateOptions | ToastOptions,
+): options is NodeOnlyUpdateOptions =>
+ options !== undefined &&
+ (options as NodeOnlyUpdateOptions).render !== undefined;
+
+/**
+ * Adds our default icons depending on the `type` of the toast. If
+ * `options` is an `UpdateOptions`, we also format `render`.
+ */
+const customize = (
+ options?: O,
+): O | undefined => {
+ if (!options) return undefined;
+
+ return produce(options, (draft) => {
+ if (isUpdateOptions(draft)) draft.render = formattedMessage(draft.render);
+
+ draft.icon = getIconForToastType(draft.type ?? 'default');
+ });
+};
+
+const launch: Toaster = (message, options?) =>
+ toastify(formattedMessage(message), customize(options));
+
+const toast: Toaster = (message, options?) =>
+ launch(message, { ...options, type: 'default' });
+
+const success: Toaster = (message, options?) =>
+ launch(message, { ...options, type: 'success' });
+
+const info: Toaster = (message, options?) =>
+ launch(message, { ...options, type: 'info' });
+
+const warn: Toaster = (message, options?) =>
+ launch(message, { ...options, type: 'warning' });
+
+const error: Toaster = (message, options?) =>
+ launch(message, { ...options, type: 'error' });
+
+/**
+ * We do not `customize` the options here because we want to retain
+ * the default loading spinner.
+ */
+const loading: Toaster = (message, options?) =>
+ toastify.loading(formattedMessage(message), options);
+
+const update = (id: Id, options?: NodeOnlyUpdateOptions): void =>
+ toastify.update(id, customize(options));
+
+const promise: PromisedToaster = (data, messages, options?) => {
+ return toastify.promise(
+ data,
+ {
+ pending: messages.pending
+ ? { render: formattedMessage(messages.pending) }
+ : undefined,
+ error: messages.error
+ ? {
+ render: formattedMessage(messages.error),
+ type: 'error',
+ icon: getIconForToastType('error'),
+ }
+ : undefined,
+ success: messages.success
+ ? {
+ render: formattedMessage(messages.success),
+ type: 'success',
+ icon: getIconForToastType('success'),
+ }
+ : undefined,
+ },
+ customize(options),
+ );
+};
+
+export default Object.assign(toast, {
+ success,
+ info,
+ warn,
+ error,
+ loading,
+ update,
+ promise,
+});
diff --git a/client/app/lib/hooks/useEffectOnce.ts b/client/app/lib/hooks/useEffectOnce.ts
new file mode 100644
index 00000000000..0163a65f6d9
--- /dev/null
+++ b/client/app/lib/hooks/useEffectOnce.ts
@@ -0,0 +1,14 @@
+import { EffectCallback, useEffect, useRef } from 'react';
+
+const useEffectOnce = (effect: EffectCallback): void => {
+ const ref = useRef(false);
+
+ useEffect(() => {
+ if (ref.current) return undefined;
+
+ ref.current = true;
+ return effect();
+ }, []);
+};
+
+export default useEffectOnce;
diff --git a/client/app/lib/initializers/webfont.js b/client/app/lib/initializers/webfont.js
index 7f9221b4f68..4aa0a11b776 100644
--- a/client/app/lib/initializers/webfont.js
+++ b/client/app/lib/initializers/webfont.js
@@ -6,7 +6,7 @@ import WebFont from 'webfontloader';
WebFont.load({
google: {
- families: ['Inter:400,500,600,700,i4', 'Varela Round'],
+ families: ['Inter:400,500,600,700,800,i4'],
},
timeout: 1500,
});
diff --git a/client/app/lib/moment.ts b/client/app/lib/moment.ts
index 1327ec3f524..d63b9cd3c3f 100644
--- a/client/app/lib/moment.ts
+++ b/client/app/lib/moment.ts
@@ -1,7 +1,5 @@
import moment from 'moment-timezone';
-import { timeZone } from 'lib/helpers/server-context';
-
const LONG_DATE_FORMAT = 'DD MMM YYYY' as const;
const LONG_TIME_FORMAT = 'h:mma' as const;
const LONG_DATE_TIME_FORMAT =
@@ -20,8 +18,6 @@ const FULL_DATE_TIME_FORMAT = 'dddd, MMMM D YYYY, HH:mm' as const;
const MINI_DATE_TIME_FORMAT = 'D MMM YYYY HH:mm' as const;
const MINI_DATE_TIME_YEARLESS_FORMAT = 'D MMM HH:mm' as const;
-moment.tz.setDefault(timeZone ?? undefined);
-
// TODO: Do not export moment and create the helpers here
export default moment;
diff --git a/client/app/router.tsx b/client/app/router.tsx
deleted file mode 100644
index d4b968096d8..00000000000
--- a/client/app/router.tsx
+++ /dev/null
@@ -1,775 +0,0 @@
-/* eslint-disable sonarjs/no-duplicate-string */
-import { Navigate, RouteObject } from 'react-router-dom';
-import { resetStore } from 'store';
-
-import GlobalAnnouncementIndex from 'bundles/announcements/GlobalAnnouncementIndex';
-import AchievementShow from 'bundles/course/achievement/pages/AchievementShow';
-import AchievementsIndex from 'bundles/course/achievement/pages/AchievementsIndex';
-import SettingsNavigation from 'bundles/course/admin/components/SettingsNavigation';
-import AnnouncementSettings from 'bundles/course/admin/pages/AnnouncementsSettings';
-import AssessmentSettings from 'bundles/course/admin/pages/AssessmentSettings';
-import CodaveriSettings from 'bundles/course/admin/pages/CodaveriSettings';
-import CommentsSettings from 'bundles/course/admin/pages/CommentsSettings';
-import ComponentSettings from 'bundles/course/admin/pages/ComponentSettings';
-import CourseSettings from 'bundles/course/admin/pages/CourseSettings';
-import ForumsSettings from 'bundles/course/admin/pages/ForumsSettings';
-import LeaderboardSettings from 'bundles/course/admin/pages/LeaderboardSettings';
-import LessonPlanSettings from 'bundles/course/admin/pages/LessonPlanSettings';
-import MaterialsSettings from 'bundles/course/admin/pages/MaterialsSettings';
-import NotificationSettings from 'bundles/course/admin/pages/NotificationSettings';
-import SidebarSettings from 'bundles/course/admin/pages/SidebarSettings';
-import VideosSettings from 'bundles/course/admin/pages/VideosSettings';
-import AnnouncementsIndex from 'bundles/course/announcements/pages/AnnouncementsIndex';
-import AssessmentEdit from 'bundles/course/assessment/pages/AssessmentEdit';
-import AssessmentMonitoring from 'bundles/course/assessment/pages/AssessmentMonitoring';
-import AssessmentShow from 'bundles/course/assessment/pages/AssessmentShow';
-import AssessmentsIndex from 'bundles/course/assessment/pages/AssessmentsIndex';
-import AssessmentStatisticsPage from 'bundles/course/assessment/pages/AssessmentStatistics';
-import EditForumPostResponsePage from 'bundles/course/assessment/question/forum-post-responses/EditForumPostResponsePage';
-import NewForumPostResponsePage from 'bundles/course/assessment/question/forum-post-responses/NewForumPostResponsePage';
-import EditMcqMrqPage from 'bundles/course/assessment/question/multiple-responses/EditMcqMrqPage';
-import NewMcqMrqPage from 'bundles/course/assessment/question/multiple-responses/NewMcqMrqPage';
-import EditProgrammingQuestionPage from 'bundles/course/assessment/question/programming/EditProgrammingQuestionPage';
-import NewProgrammingQuestionPage from 'bundles/course/assessment/question/programming/NewProgrammingQuestionPage';
-import ScribingQuestion from 'bundles/course/assessment/question/scribing/ScribingQuestion';
-import EditTextResponse from 'bundles/course/assessment/question/text-responses/EditTextResponsePage';
-import NewTextResponse from 'bundles/course/assessment/question/text-responses/NewTextResponsePage';
-import EditVoicePage from 'bundles/course/assessment/question/voice-responses/EditVoicePage';
-import NewVoicePage from 'bundles/course/assessment/question/voice-responses/NewVoicePage';
-import AssessmentSessionNew from 'bundles/course/assessment/sessions/pages/AssessmentSessionNew';
-import SkillsIndex from 'bundles/course/assessment/skills/pages/SkillsIndex';
-import LogsIndex from 'bundles/course/assessment/submission/pages/LogsIndex';
-import SubmissionEditIndex from 'bundles/course/assessment/submission/pages/SubmissionEditIndex';
-import AssessmentSubmissionsIndex from 'bundles/course/assessment/submission/pages/SubmissionsIndex';
-import SubmissionsIndex from 'bundles/course/assessment/submissions/SubmissionsIndex';
-import CourseShow from 'bundles/course/courses/pages/CourseShow';
-import CoursesIndex from 'bundles/course/courses/pages/CoursesIndex';
-import CommentIndex from 'bundles/course/discussion/topics/pages/CommentIndex';
-import Duplication from 'bundles/course/duplication/pages/Duplication';
-import UserRequests from 'bundles/course/enrol-requests/pages/UserRequests';
-import DisbursementIndex from 'bundles/course/experience-points/disbursement/pages/DisbursementIndex';
-import ForumShow from 'bundles/course/forum/pages/ForumShow';
-import ForumsIndex from 'bundles/course/forum/pages/ForumsIndex';
-import ForumTopicShow from 'bundles/course/forum/pages/ForumTopicShow';
-import GroupIndex from 'bundles/course/group/pages/GroupIndex';
-import GroupShow from 'bundles/course/group/pages/GroupShow';
-import LeaderboardIndex from 'bundles/course/leaderboard/pages/LeaderboardIndex';
-import LearningMap from 'bundles/course/learning-map/containers/LearningMap';
-import LessonPlanLayout from 'bundles/course/lesson-plan/containers/LessonPlanLayout';
-import LessonPlanEdit from 'bundles/course/lesson-plan/pages/LessonPlanEdit';
-import LessonPlanShow from 'bundles/course/lesson-plan/pages/LessonPlanShow';
-import LevelsIndex from 'bundles/course/level/pages/LevelsIndex';
-import FolderShow from 'bundles/course/material/folders/pages/FolderShow';
-import TimelineDesigner from 'bundles/course/reference-timelines/TimelineDesigner';
-import StatisticsIndex from 'bundles/course/statistics/pages/StatisticsIndex';
-import ResponseEdit from 'bundles/course/survey/pages/ResponseEdit';
-import ResponseIndex from 'bundles/course/survey/pages/ResponseIndex';
-import ResponseShow from 'bundles/course/survey/pages/ResponseShow';
-import SurveyIndex from 'bundles/course/survey/pages/SurveyIndex';
-import SurveyResults from 'bundles/course/survey/pages/SurveyResults';
-import SurveyShow from 'bundles/course/survey/pages/SurveyShow';
-import UserEmailSubscriptions from 'bundles/course/user-email-subscriptions/UserEmailSubscriptions';
-import InvitationsIndex from 'bundles/course/user-invitations/pages/InvitationsIndex';
-import InviteUsers from 'bundles/course/user-invitations/pages/InviteUsers';
-import ExperiencePointsRecords from 'bundles/course/users/pages/ExperiencePointsRecords';
-import ManageStaff from 'bundles/course/users/pages/ManageStaff';
-import ManageStudents from 'bundles/course/users/pages/ManageStudents';
-import PersonalTimes from 'bundles/course/users/pages/PersonalTimes';
-import PersonalTimesShow from 'bundles/course/users/pages/PersonalTimesShow';
-import CourseUserShow from 'bundles/course/users/pages/UserShow';
-import UsersIndex from 'bundles/course/users/pages/UsersIndex';
-import VideoShow from 'bundles/course/video/pages/VideoShow';
-import VideosIndex from 'bundles/course/video/pages/VideosIndex';
-import VideoSubmissionEdit from 'bundles/course/video/submission/pages/VideoSubmissionEdit';
-import VideoSubmissionShow from 'bundles/course/video/submission/pages/VideoSubmissionShow';
-import VideoSubmissionsIndex from 'bundles/course/video/submission/pages/VideoSubmissionsIndex';
-import UserVideoSubmissionsIndex from 'bundles/course/video-submissions/pages/UserVideoSubmissionsIndex';
-import AdminNavigator from 'bundles/system/admin/admin/AdminNavigator';
-import AnnouncementIndex from 'bundles/system/admin/admin/pages/AnnouncementsIndex';
-import CourseIndex from 'bundles/system/admin/admin/pages/CoursesIndex';
-import InstancesIndex from 'bundles/system/admin/admin/pages/InstancesIndex';
-import UserIndex from 'bundles/system/admin/admin/pages/UsersIndex';
-import InstanceAdminNavigator from 'bundles/system/admin/instance/instance/InstanceAdminNavigator';
-import InstanceAnnouncementsIndex from 'bundles/system/admin/instance/instance/pages/InstanceAnnouncementsIndex';
-import InstanceComponentsIndex from 'bundles/system/admin/instance/instance/pages/InstanceComponentsIndex';
-import InstanceCoursesIndex from 'bundles/system/admin/instance/instance/pages/InstanceCoursesIndex';
-import InstanceUserRoleRequestsIndex from 'bundles/system/admin/instance/instance/pages/InstanceUserRoleRequestsIndex';
-import InstanceUsersIndex from 'bundles/system/admin/instance/instance/pages/InstanceUsersIndex';
-import InstanceUsersInvitations from 'bundles/system/admin/instance/instance/pages/InstanceUsersInvitations';
-import InstanceUsersInvite from 'bundles/system/admin/instance/instance/pages/InstanceUsersInvite';
-import AccountSettings from 'bundles/user/AccountSettings';
-import UserShow from 'bundles/users/pages/UserShow';
-import { achievementHandle } from 'course/achievement/handles';
-import {
- assessmentHandle,
- assessmentsHandle,
- questionHandle,
-} from 'course/assessment/handles';
-import QuestionFormOutlet from 'course/assessment/question/components/QuestionFormOutlet';
-import { forumHandle, forumTopicHandle } from 'course/forum/handles';
-import { folderHandle } from 'course/material/folders/handles';
-import { videoWatchHistoryHandle } from 'course/statistics/handles';
-import { surveyHandle, surveyResponseHandle } from 'course/survey/handles';
-import {
- courseUserHandle,
- courseUserPersonalizedTimelineHandle,
- manageUserHandles,
-} from 'course/users/handles';
-import { videoHandle, videosHandle } from 'course/video/handles';
-
-import { CourseContainer } from './bundles/course/container';
-import AppContainer from './lib/containers/AppContainer';
-import CourselessContainer from './lib/containers/CourselessContainer';
-
-const router: RouteObject[] = [
- {
- path: '/',
- element: ,
- loader: AppContainer.loader,
- shouldRevalidate: (): boolean => false,
- children: [
- {
- path: 'courses/:courseId',
- element: ,
- loader: CourseContainer.loader,
- handle: CourseContainer.handle,
- shouldRevalidate: ({ currentParams, nextParams }): boolean => {
- const isChangingCourse =
- currentParams.courseId !== nextParams.courseId;
-
- // React Router's documentation never strictly mentioned that `shouldRevalidate`
- // should be a pure function, but a good software engineer would probably expect
- // it to be. Until we multi-course support in our Redux store, this is where
- // we can detect the `courseId` is changing without janky `useEffect`. It should
- // be safe since `resetStore` does not interfere with rendering or routing.
- if (isChangingCourse) resetStore();
-
- return isChangingCourse;
- },
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: 'timelines',
- handle: TimelineDesigner.handle,
- element: ,
- },
- {
- path: 'announcements',
- handle: AnnouncementsIndex.handle,
- element: ,
- },
- {
- path: 'comments',
- handle: CommentIndex.handle,
- element: ,
- },
- {
- path: 'leaderboard',
- handle: LeaderboardIndex.handle,
- element: ,
- },
- {
- path: 'learning_map',
- handle: LearningMap.handle,
- element: ,
- },
- {
- path: 'materials/folders',
- handle: folderHandle,
- // `:folderId` must be split this way so that `folderHandle` is matched
- // to the stable (non-changing) match of `/materials/folders`. This allows
- // the crumbs in the Workbin to not disappear when revalidated by the
- // Dynamic Nest API's builder.
- children: [
- {
- path: ':folderId',
- element: ,
- },
- ],
- },
- {
- path: 'levels',
- handle: LevelsIndex.handle,
- element: ,
- },
- {
- path: 'statistics',
- handle: StatisticsIndex.handle,
- element: ,
- },
- {
- path: 'duplication',
- handle: Duplication.handle,
- element: ,
- },
- {
- path: 'enrol_requests',
- handle: manageUserHandles.enrolRequests,
- element: ,
- },
- {
- path: 'user_invitations',
- handle: manageUserHandles.invitations,
- element: ,
- },
- {
- path: 'students',
- handle: manageUserHandles.students,
- element: ,
- },
- {
- path: 'staff',
- handle: manageUserHandles.staff,
- element: ,
- },
- {
- path: 'lesson_plan',
- // @ts-ignore `connect` throws error when cannot find `store` as direct parent
- element: ,
- handle: LessonPlanLayout.handle,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: 'edit',
- element: ,
- },
- ],
- },
- {
- path: 'users',
- children: [
- {
- index: true,
- handle: UsersIndex.handle,
- element: ,
- },
- {
- path: 'personal_times',
- handle: manageUserHandles.personalizedTimelines,
- element: ,
- },
- {
- path: 'invite',
- handle: manageUserHandles.inviteUsers,
- element: ,
- },
- {
- path: 'disburse_experience_points',
- handle: DisbursementIndex.handle,
- element: ,
- },
- {
- path: ':userId',
- handle: courseUserHandle,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: 'experience_points_records',
- handle: ExperiencePointsRecords.handle,
- element: ,
- },
- ],
- },
- {
- path: ':userId/personal_times',
- handle: courseUserPersonalizedTimelineHandle,
- element: ,
- },
- {
- path: ':userId/video_submissions',
- handle: videoWatchHistoryHandle,
- element: ,
- },
- {
- path: ':userId/manage_email_subscription',
- handle: UserEmailSubscriptions.handle,
- element: ,
- },
- ],
- },
- {
- path: 'admin',
- loader: SettingsNavigation.loader,
- handle: SettingsNavigation.handle,
- element: ,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: 'components',
- element: ,
- },
- {
- path: 'sidebar',
- element: ,
- },
- {
- path: 'notifications',
- element: ,
- },
- {
- path: 'announcements',
- element: ,
- },
- {
- path: 'assessments',
- element: ,
- },
- {
- path: 'materials',
- element: ,
- },
- {
- path: 'forums',
- element: ,
- },
- {
- path: 'leaderboard',
- element: ,
- },
- {
- path: 'comments',
- element: ,
- },
- {
- path: 'videos',
- element: ,
- },
- {
- path: 'lesson_plan',
- element: ,
- },
- {
- path: 'codaveri',
- element: ,
- },
- ],
- },
- {
- path: 'surveys',
- handle: SurveyIndex.handle,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: ':surveyId',
- handle: surveyHandle,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: 'results',
- handle: SurveyResults.handle,
- element: ,
- },
- {
- path: 'responses',
- children: [
- {
- index: true,
- handle: ResponseIndex.handle,
- element: ,
- },
- {
- path: ':responseId',
- children: [
- {
- index: true,
- handle: surveyResponseHandle,
- element: ,
- },
- {
- path: 'edit',
- handle: ResponseEdit.handle,
- element: ,
- },
- ],
- },
- ],
- },
- ],
- },
- ],
- },
- {
- path: 'groups',
- element: ,
- handle: GroupIndex.handle,
- children: [
- {
- path: ':groupCategoryId',
- element: ,
- },
- ],
- },
- {
- path: 'videos',
- handle: videosHandle,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: ':videoId',
- handle: videoHandle,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: 'submissions',
- children: [
- {
- index: true,
- handle: VideoSubmissionsIndex.handle,
- element: ,
- },
- {
- path: ':submissionId',
- handle: VideoSubmissionShow.handle,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: 'edit',
- element: ,
- },
- ],
- },
- ],
- },
- ],
- },
- ],
- },
- {
- path: 'forums',
- handle: ForumsIndex.handle,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: ':forumId',
- handle: forumHandle,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: 'topics/:topicId',
- handle: forumTopicHandle,
- element: ,
- },
- ],
- },
- ],
- },
- {
- path: 'achievements',
- handle: AchievementsIndex.handle,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: ':achievementId',
- handle: achievementHandle,
- element: ,
- },
- ],
- },
- {
- path: 'assessments',
- handle: assessmentsHandle,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: 'submissions',
- handle: SubmissionsIndex.handle,
- element: ,
- },
- {
- path: 'skills',
- handle: SkillsIndex.handle,
- element: ,
- },
- {
- path: ':assessmentId',
- handle: assessmentHandle,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: 'edit',
- handle: AssessmentEdit.handle,
- element: ,
- },
- {
- path: 'monitoring',
- handle: AssessmentMonitoring.handle,
- element: ,
- },
- {
- path: 'sessions/new',
- element: ,
- },
- {
- path: 'statistics',
- handle: AssessmentStatisticsPage.handle,
- // @ts-ignore `connect` throws error when cannot find `store` as direct parent
- element: ,
- },
- {
- path: 'submissions',
- children: [
- {
- index: true,
- handle: AssessmentSubmissionsIndex.handle,
- element: ,
- },
- {
- path: ':submissionId',
- children: [
- {
- path: 'edit',
- handle: SubmissionEditIndex.handle,
- element: ,
- },
- {
- path: 'logs',
- handle: LogsIndex.handle,
- element: ,
- },
- ],
- },
- ],
- },
- {
- path: 'question',
- element: ,
- handle: questionHandle,
- children: [
- {
- path: 'forum_post_responses',
- children: [
- {
- path: 'new',
- handle: NewForumPostResponsePage.handle,
- element: ,
- },
- {
- path: ':questionId/edit',
- element: ,
- },
- ],
- },
- {
- path: 'text_responses',
- children: [
- {
- path: 'new',
- handle: NewTextResponse.handle,
- element: ,
- },
- {
- path: ':questionId/edit',
- element: ,
- },
- ],
- },
- {
- path: 'voice_responses',
- children: [
- {
- path: 'new',
- handle: NewVoicePage.handle,
- element: ,
- },
- {
- path: ':questionId/edit',
- element: ,
- },
- ],
- },
- {
- path: 'multiple_responses',
- children: [
- {
- path: 'new',
- handle: NewMcqMrqPage.handle,
- element: ,
- },
- {
- path: ':questionId/edit',
- element: ,
- },
- ],
- },
- {
- path: 'scribing',
- children: [
- {
- path: 'new',
- handle: ScribingQuestion.handle,
- element: ,
- },
- {
- path: ':questionId/edit',
- element: ,
- },
- ],
- },
- {
- path: 'programming',
- children: [
- {
- path: 'new',
- handle: NewProgrammingQuestionPage.handle,
- element: ,
- },
- {
- path: ':questionId/edit',
- element: ,
- },
- ],
- },
- ],
- },
- ],
- },
- ],
- },
- ],
- },
- {
- path: '*',
- element: ,
- children: [
- {
- path: 'courses',
- handle: CoursesIndex.handle,
- element: ,
- },
- {
- path: 'admin',
- handle: AdminNavigator.handle,
- element: ,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: 'announcements',
- element: ,
- },
- {
- path: 'users',
- element: ,
- },
- {
- path: 'instances',
- element: ,
- },
- {
- path: 'courses',
- element: ,
- },
- ],
- },
- {
- path: 'admin/instance',
- handle: InstanceAdminNavigator.handle,
- element: ,
- children: [
- {
- index: true,
- element: ,
- },
- {
- path: 'announcements',
- element: ,
- },
- {
- path: 'components',
- element: ,
- },
- {
- path: 'courses',
- element: ,
- },
- {
- path: 'users',
- element: ,
- },
- {
- path: 'users/invite',
- element: ,
- },
- {
- path: 'user_invitations',
- element: ,
- },
- {
- path: 'role_requests',
- element: ,
- },
- ],
- },
- {
- path: 'announcements',
- handle: GlobalAnnouncementIndex.handle,
- element: ,
- },
- {
- path: 'users/:userId',
- element: ,
- },
- {
- path: 'user/profile/edit',
- handle: AccountSettings.handle,
- element: ,
- },
- // `/role_requests` will be routed without `InstanceAdminNavigator` and its sidebar.
- // Here, we redirect `/role_requests` to `/admin/instance/role_requests`. But until
- // we are loading the page without Rails' router, fresh loads to `/role_requests` will
- // go to 404. Once we are 100% SPA, this is a non-issue.
- {
- path: 'role_requests',
- element: ,
- },
- ],
- },
- ],
- },
-];
-
-export default router;
diff --git a/client/app/routers/AuthenticatableApp.tsx b/client/app/routers/AuthenticatableApp.tsx
new file mode 100644
index 00000000000..0bc83adeffd
--- /dev/null
+++ b/client/app/routers/AuthenticatableApp.tsx
@@ -0,0 +1,33 @@
+import { lazy, Suspense } from 'react';
+
+import LoadingIndicator from 'lib/components/core/LoadingIndicator';
+import { useAuthState } from 'lib/hooks/session';
+
+const AuthenticatedApp = lazy(
+ () => import(/* webpackChunkName: "AuthenticatedApp" */ './AuthenticatedApp'),
+);
+
+const UnauthenticatedApp = lazy(
+ () =>
+ import(/* webpackChunkName: "UnauthenticatedApp" */ './UnauthenticatedApp'),
+);
+
+const AuthenticatableApp = (): JSX.Element => {
+ const authenticated = useAuthState();
+
+ return (
+
+ }
+ >
+ {authenticated ? : }
+
+ );
+};
+
+export default AuthenticatableApp;
diff --git a/client/app/routers/AuthenticatedApp.tsx b/client/app/routers/AuthenticatedApp.tsx
new file mode 100644
index 00000000000..05da89da8b0
--- /dev/null
+++ b/client/app/routers/AuthenticatedApp.tsx
@@ -0,0 +1,826 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+import {
+ createBrowserRouter,
+ Navigate,
+ RouteObject,
+ RouterProvider,
+} from 'react-router-dom';
+import { resetStore } from 'store';
+
+import GlobalAnnouncementIndex from 'bundles/announcements/GlobalAnnouncementIndex';
+import DashboardPage from 'bundles/common/DashboardPage';
+import AchievementShow from 'bundles/course/achievement/pages/AchievementShow';
+import AchievementsIndex from 'bundles/course/achievement/pages/AchievementsIndex';
+import SettingsNavigation from 'bundles/course/admin/components/SettingsNavigation';
+import AnnouncementSettings from 'bundles/course/admin/pages/AnnouncementsSettings';
+import AssessmentSettings from 'bundles/course/admin/pages/AssessmentSettings';
+import CodaveriSettings from 'bundles/course/admin/pages/CodaveriSettings';
+import CommentsSettings from 'bundles/course/admin/pages/CommentsSettings';
+import ComponentSettings from 'bundles/course/admin/pages/ComponentSettings';
+import CourseSettings from 'bundles/course/admin/pages/CourseSettings';
+import ForumsSettings from 'bundles/course/admin/pages/ForumsSettings';
+import LeaderboardSettings from 'bundles/course/admin/pages/LeaderboardSettings';
+import LessonPlanSettings from 'bundles/course/admin/pages/LessonPlanSettings';
+import MaterialsSettings from 'bundles/course/admin/pages/MaterialsSettings';
+import NotificationSettings from 'bundles/course/admin/pages/NotificationSettings';
+import SidebarSettings from 'bundles/course/admin/pages/SidebarSettings';
+import VideosSettings from 'bundles/course/admin/pages/VideosSettings';
+import AnnouncementsIndex from 'bundles/course/announcements/pages/AnnouncementsIndex';
+import AssessmentEdit from 'bundles/course/assessment/pages/AssessmentEdit';
+import AssessmentMonitoring from 'bundles/course/assessment/pages/AssessmentMonitoring';
+import AssessmentShow from 'bundles/course/assessment/pages/AssessmentShow';
+import AssessmentsIndex from 'bundles/course/assessment/pages/AssessmentsIndex';
+import AssessmentStatisticsPage from 'bundles/course/assessment/pages/AssessmentStatistics';
+import EditForumPostResponsePage from 'bundles/course/assessment/question/forum-post-responses/EditForumPostResponsePage';
+import NewForumPostResponsePage from 'bundles/course/assessment/question/forum-post-responses/NewForumPostResponsePage';
+import EditMcqMrqPage from 'bundles/course/assessment/question/multiple-responses/EditMcqMrqPage';
+import NewMcqMrqPage from 'bundles/course/assessment/question/multiple-responses/NewMcqMrqPage';
+import EditProgrammingQuestionPage from 'bundles/course/assessment/question/programming/EditProgrammingQuestionPage';
+import NewProgrammingQuestionPage from 'bundles/course/assessment/question/programming/NewProgrammingQuestionPage';
+import ScribingQuestion from 'bundles/course/assessment/question/scribing/ScribingQuestion';
+import EditTextResponse from 'bundles/course/assessment/question/text-responses/EditTextResponsePage';
+import NewTextResponse from 'bundles/course/assessment/question/text-responses/NewTextResponsePage';
+import EditVoicePage from 'bundles/course/assessment/question/voice-responses/EditVoicePage';
+import NewVoicePage from 'bundles/course/assessment/question/voice-responses/NewVoicePage';
+import AssessmentSessionNew from 'bundles/course/assessment/sessions/pages/AssessmentSessionNew';
+import SkillsIndex from 'bundles/course/assessment/skills/pages/SkillsIndex';
+import LogsIndex from 'bundles/course/assessment/submission/pages/LogsIndex';
+import SubmissionEditIndex from 'bundles/course/assessment/submission/pages/SubmissionEditIndex';
+import AssessmentSubmissionsIndex from 'bundles/course/assessment/submission/pages/SubmissionsIndex';
+import SubmissionsIndex from 'bundles/course/assessment/submissions/SubmissionsIndex';
+import CourseShow from 'bundles/course/courses/pages/CourseShow';
+import CommentIndex from 'bundles/course/discussion/topics/pages/CommentIndex';
+import Duplication from 'bundles/course/duplication/pages/Duplication';
+import UserRequests from 'bundles/course/enrol-requests/pages/UserRequests';
+import DisbursementIndex from 'bundles/course/experience-points/disbursement/pages/DisbursementIndex';
+import ForumShow from 'bundles/course/forum/pages/ForumShow';
+import ForumsIndex from 'bundles/course/forum/pages/ForumsIndex';
+import ForumTopicShow from 'bundles/course/forum/pages/ForumTopicShow';
+import GroupIndex from 'bundles/course/group/pages/GroupIndex';
+import GroupShow from 'bundles/course/group/pages/GroupShow';
+import LeaderboardIndex from 'bundles/course/leaderboard/pages/LeaderboardIndex';
+import LearningMap from 'bundles/course/learning-map/containers/LearningMap';
+import LessonPlanLayout from 'bundles/course/lesson-plan/containers/LessonPlanLayout';
+import LessonPlanEdit from 'bundles/course/lesson-plan/pages/LessonPlanEdit';
+import LessonPlanShow from 'bundles/course/lesson-plan/pages/LessonPlanShow';
+import LevelsIndex from 'bundles/course/level/pages/LevelsIndex';
+import FolderShow from 'bundles/course/material/folders/pages/FolderShow';
+import TimelineDesigner from 'bundles/course/reference-timelines/TimelineDesigner';
+import StatisticsIndex from 'bundles/course/statistics/pages/StatisticsIndex';
+import ResponseEdit from 'bundles/course/survey/pages/ResponseEdit';
+import ResponseIndex from 'bundles/course/survey/pages/ResponseIndex';
+import ResponseShow from 'bundles/course/survey/pages/ResponseShow';
+import SurveyIndex from 'bundles/course/survey/pages/SurveyIndex';
+import SurveyResults from 'bundles/course/survey/pages/SurveyResults';
+import SurveyShow from 'bundles/course/survey/pages/SurveyShow';
+import UserEmailSubscriptions from 'bundles/course/user-email-subscriptions/UserEmailSubscriptions';
+import InvitationsIndex from 'bundles/course/user-invitations/pages/InvitationsIndex';
+import InviteUsers from 'bundles/course/user-invitations/pages/InviteUsers';
+import ExperiencePointsRecords from 'bundles/course/users/pages/ExperiencePointsRecords';
+import ManageStaff from 'bundles/course/users/pages/ManageStaff';
+import ManageStudents from 'bundles/course/users/pages/ManageStudents';
+import PersonalTimes from 'bundles/course/users/pages/PersonalTimes';
+import PersonalTimesShow from 'bundles/course/users/pages/PersonalTimesShow';
+import CourseUserShow from 'bundles/course/users/pages/UserShow';
+import UsersIndex from 'bundles/course/users/pages/UsersIndex';
+import VideoShow from 'bundles/course/video/pages/VideoShow';
+import VideosIndex from 'bundles/course/video/pages/VideosIndex';
+import VideoSubmissionEdit from 'bundles/course/video/submission/pages/VideoSubmissionEdit';
+import VideoSubmissionShow from 'bundles/course/video/submission/pages/VideoSubmissionShow';
+import VideoSubmissionsIndex from 'bundles/course/video/submission/pages/VideoSubmissionsIndex';
+import UserVideoSubmissionsIndex from 'bundles/course/video-submissions/pages/UserVideoSubmissionsIndex';
+import AdminNavigator from 'bundles/system/admin/admin/AdminNavigator';
+import AnnouncementIndex from 'bundles/system/admin/admin/pages/AnnouncementsIndex';
+import CourseIndex from 'bundles/system/admin/admin/pages/CoursesIndex';
+import InstancesIndex from 'bundles/system/admin/admin/pages/InstancesIndex';
+import UserIndex from 'bundles/system/admin/admin/pages/UsersIndex';
+import InstanceAdminNavigator from 'bundles/system/admin/instance/instance/InstanceAdminNavigator';
+import InstanceAnnouncementsIndex from 'bundles/system/admin/instance/instance/pages/InstanceAnnouncementsIndex';
+import InstanceComponentsIndex from 'bundles/system/admin/instance/instance/pages/InstanceComponentsIndex';
+import InstanceCoursesIndex from 'bundles/system/admin/instance/instance/pages/InstanceCoursesIndex';
+import InstanceUserRoleRequestsIndex from 'bundles/system/admin/instance/instance/pages/InstanceUserRoleRequestsIndex';
+import InstanceUsersIndex from 'bundles/system/admin/instance/instance/pages/InstanceUsersIndex';
+import InstanceUsersInvitations from 'bundles/system/admin/instance/instance/pages/InstanceUsersInvitations';
+import InstanceUsersInvite from 'bundles/system/admin/instance/instance/pages/InstanceUsersInvite';
+import AccountSettings from 'bundles/user/AccountSettings';
+import {
+ masqueradeLoader,
+ stopMasqueradeLoader,
+} from 'bundles/users/masqueradeLoader';
+import UserShow from 'bundles/users/pages/UserShow';
+import { achievementHandle } from 'course/achievement/handles';
+import assessmentAttemptLoader from 'course/assessment/attemptLoader';
+import {
+ assessmentHandle,
+ assessmentsHandle,
+ questionHandle,
+} from 'course/assessment/handles';
+import QuestionFormOutlet from 'course/assessment/question/components/QuestionFormOutlet';
+import { CourseContainer } from 'course/container';
+import { forumHandle, forumTopicHandle } from 'course/forum/handles';
+import { folderHandle } from 'course/material/folders/handles';
+import materialLoader from 'course/material/materialLoader';
+import { videoWatchHistoryHandle } from 'course/statistics/handles';
+import { surveyHandle, surveyResponseHandle } from 'course/survey/handles';
+import {
+ courseUserHandle,
+ courseUserPersonalizedTimelineHandle,
+ manageUserHandles,
+} from 'course/users/handles';
+import videoAttemptLoader from 'course/video/attemptLoader';
+import { videoHandle, videosHandle } from 'course/video/handles';
+import CourselessContainer from 'lib/containers/CourselessContainer';
+import useTranslation, { Translated } from 'lib/hooks/useTranslation';
+
+import { reservedRoutes } from './redirects';
+import createAppRouter from './router';
+
+const authenticatedRouter: Translated = (t) =>
+ createAppRouter([
+ {
+ path: 'courses/:courseId',
+ element: ,
+ loader: CourseContainer.loader,
+ handle: CourseContainer.handle,
+ shouldRevalidate: ({ currentParams, nextParams }): boolean => {
+ const isChangingCourse = currentParams.courseId !== nextParams.courseId;
+
+ // React Router's documentation never strictly mentioned that `shouldRevalidate`
+ // should be a pure function, but a good software engineer would probably expect
+ // it to be. Until we multi-course support in our Redux store, this is where
+ // we can detect the `courseId` is changing without janky `useEffect`. It should
+ // be safe since `resetStore` does not interfere with rendering or routing.
+ if (isChangingCourse) resetStore();
+
+ return isChangingCourse;
+ },
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'timelines',
+ handle: TimelineDesigner.handle,
+ element: ,
+ },
+ {
+ path: 'announcements',
+ handle: AnnouncementsIndex.handle,
+ element: ,
+ },
+ {
+ path: 'comments',
+ handle: CommentIndex.handle,
+ element: ,
+ },
+ {
+ path: 'leaderboard',
+ handle: LeaderboardIndex.handle,
+ element: ,
+ },
+ {
+ path: 'learning_map',
+ handle: LearningMap.handle,
+ element: ,
+ },
+ {
+ path: 'materials/folders',
+ handle: folderHandle,
+ // `:folderId` must be split this way so that `folderHandle` is matched
+ // to the stable (non-changing) match of `/materials/folders`. This allows
+ // the crumbs in the Workbin to not disappear when revalidated by the
+ // Dynamic Nest API's builder.
+ children: [
+ {
+ path: ':folderId',
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'files/:materialId',
+ loader: materialLoader(t),
+ },
+ ],
+ },
+ ],
+ },
+ {
+ path: 'levels',
+ handle: LevelsIndex.handle,
+ element: ,
+ },
+ {
+ path: 'statistics',
+ handle: StatisticsIndex.handle,
+ element: ,
+ },
+ {
+ path: 'duplication',
+ handle: Duplication.handle,
+ element: ,
+ },
+ {
+ path: 'enrol_requests',
+ handle: manageUserHandles.enrolRequests,
+ element: ,
+ },
+ {
+ path: 'user_invitations',
+ handle: manageUserHandles.invitations,
+ element: ,
+ },
+ {
+ path: 'students',
+ handle: manageUserHandles.students,
+ element: ,
+ },
+ {
+ path: 'staff',
+ handle: manageUserHandles.staff,
+ element: ,
+ },
+ {
+ path: 'lesson_plan',
+ // @ts-ignore `connect` throws error when cannot find `store` as direct parent
+ element: ,
+ handle: LessonPlanLayout.handle,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'edit',
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'users',
+ children: [
+ {
+ index: true,
+ handle: UsersIndex.handle,
+ element: ,
+ },
+ {
+ path: 'personal_times',
+ handle: manageUserHandles.personalizedTimelines,
+ element: ,
+ },
+ {
+ path: 'invite',
+ handle: manageUserHandles.inviteUsers,
+ element: ,
+ },
+ {
+ path: 'disburse_experience_points',
+ handle: DisbursementIndex.handle,
+ element: ,
+ },
+ {
+ path: ':userId',
+ handle: courseUserHandle,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'experience_points_records',
+ handle: ExperiencePointsRecords.handle,
+ element: ,
+ },
+ ],
+ },
+ {
+ path: ':userId/personal_times',
+ handle: courseUserPersonalizedTimelineHandle,
+ element: ,
+ },
+ {
+ path: ':userId/video_submissions',
+ handle: videoWatchHistoryHandle,
+ element: ,
+ },
+ {
+ path: ':userId/manage_email_subscription',
+ handle: UserEmailSubscriptions.handle,
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'admin',
+ loader: SettingsNavigation.loader,
+ handle: SettingsNavigation.handle,
+ element: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'components',
+ element: ,
+ },
+ {
+ path: 'sidebar',
+ element: ,
+ },
+ {
+ path: 'notifications',
+ element: ,
+ },
+ {
+ path: 'announcements',
+ element: ,
+ },
+ {
+ path: 'assessments',
+ element: ,
+ },
+ {
+ path: 'materials',
+ element: ,
+ },
+ {
+ path: 'forums',
+ element: ,
+ },
+ {
+ path: 'leaderboard',
+ element: ,
+ },
+ {
+ path: 'comments',
+ element: ,
+ },
+ {
+ path: 'videos',
+ element: ,
+ },
+ {
+ path: 'lesson_plan',
+ element: ,
+ },
+ {
+ path: 'codaveri',
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'surveys',
+ handle: SurveyIndex.handle,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: ':surveyId',
+ handle: surveyHandle,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'results',
+ handle: SurveyResults.handle,
+ element: ,
+ },
+ {
+ path: 'responses',
+ children: [
+ {
+ index: true,
+ handle: ResponseIndex.handle,
+ element: ,
+ },
+ {
+ path: ':responseId',
+ children: [
+ {
+ index: true,
+ handle: surveyResponseHandle,
+ element: ,
+ },
+ {
+ path: 'edit',
+ handle: ResponseEdit.handle,
+ element: ,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ path: 'groups',
+ element: ,
+ handle: GroupIndex.handle,
+ children: [
+ {
+ path: ':groupCategoryId',
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'videos',
+ handle: videosHandle,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: ':videoId',
+ handle: videoHandle,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'submissions',
+ children: [
+ {
+ index: true,
+ handle: VideoSubmissionsIndex.handle,
+ element: ,
+ },
+ {
+ path: ':submissionId',
+ handle: VideoSubmissionShow.handle,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'edit',
+ element: ,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ path: 'attempt',
+ loader: videoAttemptLoader(t),
+ },
+ ],
+ },
+ ],
+ },
+ {
+ path: 'forums',
+ handle: ForumsIndex.handle,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: ':forumId',
+ handle: forumHandle,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'topics/:topicId',
+ handle: forumTopicHandle,
+ element: ,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ path: 'achievements',
+ handle: AchievementsIndex.handle,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: ':achievementId',
+ handle: achievementHandle,
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'assessments',
+ handle: assessmentsHandle,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'submissions',
+ handle: SubmissionsIndex.handle,
+ element: ,
+ },
+ {
+ path: 'skills',
+ handle: SkillsIndex.handle,
+ element: ,
+ },
+ {
+ path: ':assessmentId',
+ handle: assessmentHandle,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'edit',
+ handle: AssessmentEdit.handle,
+ element: ,
+ },
+ {
+ path: 'attempt',
+ loader: assessmentAttemptLoader(t),
+ },
+ {
+ path: 'monitoring',
+ handle: AssessmentMonitoring.handle,
+ element: ,
+ },
+ {
+ path: 'sessions/new',
+ element: ,
+ },
+ {
+ path: 'statistics',
+ handle: AssessmentStatisticsPage.handle,
+ // @ts-ignore `connect` throws error when cannot find `store` as direct parent
+ element: ,
+ },
+ {
+ path: 'submissions',
+ children: [
+ {
+ index: true,
+ handle: AssessmentSubmissionsIndex.handle,
+ element: ,
+ },
+ {
+ path: ':submissionId',
+ children: [
+ {
+ path: 'edit',
+ handle: SubmissionEditIndex.handle,
+ element: ,
+ },
+ {
+ path: 'logs',
+ handle: LogsIndex.handle,
+ element: ,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ path: 'question',
+ element: ,
+ handle: questionHandle,
+ children: [
+ {
+ path: 'forum_post_responses',
+ children: [
+ {
+ path: 'new',
+ handle: NewForumPostResponsePage.handle,
+ element: ,
+ },
+ {
+ path: ':questionId/edit',
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'text_responses',
+ children: [
+ {
+ path: 'new',
+ handle: NewTextResponse.handle,
+ element: ,
+ },
+ {
+ path: ':questionId/edit',
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'voice_responses',
+ children: [
+ {
+ path: 'new',
+ handle: NewVoicePage.handle,
+ element: ,
+ },
+ {
+ path: ':questionId/edit',
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'multiple_responses',
+ children: [
+ {
+ path: 'new',
+ handle: NewMcqMrqPage.handle,
+ element: ,
+ },
+ {
+ path: ':questionId/edit',
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'scribing',
+ children: [
+ {
+ path: 'new',
+ handle: ScribingQuestion.handle,
+ element: ,
+ },
+ {
+ path: ':questionId/edit',
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'programming',
+ children: [
+ {
+ path: 'new',
+ handle: NewProgrammingQuestionPage.handle,
+ element: ,
+ },
+ {
+ path: ':questionId/edit',
+ element: ,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ path: '/',
+ element: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ ],
+ },
+ {
+ path: '*',
+ element: ,
+ children: [
+ reservedRoutes,
+ {
+ path: 'admin',
+ handle: AdminNavigator.handle,
+ element: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'announcements',
+ element: ,
+ },
+ {
+ path: 'users',
+ element: ,
+ },
+ {
+ path: 'instances',
+ element: ,
+ },
+ {
+ path: 'courses',
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'admin/instance',
+ handle: InstanceAdminNavigator.handle,
+ element: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'announcements',
+ element: ,
+ },
+ {
+ path: 'components',
+ element: ,
+ },
+ {
+ path: 'courses',
+ element: ,
+ },
+ {
+ path: 'users',
+ element: ,
+ },
+ {
+ path: 'users/invite',
+ element: ,
+ },
+ {
+ path: 'user_invitations',
+ element: ,
+ },
+ {
+ path: 'role_requests',
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'announcements',
+ handle: GlobalAnnouncementIndex.handle,
+ element: ,
+ },
+ {
+ path: 'users',
+ children: [
+ {
+ path: 'masquerade',
+ children: [
+ {
+ index: true,
+ loader: masqueradeLoader(t),
+ },
+ {
+ path: 'back',
+ loader: stopMasqueradeLoader(t),
+ },
+ ],
+ },
+ {
+ path: ':userId',
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'user/profile/edit',
+ handle: AccountSettings.handle,
+ element: ,
+ },
+ {
+ path: 'role_requests',
+ element: ,
+ },
+ ],
+ },
+ ]);
+
+const AuthenticatedApp = (): JSX.Element => {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+};
+
+export default AuthenticatedApp;
diff --git a/client/app/routers/UnauthenticatedApp.tsx b/client/app/routers/UnauthenticatedApp.tsx
new file mode 100644
index 00000000000..4c1ba11f814
--- /dev/null
+++ b/client/app/routers/UnauthenticatedApp.tsx
@@ -0,0 +1,109 @@
+import { createBrowserRouter, RouterProvider } from 'react-router-dom';
+
+import LandingPage from 'bundles/common/LandingPage';
+import ConfirmEmailPage from 'bundles/users/pages/ConfirmEmailPage';
+import ForgotPasswordLandingPage from 'bundles/users/pages/ForgotPasswordLandingPage';
+import ForgotPasswordPage from 'bundles/users/pages/ForgotPasswordPage';
+import ResendConfirmationEmailLandingPage from 'bundles/users/pages/ResendConfirmationEmailLandingPage';
+import ResendConfirmationEmailPage from 'bundles/users/pages/ResendConfirmationEmailPage';
+import ResetPasswordPage from 'bundles/users/pages/ResetPasswordPage';
+import SignInPage from 'bundles/users/pages/SignInPage';
+import SignUpLandingPage from 'bundles/users/pages/SignUpLandingPage';
+import SignUpPage from 'bundles/users/pages/SignUpPage';
+import AuthPagesContainer from 'lib/containers/AuthPagesContainer';
+import CourselessContainer from 'lib/containers/CourselessContainer';
+
+import { protectedRoutes } from './redirects';
+import createAppRouter from './router';
+
+const unauthenticatedRouter = createAppRouter([
+ protectedRoutes,
+ {
+ path: '*',
+ element: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'users',
+ element: ,
+ children: [
+ {
+ path: 'sign_in',
+ element: ,
+ },
+ {
+ path: 'sign_up',
+ children: [
+ {
+ index: true,
+ loader: SignUpPage.loader,
+ element: ,
+ },
+ {
+ path: 'completed',
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'confirmation',
+ children: [
+ {
+ index: true,
+ loader: ConfirmEmailPage.loader,
+ element: ,
+ errorElement: ,
+ },
+ {
+ path: 'new',
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'completed',
+ element: ,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ path: 'password',
+ children: [
+ {
+ path: 'new',
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: 'completed',
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'edit',
+ loader: ResetPasswordPage.loader,
+ errorElement: ,
+ element: ,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+]);
+
+const UnauthenticatedApp = (): JSX.Element => (
+
+);
+
+export default UnauthenticatedApp;
diff --git a/client/app/routers/index.ts b/client/app/routers/index.ts
new file mode 100644
index 00000000000..9cd7f5017df
--- /dev/null
+++ b/client/app/routers/index.ts
@@ -0,0 +1 @@
+export { default } from './AuthenticatableApp';
diff --git a/client/app/routers/redirects.tsx b/client/app/routers/redirects.tsx
new file mode 100644
index 00000000000..17cc08d6572
--- /dev/null
+++ b/client/app/routers/redirects.tsx
@@ -0,0 +1,37 @@
+import { RouteObject } from 'react-router-dom';
+
+import { Authenticatable, Redirectable } from 'lib/hooks/router/redirect';
+
+/**
+ * Routes that are only available when the app is unauthenticated.
+ *
+ * For example, `users/:userId` in `AuthenticatedApp` matches the
+ * authentication pages' routes when it shouldn't.
+ */
+export const reservedRoutes: RouteObject = {
+ path: 'users',
+ element: ,
+ children: [
+ { path: 'sign_in/*' },
+ { path: 'sign_up/*' },
+ { path: 'confirmation/*' },
+ { path: 'password/*' },
+ ],
+};
+
+/**
+ * Routes that are only available when the app is authenticated. Accessing
+ * these routes when unauthenticated will trigger a redirectable redirect
+ * to the sign in page.
+ */
+export const protectedRoutes: RouteObject = {
+ path: '*',
+ element: ,
+ children: [
+ { path: 'courses/:courseId/*' },
+ { path: 'admin/*' },
+ { path: 'announcements' },
+ { path: 'users/:userId' },
+ { path: 'user/*' },
+ ],
+};
diff --git a/client/app/routers/router.tsx b/client/app/routers/router.tsx
new file mode 100644
index 00000000000..3a1c9703c91
--- /dev/null
+++ b/client/app/routers/router.tsx
@@ -0,0 +1,66 @@
+import { RouteObject } from 'react-router-dom';
+
+import ErrorPage from 'bundles/common/ErrorPage';
+import PrivacyPolicyPage from 'bundles/common/PrivacyPolicyPage';
+import TermsOfServicePage from 'bundles/common/TermsOfServicePage';
+import CoursesIndex from 'bundles/course/courses/pages/CoursesIndex';
+import AppContainer from 'lib/containers/AppContainer';
+import CourselessContainer from 'lib/containers/CourselessContainer';
+
+const createAppRouter = (router: RouteObject[]): RouteObject[] => [
+ {
+ path: '/',
+ element: ,
+ loader: AppContainer.loader,
+ errorElement: ,
+ shouldRevalidate: (props): boolean => {
+ const isChangingCourse =
+ props.currentParams.courseId !== props.nextParams.courseId;
+ if (isChangingCourse) return true;
+
+ const currentNest = props.currentUrl.pathname.split('/')[1];
+ const nextNest = props.nextUrl.pathname.split('/')[1];
+ return currentNest !== nextNest;
+ },
+ children: [
+ ...router,
+ {
+ path: '*',
+ element: ,
+ children: [
+ {
+ path: 'courses',
+ handle: CoursesIndex.handle,
+ element: ,
+ },
+ {
+ path: 'pages',
+ children: [
+ {
+ path: 'terms_of_service',
+ handle: TermsOfServicePage.handle,
+ element: ,
+ },
+ {
+ path: 'privacy_policy',
+ handle: PrivacyPolicyPage.handle,
+ element: ,
+ },
+ ],
+ },
+ {
+ path: 'forbidden',
+ loader: ErrorPage.Forbidden.loader,
+ element: ,
+ },
+ {
+ path: '*',
+ element: ,
+ },
+ ],
+ },
+ ],
+ },
+];
+
+export default createAppRouter;
diff --git a/client/app/store.ts b/client/app/store.ts
index b1f1e7e9999..26a9767dc87 100644
--- a/client/app/store.ts
+++ b/client/app/store.ts
@@ -12,6 +12,7 @@ import deleteConfirmationReducer from 'lib/reducers/deleteConfirmation';
import notificationPopupReducer from 'lib/reducers/notificationPopup';
import globalAnnouncementReducer from './bundles/announcements/store';
+import sessionReducer from './bundles/common/store';
import achievementsReducer from './bundles/course/achievement/store';
import lessonPlanSettingsReducer from './bundles/course/admin/reducers/lessonPlanSettings';
import notificationSettingsReducer from './bundles/course/admin/reducers/notificationSettings';
@@ -85,6 +86,7 @@ const rootReducer = combineReducers({
user: globalUserReducer,
announcements: globalAnnouncementReducer,
}),
+ session: sessionReducer,
// The following reducers are for UI related rendering.
// TODO: remove these (avoid using redux to render UI components)
@@ -94,8 +96,13 @@ const rootReducer = combineReducers({
const RESET_STORE_ACTION_TYPE = 'RESET_STORE_BOOM';
-const purgeableRootReducer: Reducer = (state, action) => {
- if (action.type === RESET_STORE_ACTION_TYPE) state = undefined;
+const purgeableRootReducer: Reducer = (state, action) => {
+ if (action.type === RESET_STORE_ACTION_TYPE) {
+ // `session` is generally NOT ephemeral. If `session` is accidentally
+ // purged without intuition, the router may flicker and break.
+ state = { session: state?.session } as AppState;
+ }
+
return rootReducer(state, action);
};
diff --git a/client/app/theme/bouncing-dot.css b/client/app/theme/bouncing-dot.css
new file mode 100644
index 00000000000..4bd3f4bfda2
--- /dev/null
+++ b/client/app/theme/bouncing-dot.css
@@ -0,0 +1,29 @@
+.bouncing-dot {
+ display: inline-block;
+ animation-name: bouncing-dot-bounce;
+ animation-duration: 700ms;
+ animation-iteration-count: infinite;
+ animation-timing-function: ease-in-out;
+}
+
+.bouncing-dot:nth-child(2) {
+ animation-delay: 125ms;
+}
+
+.bouncing-dot:nth-child(3) {
+ animation-delay: 250ms;
+}
+
+@keyframes bouncing-dot-bounce {
+ 0% {
+ transform: none;
+ }
+
+ 33% {
+ transform: translateY(-0.2em);
+ }
+
+ 66% {
+ transform: none;
+ }
+}
diff --git a/client/app/theme/github.css b/client/app/theme/github.css
new file mode 100644
index 00000000000..3c6b05fca89
--- /dev/null
+++ b/client/app/theme/github.css
@@ -0,0 +1,61 @@
+.codehilite .hll { background-color: #ffffcc }
+.codehilite .c { color: #999988; font-style: italic } /* Comment */
+.codehilite .err { color: #a61717; background-color: #e3d2d2 } /* Error */
+.codehilite .k { color: #000000; font-weight: bold } /* Keyword */
+.codehilite .o { color: #000000; font-weight: bold } /* Operator */
+.codehilite .cm { color: #999988; font-style: italic } /* Comment.Multiline */
+.codehilite .cp { color: #999999; font-weight: bold; font-style: italic } /* Comment.Preproc */
+.codehilite .c1 { color: #999988; font-style: italic } /* Comment.Single */
+.codehilite .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */
+.codehilite .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */
+.codehilite .ge { color: #000000; font-style: italic } /* Generic.Emph */
+.codehilite .gr { color: #aa0000 } /* Generic.Error */
+.codehilite .gh { color: #999999 } /* Generic.Heading */
+.codehilite .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */
+.codehilite .go { color: #888888 } /* Generic.Output */
+.codehilite .gp { color: #555555 } /* Generic.Prompt */
+.codehilite .gs { font-weight: bold } /* Generic.Strong */
+.codehilite .gu { color: #aaaaaa } /* Generic.Subheading */
+.codehilite .gt { color: #aa0000 } /* Generic.Traceback */
+.codehilite .kc { color: #000000; font-weight: bold } /* Keyword.Constant */
+.codehilite .kd { color: #000000; font-weight: bold } /* Keyword.Declaration */
+.codehilite .kn { color: #000000; font-weight: bold } /* Keyword.Namespace */
+.codehilite .kp { color: #000000; font-weight: bold } /* Keyword.Pseudo */
+.codehilite .kr { color: #000000; font-weight: bold } /* Keyword.Reserved */
+.codehilite .kt { color: #445588; font-weight: bold } /* Keyword.Type */
+.codehilite .m { color: #009999 } /* Literal.Number */
+.codehilite .s { color: #d01040 } /* Literal.String */
+.codehilite .na { color: #008080 } /* Name.Attribute */
+.codehilite .nb { color: #0086B3 } /* Name.Builtin */
+.codehilite .nc { color: #445588; font-weight: bold } /* Name.Class */
+.codehilite .no { color: #008080 } /* Name.Constant */
+.codehilite .nd { color: #3c5d5d; font-weight: bold } /* Name.Decorator */
+.codehilite .ni { color: #800080 } /* Name.Entity */
+.codehilite .ne { color: #990000; font-weight: bold } /* Name.Exception */
+.codehilite .nf { color: #990000; font-weight: bold } /* Name.Function */
+.codehilite .nl { color: #990000; font-weight: bold } /* Name.Label */
+.codehilite .nn { color: #555555 } /* Name.Namespace */
+.codehilite .nt { color: #000080 } /* Name.Tag */
+.codehilite .nv { color: #008080 } /* Name.Variable */
+.codehilite .ow { color: #000000; font-weight: bold } /* Operator.Word */
+.codehilite .w { color: #bbbbbb } /* Text.Whitespace */
+.codehilite .mf { color: #009999 } /* Literal.Number.Float */
+.codehilite .mh { color: #009999 } /* Literal.Number.Hex */
+.codehilite .mi { color: #009999 } /* Literal.Number.Integer */
+.codehilite .mo { color: #009999 } /* Literal.Number.Oct */
+.codehilite .sb { color: #d01040 } /* Literal.String.Backtick */
+.codehilite .sc { color: #d01040 } /* Literal.String.Char */
+.codehilite .sd { color: #d01040 } /* Literal.String.Doc */
+.codehilite .s2 { color: #d01040 } /* Literal.String.Double */
+.codehilite .se { color: #d01040 } /* Literal.String.Escape */
+.codehilite .sh { color: #d01040 } /* Literal.String.Heredoc */
+.codehilite .si { color: #d01040 } /* Literal.String.Interpol */
+.codehilite .sx { color: #d01040 } /* Literal.String.Other */
+.codehilite .sr { color: #009926 } /* Literal.String.Regex */
+.codehilite .s1 { color: #d01040 } /* Literal.String.Single */
+.codehilite .ss { color: #990073 } /* Literal.String.Symbol */
+.codehilite .bp { color: #999999 } /* Name.Builtin.Pseudo */
+.codehilite .vc { color: #008080 } /* Name.Variable.Class */
+.codehilite .vg { color: #008080 } /* Name.Variable.Global */
+.codehilite .vi { color: #008080 } /* Name.Variable.Instance */
+.codehilite .il { color: #009999 } /* Literal.Number.Integer.Long */
diff --git a/client/app/theme/index.css b/client/app/theme/index.css
index 4feebe69d02..b39720e29f6 100644
--- a/client/app/theme/index.css
+++ b/client/app/theme/index.css
@@ -2,82 +2,66 @@
@tailwind components;
@tailwind utilities;
-@layer utilities {
- /* TODO: Remove `visible` and `invisible` once `visibility` is
- re-enabled in tailwind.config.js. */
- .visible {
- visibility: visible;
- }
+@layer base {
+ html {
+ font-size: 10px;
- .invisible {
- visibility: hidden;
+ /* Undoes MUI's font smoothing via the `CssBaseline` preflight
+ See https://mui.com/material-ui/react-css-baseline/#typography */
+ -webkit-font-smoothing: auto !important;
+ -moz-osx-font-smoothing: auto !important;
}
- .key {
- @apply rounded-xl border border-solid px-2 py-0.5;
+ strong {
+ @apply font-semibold;
}
- /* For Firefox 64+ and Firefox for Android 64+ */
- .scrollbar-hidden {
- scrollbar-width: none;
+ ul {
+ @apply list-disc;
}
- /* For Blink- and WebKit-based browsers */
- .scrollbar-hidden::-webkit-scrollbar {
- display: none;
+ code,
+ pre {
+ font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
}
-}
-
-.sidebar-handle-enter {
- animation-timing-function: ease-in-out;
- animation-name: sidebar-handle-emphasize;
- animation-duration: 400ms;
- animation-delay: -50ms;
-}
-@keyframes sidebar-handle-emphasize {
- 0% {
- transform: translateX(-200%);
+ code:not(pre code) {
+ @apply rounded-lg bg-fuchsia-100 px-2 py-1 text-fuchsia-700;
}
- 20% {
- transform: translateX(0.5rem);
- animation-timing-function: cubic-bezier(0.68, -0.6, 0.32, 1.6);
- }
- 100% {
- transform: translateX(0);
- }
-}
-.sidebar-handle-exit {
- @apply -translate-x-[200%] scale-y-0 transition-all;
-}
-
-.bouncing-dot {
- display: inline-block;
- animation-name: bouncing-dot-bounce;
- animation-duration: 700ms;
- animation-iteration-count: infinite;
- animation-timing-function: ease-in-out;
-}
+ pre {
+ @apply rounded-lg border border-solid border-neutral-300 bg-neutral-100 p-4;
+ }
-.bouncing-dot:nth-child(2) {
- animation-delay: 125ms;
-}
+ blockquote {
+ @apply m-0 border-0 border-l-4 border-solid border-neutral-200 px-6 text-neutral-500;
+ }
-.bouncing-dot:nth-child(3) {
- animation-delay: 250ms;
+ button,
+ [type='button'],
+ [type='reset'],
+ [type='submit'],
+ [role='button'] {
+ @apply cursor-pointer;
+ }
}
-@keyframes bouncing-dot-bounce {
- 0% {
- transform: none;
+@layer utilities {
+ .key {
+ @apply rounded-xl border border-solid px-2 py-0.5;
}
- 33% {
- transform: translateY(-0.2em);
+ /* For Firefox 64+ and Firefox for Android 64+ */
+ .scrollbar-hidden {
+ scrollbar-width: none;
}
- 66% {
- transform: none;
+ /* For Blink- and WebKit-based browsers */
+ .scrollbar-hidden::-webkit-scrollbar {
+ display: none;
}
}
+
+@import './bouncing-dot.css';
+@import './sidebar.css';
+@import './syntax-highlighting.css';
diff --git a/client/app/theme/sidebar.css b/client/app/theme/sidebar.css
new file mode 100644
index 00000000000..3fef9bb82d8
--- /dev/null
+++ b/client/app/theme/sidebar.css
@@ -0,0 +1,23 @@
+.sidebar-handle-enter {
+ animation-timing-function: ease-in-out;
+ animation-name: sidebar-handle-emphasize;
+ animation-duration: 400ms;
+ animation-delay: -50ms;
+}
+
+@keyframes sidebar-handle-emphasize {
+ 0% {
+ transform: translateX(-200%);
+ }
+ 20% {
+ transform: translateX(0.5rem);
+ animation-timing-function: cubic-bezier(0.68, -0.6, 0.32, 1.6);
+ }
+ 100% {
+ transform: translateX(0);
+ }
+}
+
+.sidebar-handle-exit {
+ @apply -translate-x-[200%] scale-y-0 transition-all;
+}
diff --git a/client/app/theme/syntax-highlighting.css b/client/app/theme/syntax-highlighting.css
new file mode 100644
index 00000000000..ef6c3fb488c
--- /dev/null
+++ b/client/app/theme/syntax-highlighting.css
@@ -0,0 +1,38 @@
+@import './github.css';
+
+table.codehilite tr td {
+ border: 0;
+ padding: 0;
+ font-size: 13px;
+ line-height: 1.428571429;
+}
+
+table.codehilite tr td.line-number {
+ width: 1%;
+}
+
+table.codehilite tr td.line-number::before {
+ content: attr(data-line-number);
+ padding-right: 20px;
+}
+
+table.codehilite pre {
+ background-color: transparent;
+ border: 0;
+ margin: 0;
+ padding: 0;
+ word-break: break-all;
+ word-wrap: break-word;
+}
+
+table.codehilite.table {
+ width: 100%;
+ max-width: 100%;
+ margin-bottom: 20px;
+ border-collapse: collapse;
+ border-spacing: 0;
+}
+
+div.highlight {
+ overflow: scroll;
+}
diff --git a/client/app/types/course/video/submissions.ts b/client/app/types/course/video/submissions.ts
index f55be382953..537c8c20211 100644
--- a/client/app/types/course/video/submissions.ts
+++ b/client/app/types/course/video/submissions.ts
@@ -29,3 +29,7 @@ export interface VideoEditSubmissionData {
videoDescription: string;
videoData: object;
}
+
+export interface VideoSubmissionAttemptData {
+ submissionId: number;
+}
diff --git a/client/app/types/home.ts b/client/app/types/home.ts
index 6b807b45974..0bcb29c5a44 100644
--- a/client/app/types/home.ts
+++ b/client/app/types/home.ts
@@ -2,17 +2,29 @@ import { InstanceUserRoles } from './system/instance/users';
import { UserRoles } from './users';
interface HomeLayoutUserData {
+ id: number;
name: string;
+ primaryEmail: string;
url: string;
avatarUrl: string;
role: UserRoles;
instanceRole: InstanceUserRoles;
+ canCreateNewCourse: boolean;
+}
+
+export interface HomeLayoutCourseData {
+ id: number;
+ title: string;
+ url: string;
+ logoUrl: string;
+ lastActiveAt: string | null;
}
export interface HomeLayoutData {
- courses?: { title: string; url: string }[];
+ locale: string;
+ timeZone: string | null;
+ courses?: HomeLayoutCourseData[];
user?: HomeLayoutUserData;
- signOutUrl?: string;
masqueradeUserName?: string;
stopMasqueradingUrl?: string;
}
diff --git a/client/app/types/users.ts b/client/app/types/users.ts
index d14919547fc..4cf2bf7a8da 100644
--- a/client/app/types/users.ts
+++ b/client/app/types/users.ts
@@ -86,7 +86,7 @@ export type Locale = 'en' | 'zh';
export interface ProfileData {
id: string;
name: string;
- timezone: string;
+ timeZone: string;
locale: string;
imageUrl: string;
availableLocales: Locale[];
@@ -114,7 +114,7 @@ export interface PasswordData {
export interface ProfilePostData {
user: {
name?: ProfileData['name'];
- time_zone?: ProfileData['timezone'];
+ time_zone?: ProfileData['timeZone'];
locale?: ProfileData['locale'];
profile_photo?: ProfileData['imageUrl'];
};
@@ -133,3 +133,10 @@ export interface PasswordPostData {
password_confirmation?: PasswordData['passwordConfirmation'];
};
}
+
+export interface InvitedSignUpData {
+ name: string;
+ email: string;
+ courseTitle: string;
+ courseId: string;
+}
diff --git a/client/app/utilities/index.ts b/client/app/utilities/index.ts
index 06fcc26e61d..51f6cc66860 100644
--- a/client/app/utilities/index.ts
+++ b/client/app/utilities/index.ts
@@ -1,2 +1,12 @@
export const getIdFromUnknown = (id?: string | null): number | undefined =>
parseInt(id ?? '', 10) || undefined;
+
+export const getMailtoURLWithBody = (
+ email: string,
+ subject: string,
+ body: string,
+): string => {
+ const encodedSubject = encodeURIComponent(subject);
+ const encodedBody = encodeURIComponent(body);
+ return `mailto:${email}?subject=${encodedSubject}&body=${encodedBody}`;
+};
diff --git a/client/env b/client/env
new file mode 100644
index 00000000000..5a773c55802
--- /dev/null
+++ b/client/env
@@ -0,0 +1,5 @@
+GOOGLE_RECAPTCHA_SITE_KEY = ""
+ROLLBAR_POST_CLIENT_ITEM_KEY = ""
+SUPPORT_EMAIL = ""
+DEFAULT_LOCALE = "en"
+DEFAULT_TIME_ZONE = "Asia/Singapore"
diff --git a/app/assets/images/favicon.svg b/client/favicon.svg
similarity index 100%
rename from app/assets/images/favicon.svg
rename to client/favicon.svg
diff --git a/client/jest.config.js b/client/jest.config.js
index c07ff4d3a3c..2732714586f 100644
--- a/client/jest.config.js
+++ b/client/jest.config.js
@@ -11,6 +11,7 @@ const config = {
snapshotSerializers: ['/node_modules/enzyme-to-json/serializer'],
moduleNameMapper: {
'\\.(css|scss)$': '/app/__test__/mocks/fileMock.js',
+ '^assets(.*)$': '/app/__test__/mocks/svgMock.js',
'.svg$': '/app/__test__/mocks/svgMock.js',
'^mocks(.*)$': '/app/__test__/mocks$1',
'^test-utils(.*)$': '/app/utilities/test-utils$1',
diff --git a/client/package.json b/client/package.json
index 90963bf8d05..1213fff4eaf 100644
--- a/client/package.json
+++ b/client/package.json
@@ -3,13 +3,13 @@
"version": "2.0.0",
"description": "Coursemology Frontend",
"engines": {
- "node": ">=5.10.0",
+ "node": ">=18.17.0",
"yarn": "^1.0.0"
},
"scripts": {
"test": "TZ=Asia/Singapore yarn run jest",
- "testci": "TZ=Asia/Singapore yarn run jest --maxWorkers=4",
- "build:test": "export NODE_ENV=test && yarn run build:translations && webpack -w --node-env=production --config webpack.prod.js",
+ "testci": "TZ=Asia/Singapore yarn run jest --maxWorkers=4 --collectCoverage=true",
+ "build:test": "export NODE_ENV=test && export BABEL_ENV=e2e-test && yarn run build:translations && webpack --node-env=production --config webpack.prod.js",
"build:production": "export NODE_ENV=production && yarn run build:translations && webpack --node-env=production --config webpack.prod.js",
"build:development": "yarn run build:translations && webpack serve --config webpack.dev.js",
"build:profile": "yarn run build:translations && webpack serve --config webpack.profile.js --progress=profile",
@@ -54,10 +54,8 @@
"chart.js": "^3.8.2",
"chartjs-adapter-moment": "^1.0.1",
"chartjs-plugin-zoom": "^2.0.1",
- "dotenv-webpack": "^8.0.1",
"fabric": "^5.3.0",
"fast-deep-equal": "^3.1.3",
- "glob": "^10.3.3",
"history": "^5.2.0",
"idb": "^7.1.1",
"immer": "^10.0.2",
@@ -67,7 +65,6 @@
"jquery-ui": "^1.13.2",
"lodash": "^4.17.21",
"mirror-creator": "1.1.0",
- "mkdirp": "^3.0.1",
"moment": "^2.29.4",
"moment-timezone": "^0.5.43",
"mui-datatables": "^4.3.0",
@@ -82,14 +79,16 @@
"react-draggable": "^4.4.5",
"react-dropzone": "^14.2.3",
"react-emitter-factory": "^1.1.2",
+ "react-google-recaptcha": "^3.1.0",
"react-hook-form": "^7.43.9",
"react-hot-keys": "^2.7.2",
"react-image-crop": "^10.1.5",
"react-intl": "^6.4.4",
+ "react-markdown": "^8.0.7",
"react-player": "^2.12.0",
"react-redux": "^8.1.2",
"react-resizable": "^3.0.5",
- "react-router-dom": "^6.11.1",
+ "react-router-dom": "^6.14.1",
"react-scroll": "^1.8.9",
"react-toastify": "^9.1.3",
"react-tooltip": "^5.19.0",
@@ -102,7 +101,6 @@
"redux-persist": "^6.0.0",
"redux-thunk": "^2.4.2",
"rollbar": "^2.26.2",
- "sass": "^1.64.1",
"webfontloader": "^1.6.28",
"yup": "^0.32.11"
},
@@ -130,7 +128,9 @@
"@types/react": "^18.2.17",
"@types/react-beautiful-dnd": "^13.1.4",
"@types/react-dom": "^18.2.7",
+ "@types/react-google-recaptcha": "^2.1.5",
"@types/react-resizable": "^3.0.4",
+ "@types/react-scroll": "^1.8.7",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/sharedworker": "^0.0.101",
@@ -142,11 +142,13 @@
"babel-jest": "^29.6.2",
"babel-loader": "^9.0.0",
"babel-plugin-import": "^1.13.6",
+ "babel-plugin-istanbul": "^6.1.1",
"babel-plugin-lodash": "^3.3.4",
"babel-plugin-react-remove-properties": "^0.3.0",
"babel-plugin-transform-import-meta": "^2.2.0",
"css-loader": "^6.8.1",
"cssnano": "^6.0.1",
+ "dotenv-webpack": "^8.0.1",
"enzyme": "^3.11.0",
"enzyme-to-json": "^3.6.2",
"eslint": "^8.46.0",
@@ -166,17 +168,23 @@
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-sonarjs": "^0.19.0",
"expose-loader": "^4.1.0",
+ "favicons": "^7.1.4",
+ "favicons-webpack-plugin": "^6.0.0",
"fork-ts-checker-webpack-plugin": "^8.0.0",
+ "glob": "^10.3.3",
+ "html-webpack-plugin": "^5.5.3",
"jest": "^29.6.2",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.6.2",
"jest-localstorage-mock": "^2.4.26",
+ "mkdirp": "^3.0.1",
"postcss": "^8.4.27",
"postcss-loader": "^7.3.3",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.4.1",
"react-refresh": "^0.14.0",
"redux-logger": "^3.0.6",
+ "sass": "^1.64.1",
"sass-loader": "^13.3.2",
"style-loader": "^3.3.3",
"tailwindcss": "^3.3.3",
@@ -194,5 +202,9 @@
"license": "MIT",
"firstBuildYear": 2013,
"repository": "git+https://github.com/Coursemology/coursemology2.git",
- "main": "app/index.js"
+ "main": "app/index.js",
+ "devServer": {
+ "appHost": "lvh.me",
+ "serverPort": 5000
+ }
}
diff --git a/client/public/index.html b/client/public/index.html
new file mode 100644
index 00000000000..d4f9b4274d9
--- /dev/null
+++ b/client/public/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Coursemology
+
+
+ You need to enable JavaScript to run this app.
+
+
+
diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts
index 535f4c65cae..9ddae234a0c 100644
--- a/client/tailwind.config.ts
+++ b/client/tailwind.config.ts
@@ -39,16 +39,15 @@ export default {
},
colors: {
primary: palette.primary.main,
+ success: palette.success.main,
+ error: palette.error.main,
+ warning: palette.warning.main,
+ info: palette.info.main,
},
},
},
corePlugins: {
preflight: false,
-
- // TODO: Re-enable once Bootstrap components are purged
- // Temporarily disabled because Tailwind 3.2.0 adds a new `collapse` utility
- // that conflicts with Bootstrap's Collapse component used in our sidebar.
- visibility: false,
},
plugins: [
plugin(({ addVariant }) => {
@@ -101,7 +100,7 @@ export default {
plugin(({ matchUtilities, theme }) => {
matchUtilities(
{ wh: (value) => ({ width: value, height: value }) },
- { values: theme('spacing'), type: 'number' },
+ { values: theme('spacing'), type: 'any' },
);
}),
plugin(
@@ -130,5 +129,5 @@ export default {
},
),
],
- important: '#root',
+ important: '#body',
} satisfies Config;
diff --git a/client/tsconfig.json b/client/tsconfig.json
index 967e6b6411e..6a56c9ced94 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -29,7 +29,8 @@
"paths": {
"lib/*": ["lib/*"],
"course/*": ["bundles/course/*"],
- "test-utils": ["utilities/test-utils"]
+ "test-utils": ["utilities/test-utils"],
+ "mocks/*": ["__test__/mocks/*"]
},
"pretty": true,
"resolveJsonModule": true,
diff --git a/client/webpack.common.js b/client/webpack.common.js
index 804e3e3065b..2d644b71ff5 100644
--- a/client/webpack.common.js
+++ b/client/webpack.common.js
@@ -5,8 +5,10 @@ const {
DefinePlugin,
} = require('webpack');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
-const Dotenv = require('dotenv-webpack');
+const DotenvPlugin = require('dotenv-webpack');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
const packageJSON = require('./package.json');
@@ -20,7 +22,8 @@ module.exports = {
],
},
output: {
- path: join(__dirname, '..', 'public', 'webpack'),
+ path: join(__dirname, 'build'),
+ publicPath: '/',
},
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
@@ -67,12 +70,14 @@ module.exports = {
moduleIds: 'deterministic',
},
plugins: [
- new Dotenv(),
+ new DotenvPlugin(),
new IgnorePlugin({ resourceRegExp: /__test__/ }),
new WebpackManifestPlugin({
publicPath: '/webpack/',
writeToFileEmit: true,
}),
+ new HtmlWebpackPlugin({ template: './public/index.html' }),
+ new FaviconsWebpackPlugin({ logo: './favicon.svg', inject: true }),
// Do not require all locales in moment
new ContextReplacementPlugin(/moment\/locale$/, /^\.\/(en-.*|zh-.*)$/),
new ForkTsCheckerWebpackPlugin({
@@ -170,6 +175,10 @@ module.exports = {
exposes: 'moment',
},
},
+ {
+ test: /\.md$/,
+ type: 'asset/source',
+ },
],
},
};
diff --git a/client/webpack.dev.js b/client/webpack.dev.js
index a3c2d804df0..b5f2e9c8008 100644
--- a/client/webpack.dev.js
+++ b/client/webpack.dev.js
@@ -2,33 +2,52 @@ const { merge } = require('webpack-merge');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const common = require('./webpack.common');
+const packageJSON = require('./package.json');
-const DEV_SERVER_PORT = 8080;
-const DEFAULT_LOCALHOST_HOST = 'localhost:5000';
+const SERVER_PORT = packageJSON.devServer.serverPort;
+const APP_HOST = packageJSON.devServer.appHost;
+
+const BLUE_ANSI = '\x1b[36m%s\x1b[0m';
+
+const logProxy = (source, destination) =>
+ console.info(BLUE_ANSI, `[proxy] ${source} -> ${destination}`);
module.exports = merge(common, {
mode: 'development',
- output: {
- filename: '[name].js',
- pathinfo: false,
- publicPath: `//localhost:${DEV_SERVER_PORT}/webpack/`,
-
- /**
- * If the host name of the app (e.g., `localhost:5000`) is different from
- * that of webpack-dev-server's (e.g., `localhost:8080`), worker scripts
- * and assets packed by webpack will be hosted under webpack-dev-server's
- * host name. Accessing these resources from the app's host name will
- * trigger `SecurityError` in-browser due to the different origins. Forcing
- * webpack-dev-server's `publicPath` to the app's host name bypasses this,
- * but may not work if the app is hosted on multiple different domains,
- * e.g., on both `localhost` and ngrok.
- */
- workerPublicPath: `//${DEFAULT_LOCALHOST_HOST}/webpack/`,
- },
devtool: 'eval-cheap-module-source-map',
devServer: {
- port: DEV_SERVER_PORT,
- headers: { 'Access-Control-Allow-Origin': '*' },
+ allowedHosts: [`.${APP_HOST}`],
+ historyApiFallback: true,
+ devMiddleware: {
+ index: false,
+ },
+ proxy: {
+ context: () => true,
+ changeOrigin: true,
+ onProxyReq: (proxyReq) => {
+ proxyReq.setHeader('origin', `http://${proxyReq.host}:${SERVER_PORT}`);
+ },
+ router: (request) => ({
+ protocol: 'http:',
+ host: request.headers.host.split(':')[0],
+ port: SERVER_PORT,
+ }),
+ bypass: (request) => {
+ const target = `${request.headers.host.split(':')[0]}:${SERVER_PORT}`;
+
+ const isExplicitJSON = request.query.format === 'json';
+ const isAttachment =
+ request.url.startsWith('/uploads') ||
+ request.url.startsWith('/attachments');
+
+ if (isExplicitJSON || isAttachment) {
+ logProxy(request.url, `${target}${request.url}`);
+ return null;
+ }
+
+ return '/index.html';
+ },
+ },
},
optimization: {
removeAvailableModules: false,
diff --git a/client/webpack.prod.js b/client/webpack.prod.js
index 90349c2fee6..4946a154698 100644
--- a/client/webpack.prod.js
+++ b/client/webpack.prod.js
@@ -6,8 +6,7 @@ module.exports = merge(common, {
mode: 'production',
devtool: 'source-map',
output: {
- filename: '[name]-[contenthash].js',
- publicPath: '/webpack/',
+ publicPath: '/static/',
},
optimization: {
usedExports: true,
diff --git a/client/yarn.lock b/client/yarn.lock
index afba1ef7cba..0213bd14a9d 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -1714,6 +1714,14 @@
"@jridgewell/gen-mapping" "^0.3.0"
"@jridgewell/trace-mapping" "^0.3.9"
+"@jridgewell/source-map@^0.3.3":
+ version "0.3.3"
+ resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.3.tgz#8108265659d4c33e72ffe14e33d6cc5eb59f2fda"
+ integrity sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==
+ dependencies:
+ "@jridgewell/gen-mapping" "^0.3.0"
+ "@jridgewell/trace-mapping" "^0.3.9"
+
"@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10":
version "1.4.14"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
@@ -1965,10 +1973,10 @@
redux-thunk "^2.4.2"
reselect "^4.1.8"
-"@remix-run/router@1.6.2":
- version "1.6.2"
- resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.6.2.tgz#bbe75f8c59e0b7077584920ce2cc76f8f354934d"
- integrity sha512-LzqpSrMK/3JBAVBI9u3NWtOhWNw5AMQfrUFYB0+bDHTSw17z++WJLsPsxAuK+oSddsxk4d7F/JcdDPM1M5YAhA==
+"@remix-run/router@1.7.1":
+ version "1.7.1"
+ resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.7.1.tgz#fea7ac35ae4014637c130011f59428f618730498"
+ integrity sha512-bgVQM4ZJ2u2CM8k1ey70o1ePFXsEzYVZoWghh6WjM8p59jQ7HxzbHW4SbnWFG7V9ig9chLawQxDTZ3xzOF8MkQ==
"@rollbar/react@^0.11.2":
version "0.11.2"
@@ -2266,6 +2274,13 @@
dependencies:
"@types/node" "*"
+"@types/debug@^4.0.0":
+ version "4.1.8"
+ resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.8.tgz#cef723a5d0a90990313faec2d1e22aee5eecb317"
+ integrity sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==
+ dependencies:
+ "@types/ms" "*"
+
"@types/enzyme@^3.10.13":
version "3.10.13"
resolved "https://registry.yarnpkg.com/@types/enzyme/-/enzyme-3.10.13.tgz#332c0ed59b01f7b1c398c532a1c21a5feefabeb1"
@@ -2321,6 +2336,13 @@
dependencies:
"@types/node" "*"
+"@types/hast@^2.0.0":
+ version "2.3.4"
+ resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.4.tgz#8aa5ef92c117d20d974a82bdfb6a648b08c0bafc"
+ integrity sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==
+ dependencies:
+ "@types/unist" "*"
+
"@types/hoist-non-react-statics@^3.3.1":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
@@ -2329,6 +2351,11 @@
"@types/react" "*"
hoist-non-react-statics "^3.3.0"
+"@types/html-minifier-terser@^6.0.0":
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35"
+ integrity sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==
+
"@types/http-proxy@^1.17.8":
version "1.17.9"
resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.9.tgz#7f0e7931343761efde1e2bf48c40f02f3f75705a"
@@ -2399,11 +2426,23 @@
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2"
integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==
+"@types/mdast@^3.0.0":
+ version "3.0.11"
+ resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.11.tgz#dc130f7e7d9306124286f6d6cee40cf4d14a3dc0"
+ integrity sha512-Y/uImid8aAwrEA24/1tcRZwpxX3pIFTSilcNDKSPn+Y2iDywSEachzRuvgAYYLR3wpGXAsMbv5lvKLDZLeYPAw==
+ dependencies:
+ "@types/unist" "*"
+
"@types/mime@^1":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
+"@types/ms@*":
+ version "0.7.31"
+ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
+ integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
+
"@types/node@*":
version "18.0.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.0.0.tgz#67c7b724e1bcdd7a8821ce0d5ee184d3b4dd525a"
@@ -2426,7 +2465,7 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
-"@types/prop-types@*", "@types/prop-types@^15.7.3", "@types/prop-types@^15.7.5":
+"@types/prop-types@*", "@types/prop-types@^15.0.0", "@types/prop-types@^15.7.3", "@types/prop-types@^15.7.5":
version "15.7.5"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
@@ -2460,6 +2499,13 @@
dependencies:
"@types/react" "*"
+"@types/react-google-recaptcha@^2.1.5":
+ version "2.1.5"
+ resolved "https://registry.yarnpkg.com/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.5.tgz#af157dc2e4bde3355f9b815a64f90e85cfa9df8b"
+ integrity sha512-iWTjmVttlNgp0teyh7eBXqNOQzVq2RWNiFROWjraOptRnb1OcHJehQnji0sjqIRAk9K0z8stjyhU+OLpPb0N6w==
+ dependencies:
+ "@types/react" "*"
+
"@types/react-is@^18.2.1":
version "18.2.1"
resolved "https://registry.yarnpkg.com/@types/react-is/-/react-is-18.2.1.tgz#61d01c2a6fc089a53520c0b66996d458fdc46863"
@@ -2474,6 +2520,13 @@
dependencies:
"@types/react" "*"
+"@types/react-scroll@^1.8.7":
+ version "1.8.7"
+ resolved "https://registry.yarnpkg.com/@types/react-scroll/-/react-scroll-1.8.7.tgz#7241c6ccd47839d79227a23a5184d727245c7324"
+ integrity sha512-BB8g+hQL7OtBPWg/NcES6p5u6vduZonGl1BxrsGUwcefE53pfI0pFDd1lRFndgEUE6whYdFfhD+j0sZZT/6brQ==
+ dependencies:
+ "@types/react" "*"
+
"@types/react-transition-group@^4.4.5", "@types/react-transition-group@^4.4.6":
version "4.4.6"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.6.tgz#18187bcda5281f8e10dfc48f0943e2fdf4f75e2e"
@@ -2577,6 +2630,11 @@
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397"
integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==
+"@types/unist@*", "@types/unist@^2.0.0":
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
+ integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==
+
"@types/use-sync-external-store@^0.0.3":
version "0.0.3"
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43"
@@ -2917,7 +2975,12 @@ acorn@^7.1.1:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
-acorn@^8.1.0, acorn@^8.5.0, acorn@^8.7.1, acorn@^8.8.0, acorn@^8.9.0:
+acorn@^8.1.0, acorn@^8.5.0, acorn@^8.7.1, acorn@^8.8.0:
+ version "8.8.1"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.1.tgz#0a3f9cbecc4ec3bea6f0a80b66ae8dd2da250b73"
+ integrity sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==
+
+acorn@^8.8.2, acorn@^8.9.0:
version "8.10.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5"
integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==
@@ -3158,6 +3221,11 @@ attr-accept@^2.2.2:
resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b"
integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==
+author-regex@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/author-regex/-/author-regex-1.0.0.tgz#d08885be6b9bbf9439fe087c76287245f0a81450"
+ integrity sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==
+
autoprefixer@^10.4.14:
version "10.4.14"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.14.tgz#e28d49902f8e759dd25b153264e862df2705f79d"
@@ -3204,6 +3272,11 @@ axobject-query@^3.1.1:
dependencies:
deep-equal "^2.0.5"
+b4a@^1.6.4:
+ version "1.6.4"
+ resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.4.tgz#ef1c1422cae5ce6535ec191baeed7567443f36c9"
+ integrity sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==
+
babel-jest@^29.6.2:
version "29.6.2"
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.6.2.tgz#cada0a59e07f5acaeb11cbae7e3ba92aec9c1126"
@@ -3353,11 +3426,21 @@ babel-preset-jest@^29.5.0:
babel-plugin-jest-hoist "^29.5.0"
babel-preset-current-node-syntax "^1.0.0"
+bail@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/bail/-/bail-2.0.2.tgz#d26f5cd8fe5d6f832a31517b9f7c356040ba6d5d"
+ integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==
+
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+base64-js@^1.3.1:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
+ integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
+
batch@0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16"
@@ -3373,6 +3456,15 @@ binary-extensions@^2.0.0:
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
+bl@^4.0.3:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
+ integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
+ dependencies:
+ buffer "^5.5.0"
+ inherits "^2.0.4"
+ readable-stream "^3.4.0"
+
body-parser@1.20.0:
version "1.20.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5"
@@ -3462,6 +3554,14 @@ buffer-from@^1.0.0:
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
+buffer@^5.5.0:
+ version "5.7.1"
+ resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
+ integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
+ dependencies:
+ base64-js "^1.3.1"
+ ieee754 "^1.1.13"
+
bytes@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
@@ -3485,6 +3585,14 @@ callsites@^3.0.0:
resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
+camel-case@^4.1.2:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a"
+ integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==
+ dependencies:
+ pascal-case "^3.1.2"
+ tslib "^2.0.3"
+
camelcase-css@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5"
@@ -3554,6 +3662,11 @@ char-regex@^1.0.2:
resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf"
integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==
+character-entities@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22"
+ integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==
+
chart.js@^3.8.2:
version "3.8.2"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.8.2.tgz#e3ebb88f7072780eec4183a788a990f4a58ba7a1"
@@ -3610,6 +3723,11 @@ cheerio@1.0.0-rc.10, cheerio@^1.0.0-rc.3:
optionalDependencies:
fsevents "~2.3.2"
+chownr@^1.1.1:
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
+ integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
+
chownr@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
@@ -3635,6 +3753,13 @@ classnames@2.x, classnames@^2.2.1, classnames@^2.2.5, classnames@^2.2.6, classna
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
+clean-css@^5.2.2:
+ version "5.3.2"
+ resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.2.tgz#70ecc7d4d4114921f5d298349ff86a31a9975224"
+ integrity sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==
+ dependencies:
+ source-map "~0.6.0"
+
cliui@^7.0.4:
version "7.0.4"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
@@ -3696,16 +3821,32 @@ color-name@1.1.3:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
-color-name@^1.1.4, color-name@~1.1.4:
+color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+color-string@^1.9.0:
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4"
+ integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==
+ dependencies:
+ color-name "^1.0.0"
+ simple-swizzle "^0.2.2"
+
color-support@^1.1.2:
version "1.1.3"
resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==
+color@^4.2.3:
+ version "4.2.3"
+ resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a"
+ integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==
+ dependencies:
+ color-convert "^2.0.1"
+ color-string "^1.9.0"
+
colord@^2.9.1:
version "2.9.3"
resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43"
@@ -3723,6 +3864,11 @@ combined-stream@^1.0.8:
dependencies:
delayed-stream "~1.0.0"
+comma-separated-tokens@^2.0.0:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee"
+ integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
+
commander@^10.0.1:
version "10.0.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06"
@@ -3743,6 +3889,11 @@ commander@^7.2.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
+commander@^8.3.0:
+ version "8.3.0"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66"
+ integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==
+
common-path-prefix@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/common-path-prefix/-/common-path-prefix-3.0.0.tgz#7d007a7e07c58c4b4d5f433131a19141b29f11e0"
@@ -3915,7 +4066,7 @@ css-loader@^6.8.1:
postcss-value-parser "^4.2.0"
semver "^7.3.8"
-css-select@^4.3.0:
+css-select@^4.1.3, css-select@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b"
integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==
@@ -4071,7 +4222,7 @@ debug@2.6.9:
dependencies:
ms "2.0.0"
-debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4:
+debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
@@ -4097,6 +4248,13 @@ decimal.js@^10.3.1, decimal.js@^10.4.1:
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.2.tgz#0341651d1d997d86065a2ce3a441fbd0d8e8b98e"
integrity sha512-ic1yEvwT6GuvaYwBLLY6/aFFgjZdySKTE8en/fkU3QICTmRtgtSlFn0u0BXN06InZwtfCelR7j8LRiDI/02iGA==
+decode-named-character-reference@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz#daabac9690874c394c81e4162a0304b35d824f0e"
+ integrity sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==
+ dependencies:
+ character-entities "^2.0.0"
+
decompress-response@^4.2.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986"
@@ -4104,6 +4262,13 @@ decompress-response@^4.2.0:
dependencies:
mimic-response "^2.0.0"
+decompress-response@^6.0.0:
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc"
+ integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==
+ dependencies:
+ mimic-response "^3.1.0"
+
dedent@^1.0.0:
version "1.5.1"
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.1.tgz#4f3fc94c8b711e9bb2800d185cd6ad20f2a90aff"
@@ -4137,6 +4302,11 @@ deep-equal@^2.0.5:
which-collection "^1.0.1"
which-typed-array "^1.1.9"
+deep-extend@^0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+ integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
+
deep-is@^0.1.3, deep-is@~0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
@@ -4187,6 +4357,11 @@ depd@~1.1.2:
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
+dequal@^2.0.0:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
+ integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
+
destroy@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
@@ -4197,6 +4372,11 @@ detect-libc@^2.0.0:
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd"
integrity sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==
+detect-libc@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.2.tgz#8ccf2ba9315350e1241b88d0ac3b0e1fbd99605d"
+ integrity sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==
+
detect-newline@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
@@ -4222,6 +4402,11 @@ diff-sequences@^29.4.3:
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2"
integrity sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==
+diff@^5.0.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40"
+ integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==
+
dir-glob@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
@@ -4284,6 +4469,13 @@ dom-align@^1.7.0:
resolved "https://registry.yarnpkg.com/dom-align/-/dom-align-1.12.3.tgz#a36d02531dae0eefa2abb0c4db6595250526f103"
integrity sha512-Gj9hZN3a07cbR6zviMUBOMPdWxYhbMI+x+WS0NAIu2zFZmbK8ys9R79g+iG9qLnlCwpFoaB+fKy8Pdv470GsPA==
+dom-converter@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768"
+ integrity sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==
+ dependencies:
+ utila "~0.4"
+
dom-helpers@^5.0.1, dom-helpers@^5.1.3:
version "5.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
@@ -4416,6 +4608,13 @@ encodeurl@~1.0.2:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
+end-of-stream@^1.1.0, end-of-stream@^1.4.1:
+ version "1.4.4"
+ resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
+ integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
+ dependencies:
+ once "^1.4.0"
+
enhanced-resolve@^0.9.1:
version "0.9.1"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz#4d6e689b3725f86090927ccc86cd9f1635b89e2e"
@@ -4582,7 +4781,7 @@ escalade@^3.1.1:
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
-escape-html@~1.0.3:
+escape-html@^1.0.3, escape-html@~1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
@@ -4931,6 +5130,11 @@ exit@^0.1.2:
resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==
+expand-template@^2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c"
+ integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==
+
expect@^29.0.0:
version "29.6.1"
resolved "https://registry.yarnpkg.com/expect/-/expect-29.6.1.tgz#64dd1c8f75e2c0b209418f2b8d36a07921adfdf1"
@@ -4997,6 +5201,11 @@ express@^4.17.3:
utils-merge "1.0.1"
vary "~1.1.2"
+extend@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
+ integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
+
fabric@^5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/fabric/-/fabric-5.3.0.tgz#199297b6409e3a6279c16c1166da2b2a9e3e8b9b"
@@ -5015,6 +5224,11 @@ fast-diff@^1.1.2:
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
+fast-fifo@^1.1.0, fast-fifo@^1.2.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.0.tgz#03e381bcbfb29932d7c3afde6e15e83e05ab4d8b"
+ integrity sha512-IgfweLvEpwyA4WgiQe9Nx6VV2QkML2NkvZnk1oKnIzXgXdWxuhF7zw4DvLTPZJn6PIUneiAXPF24QmoEqHTjyw==
+
fast-glob@^3.2.12, fast-glob@^3.2.9:
version "3.2.12"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
@@ -5048,6 +5262,26 @@ fastq@^1.6.0:
dependencies:
reusify "^1.0.4"
+favicons-webpack-plugin@^6.0.0:
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/favicons-webpack-plugin/-/favicons-webpack-plugin-6.0.0.tgz#a54777103e98ad64929e3ed5fbcafe6b3bae357c"
+ integrity sha512-wryICW2NjR9BORYjP1PN3CDbjVzXDxcemLMWQBdLJGhZlj0sYF7NNq+ddQtO/YJvBurYQ3YR1df5uXZRmcF9hw==
+ dependencies:
+ find-root "^1.1.0"
+ parse-author "^2.0.0"
+ parse5 "^7.1.1"
+ optionalDependencies:
+ html-webpack-plugin "^5.5.0"
+
+favicons@^7.1.4:
+ version "7.1.4"
+ resolved "https://registry.yarnpkg.com/favicons/-/favicons-7.1.4.tgz#bc0ed1a8d752f94a36912294681925e272d25ff0"
+ integrity sha512-lnZpVgT7Fzz+DUjioKF1dMwLYlpqWCaB4gIksIfIKwtlhHO1Q7w23hERwHQjEsec+43iENwbTAPRDW3XvpLhbg==
+ dependencies:
+ escape-html "^1.0.3"
+ sharp "^0.32.4"
+ xml2js "^0.6.1"
+
faye-websocket@^0.11.3:
version "0.11.4"
resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da"
@@ -5224,6 +5458,11 @@ fresh@0.5.2:
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
+fs-constants@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
+ integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
+
fs-extra@^10.0.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf"
@@ -5332,6 +5571,11 @@ get-symbol-description@^1.0.0:
call-bind "^1.0.2"
get-intrinsic "^1.1.1"
+github-from-package@0.0.0:
+ version "0.0.0"
+ resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce"
+ integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==
+
glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
@@ -5483,6 +5727,16 @@ has@^1.0.0, has@^1.0.3:
dependencies:
function-bind "^1.1.1"
+hast-util-whitespace@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz#0ec64e257e6fc216c7d14c8a1b74d27d650b4557"
+ integrity sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==
+
+he@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
+ integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
+
history@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b"
@@ -5544,6 +5798,30 @@ html-escaper@^2.0.0:
resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453"
integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==
+html-minifier-terser@^6.0.2:
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#bfc818934cc07918f6b3669f5774ecdfd48f32ab"
+ integrity sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==
+ dependencies:
+ camel-case "^4.1.2"
+ clean-css "^5.2.2"
+ commander "^8.3.0"
+ he "^1.2.0"
+ param-case "^3.0.4"
+ relateurl "^0.2.7"
+ terser "^5.10.0"
+
+html-webpack-plugin@^5.5.0, html-webpack-plugin@^5.5.3:
+ version "5.5.3"
+ resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.5.3.tgz#72270f4a78e222b5825b296e5e3e1328ad525a3e"
+ integrity sha512-6YrDKTuqaP/TquFH7h4srYWsZx+x6k6+FbsTm0ziCwGHDP78Unr1r9F/H4+sGmMbX08GQcJ+K64x55b+7VM/jg==
+ dependencies:
+ "@types/html-minifier-terser" "^6.0.0"
+ html-minifier-terser "^6.0.2"
+ lodash "^4.17.21"
+ pretty-error "^4.0.0"
+ tapable "^2.0.0"
+
htmlparser2@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7"
@@ -5651,6 +5929,11 @@ idb@^7.1.1:
resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b"
integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==
+ieee754@^1.1.13:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
+ integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
+
ignore@^5.0.5, ignore@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
@@ -5710,7 +5993,7 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
-inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3:
+inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -5720,6 +6003,16 @@ inherits@2.0.3:
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==
+ini@~1.3.0:
+ version "1.3.8"
+ resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
+ integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
+
+inline-style-parser@0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1"
+ integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==
+
internal-slot@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c"
@@ -5786,6 +6079,11 @@ is-arrayish@^0.2.1:
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==
+is-arrayish@^0.3.1:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
+ integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
+
is-bigint@^1.0.1:
version "1.0.4"
resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3"
@@ -5808,7 +6106,7 @@ is-boolean-object@^1.0.1, is-boolean-object@^1.1.0:
call-bind "^1.0.2"
has-tostringtag "^1.0.0"
-is-buffer@^2.0.5:
+is-buffer@^2.0.0, is-buffer@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191"
integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==
@@ -5891,6 +6189,11 @@ is-plain-obj@^3.0.0:
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7"
integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==
+is-plain-obj@^4.0.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0"
+ integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==
+
is-plain-object@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
@@ -6673,6 +6976,11 @@ kleur@^3.0.3:
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==
+kleur@^4.0.3:
+ version "4.1.5"
+ resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780"
+ integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==
+
language-subtag-registry@~0.3.2:
version "0.3.21"
resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz#04ac218bea46f04cb039084602c6da9e788dd45a"
@@ -6835,7 +7143,7 @@ lodash.uniq@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==
-lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.21:
+lodash@^4.0.1, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -6915,6 +7223,54 @@ material-colors@^1.2.1:
resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46"
integrity sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==
+mdast-util-definitions@^5.0.0:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz#9910abb60ac5d7115d6819b57ae0bcef07a3f7a7"
+ integrity sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==
+ dependencies:
+ "@types/mdast" "^3.0.0"
+ "@types/unist" "^2.0.0"
+ unist-util-visit "^4.0.0"
+
+mdast-util-from-markdown@^1.0.0:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz#9421a5a247f10d31d2faed2a30df5ec89ceafcf0"
+ integrity sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==
+ dependencies:
+ "@types/mdast" "^3.0.0"
+ "@types/unist" "^2.0.0"
+ decode-named-character-reference "^1.0.0"
+ mdast-util-to-string "^3.1.0"
+ micromark "^3.0.0"
+ micromark-util-decode-numeric-character-reference "^1.0.0"
+ micromark-util-decode-string "^1.0.0"
+ micromark-util-normalize-identifier "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+ unist-util-stringify-position "^3.0.0"
+ uvu "^0.5.0"
+
+mdast-util-to-hast@^12.1.0:
+ version "12.3.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-12.3.0.tgz#045d2825fb04374e59970f5b3f279b5700f6fb49"
+ integrity sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==
+ dependencies:
+ "@types/hast" "^2.0.0"
+ "@types/mdast" "^3.0.0"
+ mdast-util-definitions "^5.0.0"
+ micromark-util-sanitize-uri "^1.1.0"
+ trim-lines "^3.0.0"
+ unist-util-generated "^2.0.0"
+ unist-util-position "^4.0.0"
+ unist-util-visit "^4.0.0"
+
+mdast-util-to-string@^3.1.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz#66f7bb6324756741c5f47a53557f0cbf16b6f789"
+ integrity sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==
+ dependencies:
+ "@types/mdast" "^3.0.0"
+
mdn-data@2.0.28:
version "2.0.28"
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba"
@@ -6972,6 +7328,200 @@ methods@~1.1.2:
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
+micromark-core-commonmark@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8"
+ integrity sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==
+ dependencies:
+ decode-named-character-reference "^1.0.0"
+ micromark-factory-destination "^1.0.0"
+ micromark-factory-label "^1.0.0"
+ micromark-factory-space "^1.0.0"
+ micromark-factory-title "^1.0.0"
+ micromark-factory-whitespace "^1.0.0"
+ micromark-util-character "^1.0.0"
+ micromark-util-chunked "^1.0.0"
+ micromark-util-classify-character "^1.0.0"
+ micromark-util-html-tag-name "^1.0.0"
+ micromark-util-normalize-identifier "^1.0.0"
+ micromark-util-resolve-all "^1.0.0"
+ micromark-util-subtokenize "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.1"
+ uvu "^0.5.0"
+
+micromark-factory-destination@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz#eb815957d83e6d44479b3df640f010edad667b9f"
+ integrity sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==
+ dependencies:
+ micromark-util-character "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+
+micromark-factory-label@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz#cc95d5478269085cfa2a7282b3de26eb2e2dec68"
+ integrity sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==
+ dependencies:
+ micromark-util-character "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+ uvu "^0.5.0"
+
+micromark-factory-space@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz#c8f40b0640a0150751d3345ed885a080b0d15faf"
+ integrity sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==
+ dependencies:
+ micromark-util-character "^1.0.0"
+ micromark-util-types "^1.0.0"
+
+micromark-factory-title@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz#dd0fe951d7a0ac71bdc5ee13e5d1465ad7f50ea1"
+ integrity sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==
+ dependencies:
+ micromark-factory-space "^1.0.0"
+ micromark-util-character "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+
+micromark-factory-whitespace@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz#798fb7489f4c8abafa7ca77eed6b5745853c9705"
+ integrity sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==
+ dependencies:
+ micromark-factory-space "^1.0.0"
+ micromark-util-character "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+
+micromark-util-character@^1.0.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-1.2.0.tgz#4fedaa3646db249bc58caeb000eb3549a8ca5dcc"
+ integrity sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==
+ dependencies:
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+
+micromark-util-chunked@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz#37a24d33333c8c69a74ba12a14651fd9ea8a368b"
+ integrity sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==
+ dependencies:
+ micromark-util-symbol "^1.0.0"
+
+micromark-util-classify-character@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz#6a7f8c8838e8a120c8e3c4f2ae97a2bff9190e9d"
+ integrity sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==
+ dependencies:
+ micromark-util-character "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+
+micromark-util-combine-extensions@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz#192e2b3d6567660a85f735e54d8ea6e3952dbe84"
+ integrity sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==
+ dependencies:
+ micromark-util-chunked "^1.0.0"
+ micromark-util-types "^1.0.0"
+
+micromark-util-decode-numeric-character-reference@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz#b1e6e17009b1f20bc652a521309c5f22c85eb1c6"
+ integrity sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==
+ dependencies:
+ micromark-util-symbol "^1.0.0"
+
+micromark-util-decode-string@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz#dc12b078cba7a3ff690d0203f95b5d5537f2809c"
+ integrity sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==
+ dependencies:
+ decode-named-character-reference "^1.0.0"
+ micromark-util-character "^1.0.0"
+ micromark-util-decode-numeric-character-reference "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+
+micromark-util-encode@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz#92e4f565fd4ccb19e0dcae1afab9a173bbeb19a5"
+ integrity sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==
+
+micromark-util-html-tag-name@^1.0.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz#48fd7a25826f29d2f71479d3b4e83e94829b3588"
+ integrity sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==
+
+micromark-util-normalize-identifier@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz#7a73f824eb9f10d442b4d7f120fecb9b38ebf8b7"
+ integrity sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==
+ dependencies:
+ micromark-util-symbol "^1.0.0"
+
+micromark-util-resolve-all@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz#4652a591ee8c8fa06714c9b54cd6c8e693671188"
+ integrity sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==
+ dependencies:
+ micromark-util-types "^1.0.0"
+
+micromark-util-sanitize-uri@^1.0.0, micromark-util-sanitize-uri@^1.1.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz#613f738e4400c6eedbc53590c67b197e30d7f90d"
+ integrity sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==
+ dependencies:
+ micromark-util-character "^1.0.0"
+ micromark-util-encode "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+
+micromark-util-subtokenize@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz#941c74f93a93eaf687b9054aeb94642b0e92edb1"
+ integrity sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==
+ dependencies:
+ micromark-util-chunked "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.0"
+ uvu "^0.5.0"
+
+micromark-util-symbol@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz#813cd17837bdb912d069a12ebe3a44b6f7063142"
+ integrity sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==
+
+micromark-util-types@^1.0.0, micromark-util-types@^1.0.1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-1.1.0.tgz#e6676a8cae0bb86a2171c498167971886cb7e283"
+ integrity sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==
+
+micromark@^3.0.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/micromark/-/micromark-3.2.0.tgz#1af9fef3f995ea1ea4ac9c7e2f19c48fd5c006e9"
+ integrity sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==
+ dependencies:
+ "@types/debug" "^4.0.0"
+ debug "^4.0.0"
+ decode-named-character-reference "^1.0.0"
+ micromark-core-commonmark "^1.0.1"
+ micromark-factory-space "^1.0.0"
+ micromark-util-character "^1.0.0"
+ micromark-util-chunked "^1.0.0"
+ micromark-util-combine-extensions "^1.0.0"
+ micromark-util-decode-numeric-character-reference "^1.0.0"
+ micromark-util-encode "^1.0.0"
+ micromark-util-normalize-identifier "^1.0.0"
+ micromark-util-resolve-all "^1.0.0"
+ micromark-util-sanitize-uri "^1.0.0"
+ micromark-util-subtokenize "^1.0.0"
+ micromark-util-symbol "^1.0.0"
+ micromark-util-types "^1.0.1"
+ uvu "^0.5.0"
+
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
@@ -7007,6 +7557,11 @@ mimic-response@^2.0.0:
resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43"
integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==
+mimic-response@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9"
+ integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
+
min-indent@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
@@ -7036,6 +7591,11 @@ minimist@^1.2.0, minimist@^1.2.6:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
+minimist@^1.2.3:
+ version "1.2.8"
+ resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
+ integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
+
minipass@^3.0.0:
version "3.3.4"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.4.tgz#ca99f95dd77c43c7a76bf51e6d200025eee0ffae"
@@ -7061,6 +7621,11 @@ mirror-creator@1.1.0:
resolved "https://registry.yarnpkg.com/mirror-creator/-/mirror-creator-1.1.0.tgz#170cc74d0ff244449177204716cbbe555d68d69a"
integrity sha512-BmoCJxp2f31N08k9RMWuUcedmmhs/BCw7uHKZtzk4qyvbwo1UjygFdXt2TlSOcc7bCMVL6184kcNPXakmez5xg==
+mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
+ version "0.5.3"
+ resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
+ integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
+
mkdirp@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
@@ -7095,6 +7660,11 @@ moo@^0.5.0:
resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4"
integrity sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w==
+mri@^1.1.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
+ integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==
+
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@@ -7166,6 +7736,11 @@ nanoid@^3.3.6:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
+napi-build-utils@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806"
+ integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==
+
natural-compare-lite@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4"
@@ -7204,11 +7779,23 @@ no-case@^3.0.4:
lower-case "^2.0.2"
tslib "^2.0.3"
+node-abi@^3.3.0:
+ version "3.45.0"
+ resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.45.0.tgz#f568f163a3bfca5aacfce1fbeee1fa2cc98441f5"
+ integrity sha512-iwXuFrMAcFVi/ZoZiqq8BzAdsLw9kxDfTC0HMyjXfSL/6CSDAGD5UmR7azrAgWV1zKYq7dUUMj4owusBWKLsiQ==
+ dependencies:
+ semver "^7.3.5"
+
node-abort-controller@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.0.1.tgz#f91fa50b1dee3f909afabb7e261b1e1d6b0cb74e"
integrity sha512-/ujIVxthRs+7q6hsdjHMaj8hRG9NuWmwrz+JdRwZ14jdFoKSkm+vDsCbF9PLpnSqjaWQJuTmVtcWHNLr+vrOFw==
+node-addon-api@^6.1.0:
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-6.1.0.tgz#ac8470034e58e67d0c6f1204a18ae6995d9c0d76"
+ integrity sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==
+
node-environment-flags@^1.0.5:
version "1.0.6"
resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088"
@@ -7385,7 +7972,7 @@ on-headers@~1.0.2:
resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
-once@^1.3.0, once@^1.3.1:
+once@^1.3.0, once@^1.3.1, once@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
@@ -7485,6 +8072,14 @@ papaparse@^5.4.1:
resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.4.1.tgz#f45c0f871853578bd3a30f92d96fdcfb6ebea127"
integrity sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==
+param-case@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5"
+ integrity sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==
+ dependencies:
+ dot-case "^3.0.4"
+ tslib "^2.0.3"
+
parent-module@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
@@ -7492,6 +8087,13 @@ parent-module@^1.0.0:
dependencies:
callsites "^3.0.0"
+parse-author@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/parse-author/-/parse-author-2.0.0.tgz#d3460bf1ddd0dfaeed42da754242e65fb684a81f"
+ integrity sha512-yx5DfvkN8JsHL2xk2Os9oTia467qnvRgey4ahSm2X8epehBLx/gWLcy5KI+Y36ful5DzGbCS6RazqZGgy1gHNw==
+ dependencies:
+ author-regex "^1.0.0"
+
parse-json@^5.0.0, parse-json@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd"
@@ -7531,6 +8133,14 @@ parseurl@~1.3.2, parseurl@~1.3.3:
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+pascal-case@^3.1.2:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb"
+ integrity sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==
+ dependencies:
+ no-case "^3.0.4"
+ tslib "^2.0.3"
+
path-exists@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
@@ -7904,6 +8514,24 @@ postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.27:
picocolors "^1.0.0"
source-map-js "^1.0.2"
+prebuild-install@^7.1.1:
+ version "7.1.1"
+ resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.1.tgz#de97d5b34a70a0c81334fd24641f2a1702352e45"
+ integrity sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==
+ dependencies:
+ detect-libc "^2.0.0"
+ expand-template "^2.0.3"
+ github-from-package "0.0.0"
+ minimist "^1.2.3"
+ mkdirp-classic "^0.5.3"
+ napi-build-utils "^1.0.1"
+ node-abi "^3.3.0"
+ pump "^3.0.0"
+ rc "^1.2.7"
+ simple-get "^4.0.0"
+ tar-fs "^2.0.0"
+ tunnel-agent "^0.6.0"
+
prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@@ -7931,6 +8559,14 @@ prettier@^2.8.8:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da"
integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==
+pretty-error@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-4.0.0.tgz#90a703f46dd7234adb46d0f84823e9d1cb8f10d6"
+ integrity sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==
+ dependencies:
+ lodash "^4.17.20"
+ renderkid "^3.0.0"
+
pretty-format@^27.0.2:
version "27.5.1"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e"
@@ -7971,7 +8607,7 @@ prompts@^2.0.1:
kleur "^3.0.3"
sisteransi "^1.0.5"
-prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.9, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.0, prop-types@^15.7.2, prop-types@^15.8.0, prop-types@^15.8.1:
+prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.9, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.0, prop-types@^15.7.2, prop-types@^15.8.0, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -7985,6 +8621,11 @@ property-expr@^2.0.4:
resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4"
integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==
+property-information@^6.0.0:
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.2.0.tgz#b74f522c31c097b5149e3c3cb8d7f3defd986a1d"
+ integrity sha512-kma4U7AFCTwpqq5twzC1YVIDXSqg6qQK6JN0smOw8fgRy1OkMi0CYSzFmsy6dnqSenamAtj0CyXMUJ1Mf6oROg==
+
proxy-addr@~2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
@@ -8003,6 +8644,14 @@ psl@^1.1.33:
resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==
+pump@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
+ integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
+ dependencies:
+ end-of-stream "^1.1.0"
+ once "^1.3.1"
+
punycode@^2.1.0, punycode@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
@@ -8030,6 +8679,11 @@ queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+queue-tick@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/queue-tick/-/queue-tick-1.0.1.tgz#f6f07ac82c1fd60f82e098b417a80e52f1f4c142"
+ integrity sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==
+
raf-schd@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a"
@@ -8137,6 +8791,16 @@ rc-util@^5.16.1, rc-util@^5.19.2, rc-util@^5.21.0, rc-util@^5.3.0:
react-is "^16.12.0"
shallowequal "^1.1.0"
+rc@^1.2.7:
+ version "1.2.8"
+ resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
+ integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
+ dependencies:
+ deep-extend "^0.6.0"
+ ini "~1.3.0"
+ minimist "^1.2.0"
+ strip-json-comments "~2.0.1"
+
react-ace@^10.1.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-10.1.0.tgz#d348eac2b16475231779070b6cd16768deed565f"
@@ -8148,6 +8812,14 @@ react-ace@^10.1.0:
lodash.isequal "^4.5.0"
prop-types "^15.7.2"
+react-async-script@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/react-async-script/-/react-async-script-1.2.0.tgz#ab9412a26f0b83f5e2e00de1d2befc9400834b21"
+ integrity sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==
+ dependencies:
+ hoist-non-react-statics "^3.3.0"
+ prop-types "^15.5.0"
+
react-chartjs-2@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-4.3.1.tgz#9941e7397fb963f28bb557addb401e9ff96c6681"
@@ -8243,6 +8915,14 @@ react-fast-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
+react-google-recaptcha@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz#44aaab834495d922b9d93d7d7a7fb2326315b4ab"
+ integrity sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==
+ dependencies:
+ prop-types "^15.5.0"
+ react-async-script "^1.2.0"
+
react-hook-form@^7.43.9:
version "7.43.9"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.43.9.tgz#84b56ac2f38f8e946c6032ccb760e13a1037c66d"
@@ -8299,6 +8979,27 @@ react-lifecycles-compat@^3.0.4:
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
+react-markdown@^8.0.7:
+ version "8.0.7"
+ resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-8.0.7.tgz#c8dbd1b9ba5f1c5e7e5f2a44de465a3caafdf89b"
+ integrity sha512-bvWbzG4MtOU62XqBx3Xx+zB2raaFFsq4mYiAzfjXJMEz2sixgeAfraA3tvzULF02ZdOMUOKTBFFaZJDDrq+BJQ==
+ dependencies:
+ "@types/hast" "^2.0.0"
+ "@types/prop-types" "^15.0.0"
+ "@types/unist" "^2.0.0"
+ comma-separated-tokens "^2.0.0"
+ hast-util-whitespace "^2.0.0"
+ prop-types "^15.0.0"
+ property-information "^6.0.0"
+ react-is "^18.0.0"
+ remark-parse "^10.0.0"
+ remark-rehype "^10.0.0"
+ space-separated-tokens "^2.0.0"
+ style-to-object "^0.4.0"
+ unified "^10.0.0"
+ unist-util-visit "^4.0.0"
+ vfile "^5.0.0"
+
react-player@^2.12.0:
version "2.12.0"
resolved "https://registry.yarnpkg.com/react-player/-/react-player-2.12.0.tgz#2fc05dbfec234c829292fbca563b544064bd14f0"
@@ -8335,20 +9036,20 @@ react-resizable@^3.0.5:
prop-types "15.x"
react-draggable "^4.0.3"
-react-router-dom@^6.11.1:
- version "6.11.2"
- resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.11.2.tgz#324d55750ffe2ecd54ca4ec6b7bc7ab01741f170"
- integrity sha512-JNbKtAeh1VSJQnH6RvBDNhxNwemRj7KxCzc5jb7zvDSKRnPWIFj9pO+eXqjM69gQJ0r46hSz1x4l9y0651DKWw==
+react-router-dom@^6.14.1:
+ version "6.14.1"
+ resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.14.1.tgz#0ad7ba7abdf75baa61169d49f096f0494907a36f"
+ integrity sha512-ssF6M5UkQjHK70fgukCJyjlda0Dgono2QGwqGvuk7D+EDGHdacEN3Yke2LTMjkrpHuFwBfDFsEjGVXBDmL+bWw==
dependencies:
- "@remix-run/router" "1.6.2"
- react-router "6.11.2"
+ "@remix-run/router" "1.7.1"
+ react-router "6.14.1"
-react-router@6.11.2:
- version "6.11.2"
- resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.11.2.tgz#006301c4da1a173d7ad76b7ecd2da01b9dd3837a"
- integrity sha512-74z9xUSaSX07t3LM+pS6Un0T55ibUE/79CzfZpy5wsPDZaea1F8QkrsiyRnA2YQ7LwE/umaydzXZV80iDCPkMg==
+react-router@6.14.1:
+ version "6.14.1"
+ resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.14.1.tgz#5e82bcdabf21add859dc04b1859f91066b3a5810"
+ integrity sha512-U4PfgvG55LdvbQjg5Y9QRWyVxIdO1LlpYT7x+tMAxd9/vmiPuJhIwdxZuIQLN/9e3O4KFDHYfR9gzGeYMasW8g==
dependencies:
- "@remix-run/router" "1.6.2"
+ "@remix-run/router" "1.7.1"
react-scroll@^1.8.9:
version "1.8.9"
@@ -8513,6 +9214,15 @@ readable-stream@^3.0.6, readable-stream@^3.6.0:
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
+readable-stream@^3.1.1, readable-stream@^3.4.0:
+ version "3.6.2"
+ resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"
+ integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
+ dependencies:
+ inherits "^2.0.3"
+ string_decoder "^1.1.1"
+ util-deprecate "^1.0.1"
+
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
@@ -8616,6 +9326,41 @@ regjsparser@^0.9.1:
dependencies:
jsesc "~0.5.0"
+relateurl@^0.2.7:
+ version "0.2.7"
+ resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
+ integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==
+
+remark-parse@^10.0.0:
+ version "10.0.2"
+ resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-10.0.2.tgz#ca241fde8751c2158933f031a4e3efbaeb8bc262"
+ integrity sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw==
+ dependencies:
+ "@types/mdast" "^3.0.0"
+ mdast-util-from-markdown "^1.0.0"
+ unified "^10.0.0"
+
+remark-rehype@^10.0.0:
+ version "10.1.0"
+ resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-10.1.0.tgz#32dc99d2034c27ecaf2e0150d22a6dcccd9a6279"
+ integrity sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==
+ dependencies:
+ "@types/hast" "^2.0.0"
+ "@types/mdast" "^3.0.0"
+ mdast-util-to-hast "^12.1.0"
+ unified "^10.0.0"
+
+renderkid@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-3.0.0.tgz#5fd823e4d6951d37358ecc9a58b1f06836b6268a"
+ integrity sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==
+ dependencies:
+ css-select "^4.1.3"
+ dom-converter "^0.2.0"
+ htmlparser2 "^6.1.0"
+ lodash "^4.17.21"
+ strip-ansi "^6.0.1"
+
request-ip@~3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/request-ip/-/request-ip-3.3.0.tgz#863451e8fec03847d44f223e30a5d63e369fa611"
@@ -8748,12 +9493,19 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
+sade@^1.7.3:
+ version "1.8.1"
+ resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701"
+ integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==
+ dependencies:
+ mri "^1.1.0"
+
safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
-safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0:
+safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -8788,6 +9540,11 @@ sass@^1.64.1:
immutable "^4.0.0"
source-map-js ">=0.6.2 <2.0.0"
+sax@>=0.6.0:
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
+ integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
+
saxes@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d"
@@ -8858,7 +9615,7 @@ semver@^6.0.0, semver@^6.3.0, semver@^6.3.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
-semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3:
+semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4:
version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
@@ -8941,6 +9698,20 @@ shallowequal@^1.1.0:
resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==
+sharp@^0.32.4:
+ version "0.32.4"
+ resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.32.4.tgz#0354653b7924f2520b2264ac9bcd10a58bf411b6"
+ integrity sha512-exUnZewqVZC6UXqXuQ8fyJJv0M968feBi04jb9GcUHrWtkRoAKnbJt8IfwT4NJs7FskArbJ14JAFGVuooszoGg==
+ dependencies:
+ color "^4.2.3"
+ detect-libc "^2.0.2"
+ node-addon-api "^6.1.0"
+ prebuild-install "^7.1.1"
+ semver "^7.5.4"
+ simple-get "^4.0.1"
+ tar-fs "^3.0.4"
+ tunnel-agent "^0.6.0"
+
shebang-command@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
@@ -8991,6 +9762,22 @@ simple-get@^3.0.3:
once "^1.3.1"
simple-concat "^1.0.0"
+simple-get@^4.0.0, simple-get@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543"
+ integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==
+ dependencies:
+ decompress-response "^6.0.0"
+ once "^1.3.1"
+ simple-concat "^1.0.0"
+
+simple-swizzle@^0.2.2:
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
+ integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==
+ dependencies:
+ is-arrayish "^0.3.1"
+
sisteransi@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
@@ -9054,7 +9841,7 @@ source-map@^0.5.7:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==
-source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1:
+source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
@@ -9064,6 +9851,11 @@ source-map@^0.7.3:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656"
integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==
+space-separated-tokens@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f"
+ integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==
+
spdy-transport@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31"
@@ -9114,6 +9906,14 @@ statuses@2.0.1:
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
+streamx@^2.15.0:
+ version "2.15.1"
+ resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.15.1.tgz#396ad286d8bc3eeef8f5cea3f029e81237c024c6"
+ integrity sha512-fQMzy2O/Q47rgwErk/eGeLu/roaFWV0jVsogDmrszM9uIw8L5OA+t+V93MgYlufNptfjmYR1tOMWhei/Eh7TQA==
+ dependencies:
+ fast-fifo "^1.1.0"
+ queue-tick "^1.0.1"
+
string-length@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"
@@ -9220,11 +10020,23 @@ strip-json-comments@^3.1.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
+strip-json-comments@~2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
+ integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==
+
style-loader@^3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.3.tgz#bba8daac19930169c0c9c96706749a597ae3acff"
integrity sha512-53BiGLXAcll9maCYtZi2RCQZKa8NQQai5C4horqKyRmHj9H7QmcUyucrH+4KW/gBQbXM2AsB0axoEcFZPlfPcw==
+style-to-object@^0.4.0:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.4.1.tgz#53cf856f7cf7f172d72939d9679556469ba5de37"
+ integrity sha512-HFpbb5gr2ypci7Qw+IOhnP2zOU7e77b+rzM+wTzXzfi1PrtBCX0E7Pk4wL4iTLnhzZ+JgEGAhX81ebTg/aYjQw==
+ dependencies:
+ inline-style-parser "0.1.1"
+
stylehacks@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-6.0.0.tgz#9fdd7c217660dae0f62e14d51c89f6c01b3cb738"
@@ -9337,6 +10149,45 @@ tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1:
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0"
integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==
+tar-fs@^2.0.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784"
+ integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==
+ dependencies:
+ chownr "^1.1.1"
+ mkdirp-classic "^0.5.2"
+ pump "^3.0.0"
+ tar-stream "^2.1.4"
+
+tar-fs@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.0.4.tgz#a21dc60a2d5d9f55e0089ccd78124f1d3771dbbf"
+ integrity sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==
+ dependencies:
+ mkdirp-classic "^0.5.2"
+ pump "^3.0.0"
+ tar-stream "^3.1.5"
+
+tar-stream@^2.1.4:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
+ integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
+ dependencies:
+ bl "^4.0.3"
+ end-of-stream "^1.4.1"
+ fs-constants "^1.0.0"
+ inherits "^2.0.3"
+ readable-stream "^3.1.1"
+
+tar-stream@^3.1.5:
+ version "3.1.6"
+ resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.6.tgz#6520607b55a06f4a2e2e04db360ba7d338cc5bab"
+ integrity sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==
+ dependencies:
+ b4a "^1.6.4"
+ fast-fifo "^1.2.0"
+ streamx "^2.15.0"
+
tar@^6.1.11:
version "6.1.11"
resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621"
@@ -9360,6 +10211,16 @@ terser-webpack-plugin@^5.3.7:
serialize-javascript "^6.0.1"
terser "^5.16.5"
+terser@^5.10.0:
+ version "5.18.2"
+ resolved "https://registry.yarnpkg.com/terser/-/terser-5.18.2.tgz#ff3072a0faf21ffd38f99acc9a0ddf7b5f07b948"
+ integrity sha512-Ah19JS86ypbJzTzvUCX7KOsEIhDaRONungA4aYBjEP3JZRf4ocuDzTg4QWZnPn9DEMiMYGJPiSOy7aykoCc70w==
+ dependencies:
+ "@jridgewell/source-map" "^0.3.3"
+ acorn "^8.8.2"
+ commander "^2.20.0"
+ source-map-support "~0.5.20"
+
terser@^5.16.5:
version "5.16.9"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.16.9.tgz#7a28cb178e330c484369886f2afd623d9847495f"
@@ -9467,6 +10328,16 @@ traverse-chain@~0.1.0:
resolved "https://registry.yarnpkg.com/traverse-chain/-/traverse-chain-0.1.0.tgz#61dbc2d53b69ff6091a12a168fd7d433107e40f1"
integrity sha512-up6Yvai4PYKhpNp5PkYtx50m3KbwQrqDwbuZP/ItyL64YEWHAvH6Md83LFLV/GRSk/BoUVwwgUzX6SOQSbsfAg==
+trim-lines@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338"
+ integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==
+
+trough@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/trough/-/trough-2.1.0.tgz#0f7b511a4fde65a46f18477ab38849b22c554876"
+ integrity sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==
+
ts-interface-checker@^0.1.9:
version "0.1.13"
resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
@@ -9522,6 +10393,13 @@ tsutils@^3.21.0:
dependencies:
tslib "^1.8.1"
+tunnel-agent@^0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
+ integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==
+ dependencies:
+ safe-buffer "^5.0.1"
+
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
@@ -9597,6 +10475,62 @@ unicode-property-aliases-ecmascript@^2.0.0:
resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz#0a36cb9a585c4f6abd51ad1deddb285c165297c8"
integrity sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ==
+unified@^10.0.0:
+ version "10.1.2"
+ resolved "https://registry.yarnpkg.com/unified/-/unified-10.1.2.tgz#b1d64e55dafe1f0b98bb6c719881103ecf6c86df"
+ integrity sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==
+ dependencies:
+ "@types/unist" "^2.0.0"
+ bail "^2.0.0"
+ extend "^3.0.0"
+ is-buffer "^2.0.0"
+ is-plain-obj "^4.0.0"
+ trough "^2.0.0"
+ vfile "^5.0.0"
+
+unist-util-generated@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/unist-util-generated/-/unist-util-generated-2.0.1.tgz#e37c50af35d3ed185ac6ceacb6ca0afb28a85cae"
+ integrity sha512-qF72kLmPxAw0oN2fwpWIqbXAVyEqUzDHMsbtPvOudIlUzXYFIeQIuxXQCRCFh22B7cixvU0MG7m3MW8FTq/S+A==
+
+unist-util-is@^5.0.0:
+ version "5.2.1"
+ resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-5.2.1.tgz#b74960e145c18dcb6226bc57933597f5486deae9"
+ integrity sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==
+ dependencies:
+ "@types/unist" "^2.0.0"
+
+unist-util-position@^4.0.0:
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-4.0.4.tgz#93f6d8c7d6b373d9b825844645877c127455f037"
+ integrity sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==
+ dependencies:
+ "@types/unist" "^2.0.0"
+
+unist-util-stringify-position@^3.0.0:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz#03ad3348210c2d930772d64b489580c13a7db39d"
+ integrity sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==
+ dependencies:
+ "@types/unist" "^2.0.0"
+
+unist-util-visit-parents@^5.1.1:
+ version "5.1.3"
+ resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz#b4520811b0ca34285633785045df7a8d6776cfeb"
+ integrity sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==
+ dependencies:
+ "@types/unist" "^2.0.0"
+ unist-util-is "^5.0.0"
+
+unist-util-visit@^4.0.0:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-4.1.2.tgz#125a42d1eb876283715a3cb5cceaa531828c72e2"
+ integrity sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==
+ dependencies:
+ "@types/unist" "^2.0.0"
+ unist-util-is "^5.0.0"
+ unist-util-visit-parents "^5.1.1"
+
universalify@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0"
@@ -9650,6 +10584,11 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
+utila@~0.4:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c"
+ integrity sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==
+
utils-merge@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
@@ -9660,6 +10599,16 @@ uuid@^8.3.2:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
+uvu@^0.5.0:
+ version "0.5.6"
+ resolved "https://registry.yarnpkg.com/uvu/-/uvu-0.5.6.tgz#2754ca20bcb0bb59b64e9985e84d2e81058502df"
+ integrity sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==
+ dependencies:
+ dequal "^2.0.0"
+ diff "^5.0.0"
+ kleur "^4.0.3"
+ sade "^1.7.3"
+
v8-to-istanbul@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz#b6f994b0b5d4ef255e17a0d17dc444a9f5132fa4"
@@ -9681,6 +10630,24 @@ vary@~1.1.2:
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
+vfile-message@^3.0.0:
+ version "3.1.4"
+ resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-3.1.4.tgz#15a50816ae7d7c2d1fa87090a7f9f96612b59dea"
+ integrity sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==
+ dependencies:
+ "@types/unist" "^2.0.0"
+ unist-util-stringify-position "^3.0.0"
+
+vfile@^5.0.0:
+ version "5.3.7"
+ resolved "https://registry.yarnpkg.com/vfile/-/vfile-5.3.7.tgz#de0677e6683e3380fafc46544cfe603118826ab7"
+ integrity sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==
+ dependencies:
+ "@types/unist" "^2.0.0"
+ is-buffer "^2.0.0"
+ unist-util-stringify-position "^3.0.0"
+ vfile-message "^3.0.0"
+
w3c-hr-time@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd"
@@ -9996,6 +10963,19 @@ xml-name-validator@^4.0.0:
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835"
integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==
+xml2js@^0.6.1:
+ version "0.6.2"
+ resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.2.tgz#dd0b630083aa09c161e25a4d0901e2b2a929b499"
+ integrity sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==
+ dependencies:
+ sax ">=0.6.0"
+ xmlbuilder "~11.0.0"
+
+xmlbuilder@~11.0.0:
+ version "11.0.1"
+ resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
+ integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
+
xmlchars@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 10ff6dba999..5347a4f65cc 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -61,9 +61,10 @@
# routes, locales, etc. This feature depends on the listen gem.
config.file_watcher = ActiveSupport::EventedFileUpdateChecker
- config.x.default_host = 'localhost:5000'
+ config.x.default_app_host = 'lvh.me'
+ config.x.default_host = "#{config.x.default_app_host}:5000"
- config.action_mailer.default_url_options = { host: 'localhost:5000' }
+ config.action_mailer.default_url_options = { host: "#{config.x.default_app_host}:5000" }
# Rails 6.0.5.1 security patch
# To find out more unpermitted classes and add below then uncomment
@@ -78,4 +79,13 @@
config.action_mailer.raise_delivery_errors = false
config.action_cable.disable_request_forgery_protection = true
+
+ config.hosts << ".#{config.x.default_app_host}"
+
+ config.middleware.insert_before 0, Rack::Cors do
+ allow do
+ origins(/lvh\.me:([0-9]+)/, /(.*?)\.lvh\.me:([0-9]+)/)
+ resource '*', headers: :any, methods: [:get, :post, :patch, :put], credentials: true
+ end
+ end
end
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 7e005ceaa11..15b8051d7b3 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -31,8 +31,6 @@
# Apache or NGINX already handles this.
config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
- # Compress JavaScripts and CSS.
- config.assets.js_compressor = Uglifier.new(harmony: true)
# config.assets.css_compressor = :sass
# Do not fallback to assets pipeline if a precompiled asset is missed.
@@ -134,4 +132,11 @@
# config.active_record.yaml_column_permitted_classes = [ActiveSupport::HashWithIndifferentAccess,
# ActiveSupport::Duration]
config.active_record.use_yaml_unsafe_load = true
+
+ config.middleware.insert_before 0, Rack::Cors do
+ allow do
+ origins(/coursemology\.org:([0-9]+)/, /(.*?)\.coursemology\.org:([0-9]+)/)
+ resource '*', headers: :any, methods: [:get, :post, :patch, :put], credentials: true
+ end
+ end
end
diff --git a/config/environments/test.rb b/config/environments/test.rb
index 01b16e849b7..f929b46bebd 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -43,7 +43,7 @@
config.action_mailer.default_options = { from: 'coursemology@example.org' }
# We will assume that we are running on localhost
- config.action_mailer.default_url_options = { host: 'localhost' }
+ config.action_mailer.default_url_options = { host: 'lvh.me:3200' }
# Use the threaded background job adapter for replicating the production environment.
config.active_job.queue_adapter = ActiveJob::QueueAdapters::BackgroundThreadAdapter.new
@@ -54,7 +54,10 @@
# Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr
- config.x.default_host = 'coursemology.lvh.me'
+ config.x.default_host = 'lvh.me'
+ config.x.client_port = 3200
+ config.x.server_port = 7979
+ config.x.default_user_password = 'lolololol'
# Raises error for missing translations.
# config.action_view.raise_on_missing_translations = true
@@ -66,4 +69,11 @@
# config.active_record.yaml_column_permitted_classes = [ActiveSupport::HashWithIndifferentAccess,
# ActiveSupport::Duration]
config.active_record.use_yaml_unsafe_load = true
+
+ config.middleware.insert_before 0, Rack::Cors do
+ allow do
+ origins(/lvh\.me:([0-9]+)/, /(.*?)\.lvh\.me:([0-9]+)/)
+ resource '*', headers: :any, methods: [:get, :post, :patch, :put], credentials: true
+ end
+ end
end
diff --git a/config/favicon.json b/config/favicon.json
deleted file mode 100644
index 0b699432cde..00000000000
--- a/config/favicon.json
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- Used by rails_real_favicon gem to generate favicon.
-
- Config file is generated from https://realfavicongenerator.net/
-*/
-{
- "master_picture": "app/assets/images/favicon.svg",
- "favicon_design": {
- "ios": {
- "picture_aspect": "background_and_margin",
- "background_color": "#ffffff",
- "margin": "14%",
- "assets": {
- "ios6_and_prior_icons": false,
- "ios7_and_later_icons": false,
- "precomposed_icons": false,
- "declare_only_default_icon": true
- }
- },
- "desktop_browser": [],
- "windows": {
- "picture_aspect": "no_change",
- "background_color": "#ffc40d",
- "on_conflict": "override",
- "assets": {
- "windows_80_ie_10_tile": false,
- "windows_10_ie_11_edge_tiles": {
- "small": false,
- "medium": true,
- "big": false,
- "rectangle": false
- }
- }
- },
- "android_chrome": {
- "picture_aspect": "no_change",
- "theme_color": "#ffffff",
- "manifest": {
- "name": "Coursemology",
- "display": "standalone",
- "orientation": "not_set",
- "on_conflict": "override",
- "declared": true
- },
- "assets": {
- "legacy_icon": false,
- "low_resolution_icons": false
- }
- },
- "safari_pinned_tab": {
- "picture_aspect": "black_and_white",
- "threshold": 50,
- "theme_color": "#5bbad5"
- }
- },
- "settings": {
- "scaling_algorithm": "Mitchell",
- "error_on_image_too_small": false
- }
-}
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
index e8cb57c51b2..482402ce63a 100644
--- a/config/i18n-tasks.yml
+++ b/config/i18n-tasks.yml
@@ -76,16 +76,9 @@ search:
## Do not consider these keys missing:
ignore_missing:
- - "application_helper.page_header.header"
- "errors.messages.*"
- "activerecord.errors.messages.*"
- # I18n-tasks gives the wrong results for normal (non action) methods in the controller, ignore them here.
- - "course.user_invitations.create_success_message.*"
- - "course.user_invitations.create_warning_message.*"
- - "system.admin.instance.user_invitations.create_success_message.*"
- - "system.admin.instance.user_invitations.create_warning_message.*"
-
## Consider these keys used:
ignore_unused:
- "activerecord.attributes.*"
@@ -94,9 +87,6 @@ ignore_unused:
- "errors.messages.*"
- "helpers.buttons.*"
- "{devise,kaminari,will_paginate}.*"
- - "simple_form.{yes,no}"
- - "simple_form.{placeholders,hints,labels}.*"
- - "simple_form.{error_notification,required}.:"
- "helpers.submit.*.create"
- "helpers.submit.*.update"
# Dynamically resolved using symbol overload of #t
diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb
deleted file mode 100644
index f7deda5e20f..00000000000
--- a/config/initializers/assets.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-# Be sure to restart your server when you modify this file.
-
-# Version of your assets, change this if you want to expire all your assets.
-Rails.application.config.assets.version = '1.0'
-
-# Add additional assets to the asset load path.
-# Rails.application.config.assets.paths << Emoji.images_path
-
-# Precompile additional assets.
-# application.js, application.css, and all non-JS/CSS in the app/assets
-# folder are already added.
-# Rails.application.config.assets.precompile += %w( admin.js admin.css )
diff --git a/config/initializers/coverage.rb b/config/initializers/coverage.rb
new file mode 100644
index 00000000000..abeaa6e3f43
--- /dev/null
+++ b/config/initializers/coverage.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+if Rails.env.test? && ENV['COLLECT_COVERAGE'] == 'true'
+ require 'simplecov'
+ require 'codecov'
+
+ SimpleCov.formatter = SimpleCov::Formatter::Codecov
+
+ SimpleCov.start('rails') do
+ add_filter '/lib/extensions/legacy/active_record/connection_adapters/table_definition.rb'
+ add_filter '/lib/tasks/coursemology/stats_setup.rake'
+ add_filter '/lib/tasks/coursemology/seed.rake'
+ add_filter '/lib/tasks/'
+ end
+end
diff --git a/config/initializers/high_voltage.rb b/config/initializers/high_voltage.rb
deleted file mode 100644
index 2e14e111ac8..00000000000
--- a/config/initializers/high_voltage.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-HighVoltage.configure do |config|
- config.home_page = 'home'
- config.layout = nil
-end
diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb
deleted file mode 100644
index fe7c69c6ed8..00000000000
--- a/config/initializers/simple_form.rb
+++ /dev/null
@@ -1,4 +0,0 @@
-# frozen_string_literal: true
-# Use this setup block to configure all options available in SimpleForm.
-SimpleForm.setup do |_|
-end
diff --git a/config/locales/en/announcements.yml b/config/locales/en/announcements.yml
deleted file mode 100644
index b882b45d181..00000000000
--- a/config/locales/en/announcements.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-en:
- announcements:
- index:
- header: 'All Announcements'
- global_announcement:
- close: 'Close'
diff --git a/config/locales/en/attachment_references.yml b/config/locales/en/attachment_references.yml
deleted file mode 100644
index 88a4bc1091b..00000000000
--- a/config/locales/en/attachment_references.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-en:
- attachment_references:
- render_html_response:
- success: 'Attachment has been deleted'
- failure: 'Failed to delete attachment: %{error}'
diff --git a/config/locales/en/attachments.yml b/config/locales/en/attachments.yml
deleted file mode 100644
index 20240f628a0..00000000000
--- a/config/locales/en/attachments.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-en:
- attachments:
- attachment:
- uploaded_by: :'layouts.materials_uploader.uploaded_by'
- delete_attachment: 'Delete'
- confirm_delete_attachment: 'Are you sure you want to delete this attachment?'
diff --git a/config/locales/en/common.yml b/config/locales/en/common.yml
index bfc8d986027..ce59bd60c70 100644
--- a/config/locales/en/common.yml
+++ b/config/locales/en/common.yml
@@ -1,23 +1,8 @@
en:
common:
- not_started: 'Not started'
- ended: 'Ended'
plain_text_link: '%{text} (%{url})'
click_here: 'Click here'
helpers:
- submit:
- condition_level:
- create: 'Create Level Condition'
- update: 'Update Level Condition'
- condition_achievement:
- create: 'Create Achievement Condition'
- update: 'Update Achievement Condition'
- condition_assessment:
- create: 'Create Assessment Condition'
- update: 'Update Assessment Condition'
- condition_survey:
- create: 'Create Survey Condition'
- update: 'Update Survey Condition'
buttons:
new: 'New %{model}'
create: 'Create %{model}'
diff --git a/config/locales/en/course/assessment/question/text_response.yml b/config/locales/en/course/assessment/question/text_response.yml
deleted file mode 100644
index 59ace807af5..00000000000
--- a/config/locales/en/course/assessment/question/text_response.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-en:
- course:
- assessment:
- question:
- text_responses:
- form_comprehension:
- multiline_explanation_comprehension_html: >
- Adding solutions allows the question to be autograded. Students can only input plaintext.
- add_group: 'Add Group'
- comprehension: 'Comprehension Question'
- text_response_autograde: 'Note: If no solutions are provided, the autograder will always award the maximum grade.'
- comprehension_group_fields:
- group: 'Group'
- remove_group: 'Remove Group'
- maximum_group_grade: 'Maximum Grade for this Group'
- add_point: 'Add Point'
- comprehension_point_fields:
- point: 'Point'
- remove_point: 'Remove Point'
- point_grade: 'Grade for this Point'
- add_solution: 'Add Solution'
- solution_type: 'Type'
- solution: 'Solution'
- information: 'Word from Text Passage'
- comprehension_solution_fields:
- information_hint: 'The exact word from the text passage.'
- add_solution_word: 'Add Solution Word'
- remove: 'Remove'
- compre_keyword: 'Keyword'
- compre_lifted_word: 'Lifted Word'
diff --git a/config/locales/en/course/lesson_plan/items.yml b/config/locales/en/course/lesson_plan/items.yml
index 4b50e3e045c..3e4ce95a8f9 100644
--- a/config/locales/en/course/lesson_plan/items.yml
+++ b/config/locales/en/course/lesson_plan/items.yml
@@ -5,9 +5,6 @@ en:
sidebar_title: :'course.lesson_plan.items.index.header'
index:
header: 'Lesson Plan'
- ref: 'Ref: '
- fixed_desc: >
- The personalized timeline for this item has been fixed. It will no longer be automatically modified.
activerecord:
attributes:
course/lesson_plan/item:
diff --git a/config/locales/en/course/user_registrations.yml b/config/locales/en/course/user_registrations.yml
index 37fa7beb6f0..6d379be53f0 100644
--- a/config/locales/en/course/user_registrations.yml
+++ b/config/locales/en/course/user_registrations.yml
@@ -9,5 +9,3 @@ en:
code_taken_with_email: >
The provided invitation code has been claimed by %{email},
please use that account to access the course instead.
- requested: "You have submitted registration request to the course."
- registered: "You have been registered as a %{role}."
diff --git a/config/locales/en/layout.yml b/config/locales/en/layout.yml
index 227ec2d48d4..979624b7541 100644
--- a/config/locales/en/layout.yml
+++ b/config/locales/en/layout.yml
@@ -1,17 +1,6 @@
en:
layout:
coursemology: 'Coursemology'
- navbar:
- toggle_navigation: 'Toggle Navigation'
- courses: 'Courses'
- all_courses: 'All Courses'
- help: 'Help'
- register: 'Register'
- sign_in: 'Sign In'
- sign_out: 'Sign Out'
- admin_panel: 'Administration Panel'
- instance_admin_panel: 'Instance Administration Panel'
- stop_masquerading: '(Stop Masquerading)'
layouts:
course_admin:
title: 'Course Settings'
@@ -33,12 +22,6 @@ en:
title: 'Manage Users'
duplication:
title: 'Duplicate Data'
- attachment_uploader:
- uploaded_files: 'Uploaded Files:'
- uploaded_file: 'Uploaded File:'
- new_files: 'Upload New Files'
- materials_uploader:
- uploaded_by: 'Uploaded By: %{name}'
mailer:
greeting: 'Hello, %{user}:'
code_formatter:
diff --git a/config/locales/en/simple_form.yml b/config/locales/en/simple_form.yml
deleted file mode 100644
index c6562cf7ae9..00000000000
--- a/config/locales/en/simple_form.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-en:
- simple_form:
- 'yes': 'Yes'
- 'no': 'No'
- required:
- text: 'required'
- mark: '*'
- # You can uncomment the line below if you need to overwrite the whole required html.
- # When using html, text and mark won't be used.
- # html: '* '
- error_notification:
- default_message: 'Please review the problems below:'
- # Examples
- # labels:
- # defaults:
- # password: 'Password'
- # user:
- # new:
- # email: 'E-mail to sign in.'
- # edit:
- # email: 'E-mail.'
- # hints:
- # defaults:
- # username: 'User name to sign in.'
- # password: 'No special characters, please.'
- # include_blanks:
- # defaults:
- # age: 'Rather not say'
- # prompts:
- # defaults:
- # age: 'Select your age'
diff --git a/config/locales/en/user/admin.yml b/config/locales/en/user/admin.yml
deleted file mode 100644
index 011062284ca..00000000000
--- a/config/locales/en/user/admin.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-en:
- user:
- admin:
- navbar:
- account_settings: :'user.registrations.edit.header'
diff --git a/config/locales/en/user/registrations.yml b/config/locales/en/user/registrations.yml
index 845e2c6fd45..15c10c5443a 100644
--- a/config/locales/en/user/registrations.yml
+++ b/config/locales/en/user/registrations.yml
@@ -3,10 +3,6 @@ en:
registrations:
new:
header: :'layout.navbar.register'
- already_registered_html: >
- Already registered? If you have an existing Coursemology account, you can %{sign_in}.
- password_hint: '#{length} characters minimum'
- sign_up: :'user.registrations.new.header'
used: >
The provided invitation code has been claimed by another account, please use that account to log in instead.
used_with_email: >
diff --git a/config/locales/en/user/sessions.yml b/config/locales/en/user/sessions.yml
index a95637a5321..89ca98ec80c 100644
--- a/config/locales/en/user/sessions.yml
+++ b/config/locales/en/user/sessions.yml
@@ -3,4 +3,4 @@ en:
sessions:
new:
header: :'layout.navbar.sign_in'
- sign_in: :'user.sessions.new.header'
+
diff --git a/config/locales/zh/announcements.yml b/config/locales/zh/announcements.yml
deleted file mode 100644
index 77d87eddb01..00000000000
--- a/config/locales/zh/announcements.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-zh:
- announcements:
- index:
- header: '全部公告'
- global_announcement:
- close: '关闭'
diff --git a/config/locales/zh/attachment_references.yml b/config/locales/zh/attachment_references.yml
deleted file mode 100644
index 58bcdc9e568..00000000000
--- a/config/locales/zh/attachment_references.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-zh:
- attachment_references:
- render_html_response:
- success: '附件已删除'
- failure: '附件删除失败:%{error}'
diff --git a/config/locales/zh/attachments.yml b/config/locales/zh/attachments.yml
deleted file mode 100644
index 9d5538baeb3..00000000000
--- a/config/locales/zh/attachments.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-zh:
- attachments:
- attachment:
- uploaded_by: :'layouts.materials_uploader.uploaded_by'
- delete_attachment: '删除'
- confirm_delete_attachment: '确定要删除该附件吗?'
diff --git a/config/locales/zh/common.yml b/config/locales/zh/common.yml
index 362028c3197..4f6eaab4f55 100644
--- a/config/locales/zh/common.yml
+++ b/config/locales/zh/common.yml
@@ -1,23 +1,8 @@
zh:
common:
- not_started: '尚未开始'
- ended: '已结束'
plain_text_link: '%{text} (%{url})'
click_here: '点击此处'
helpers:
- submit:
- condition_level:
- create: '创建升级条件'
- update: '更新升级条件'
- condition_achievement:
- create: '创建成就获取条件'
- update: '更新成就获取条件'
- condition_assessment:
- create: '创建测验条件'
- update: '更新测验条件'
- condition_survey:
- create: '创建调研条件'
- update: '更新调研条件'
buttons:
new: '新%{model}'
create: '创建%{model}'
diff --git a/config/locales/zh/course/assessment/question/text_response.yml b/config/locales/zh/course/assessment/question/text_response.yml
deleted file mode 100644
index f09fe96cf54..00000000000
--- a/config/locales/zh/course/assessment/question/text_response.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-zh:
- course:
- assessment:
- question:
- text_responses:
- form_comprehension:
- multiline_explanation_comprehension_html: >
- 添加解决方案可以使问题自动评分,学生只能输入纯文本
- add_group: '添加组'
- comprehension: '理解题'
- text_response_autograde: '注意:如果没有提供答案,自动评分器将始终授予最高分数。'
- comprehension_group_fields:
- group: '组'
- remove_group: '移除组'
- maximum_group_grade: '该组的最高分'
- add_point: '添加要点'
- comprehension_point_fields:
- point: '要点'
- remove_point: '移除要点'
- point_grade: '该要点的得分'
- add_solution: '添加答案'
- solution_type: '类型'
- solution: '答案'
- information: '段落中的文本'
- comprehension_solution_fields:
- information_hint: '文字段落中的确切字句。'
- add_solution_word: '添加答案文本'
- remove: '移除'
- compre_keyword: '关键词'
- compre_lifted_word: '要点词'
diff --git a/config/locales/zh/course/lesson_plan/items.yml b/config/locales/zh/course/lesson_plan/items.yml
index 627efaca038..27e7d218ea7 100644
--- a/config/locales/zh/course/lesson_plan/items.yml
+++ b/config/locales/zh/course/lesson_plan/items.yml
@@ -5,9 +5,6 @@ zh:
sidebar_title: :'course.lesson_plan.items.index.header'
index:
header: '课程计划'
- ref: 'Ref: '
- fixed_desc: >
- 这个项目的定制化时间线已被修复,它将不再被自动修改。
activerecord:
attributes:
course/lesson_plan/item:
diff --git a/config/locales/zh/course/user_registrations.yml b/config/locales/zh/course/user_registrations.yml
index c47bb5f8ae6..7b8520096d6 100644
--- a/config/locales/zh/course/user_registrations.yml
+++ b/config/locales/zh/course/user_registrations.yml
@@ -9,5 +9,3 @@ zh:
code_taken_with_email: >
所提供的邀请码已被%{email}使用,
请使用该账户访问该课程。
- requested: "你已经提交了课程的注册请求。"
- registered: "你已注册为%{role}."
diff --git a/config/locales/zh/layout.yml b/config/locales/zh/layout.yml
index 580407cb2c6..1dbc433671f 100644
--- a/config/locales/zh/layout.yml
+++ b/config/locales/zh/layout.yml
@@ -1,17 +1,6 @@
zh:
layout:
coursemology: 'Coursemology'
- navbar:
- toggle_navigation: '切换导航'
- courses: '课程'
- all_courses: '全部课程'
- help: '帮助'
- register: '注册'
- sign_in: '登录'
- sign_out: '登出'
- admin_panel: '管理小组'
- instance_admin_panel: '实例管理小组'
- stop_masquerading: '(停止伪装)'
layouts:
course_admin:
title: '课程设置'
@@ -33,12 +22,6 @@ zh:
title: '管理用户'
duplication:
title: '复制数据'
- attachment_uploader:
- uploaded_files: '已上传文件:'
- uploaded_file: '已上传文件:'
- new_files: '上传新文件'
- materials_uploader:
- uploaded_by: '由:%{name}上传'
mailer:
greeting: '你好, %{user}:'
code_formatter:
diff --git a/config/locales/zh/simple_form.yml b/config/locales/zh/simple_form.yml
deleted file mode 100644
index 486744b8a9e..00000000000
--- a/config/locales/zh/simple_form.yml
+++ /dev/null
@@ -1,9 +0,0 @@
-zh:
- simple_form:
- 'yes': '是'
- 'no': '否'
- required:
- text: '必须'
- mark: '*'
- error_notification:
- default_message: '请查阅以下问题信息:'
diff --git a/config/locales/zh/user/admin.yml b/config/locales/zh/user/admin.yml
deleted file mode 100644
index da6af697d51..00000000000
--- a/config/locales/zh/user/admin.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-zh:
- user:
- admin:
- navbar:
- account_settings: :'user.registrations.edit.header'
diff --git a/config/locales/zh/user/registrations.yml b/config/locales/zh/user/registrations.yml
index 65730b7fb0d..f4602ae2640 100644
--- a/config/locales/zh/user/registrations.yml
+++ b/config/locales/zh/user/registrations.yml
@@ -3,10 +3,6 @@ zh:
registrations:
new:
header: :'layout.navbar.register'
- already_registered_html: >
- 已经注册过? 如果你已经拥有一个Coursemology账号, 你可以直接%{sign_in}.
- password_hint: '至少需要 #{length} 个字符'
- sign_up: :'user.registrations.new.header'
used: >
所提供的邀请码已被另一个账户所使用,请使用该账户登录。
used_with_email: >
diff --git a/config/locales/zh/user/sessions.yml b/config/locales/zh/user/sessions.yml
index 7f290e4859c..99ca047f037 100644
--- a/config/locales/zh/user/sessions.yml
+++ b/config/locales/zh/user/sessions.yml
@@ -3,4 +3,4 @@ zh:
sessions:
new:
header: :'layout.navbar.sign_in'
- sign_in: :'user.sessions.new.header'
+
diff --git a/config/routes.rb b/config/routes.rb
index 163fe94e3d4..7ced1146b24 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -4,7 +4,7 @@
# See how all your routes lay out with "rake routes".
# You can have the root of your site routed with "root"
- # root 'welcome#index'
+ root 'application#index'
# Example of regular route:
# get 'products/:id' => 'catalog#view'
@@ -71,9 +71,13 @@
devise_for :users, controllers: {
registrations: 'user/registrations',
sessions: 'user/sessions',
- masquerades: 'user/masquerades'
+ masquerades: 'user/masquerades',
+ passwords: 'user/passwords',
+ confirmations: 'user/confirmations'
}
+ get 'csrf_token' => 'csrf_token#csrf_token'
+
resources :announcements, only: [:index] do
post 'mark_as_read'
end
@@ -460,4 +464,12 @@
end
resources :attachment_references, path: 'attachments', only: [:create, :show, :destroy]
+
+ if Rails.env.test?
+ namespace :test do
+ post 'create' => 'factories#create'
+ delete 'clear_emails' => 'mailer#clear'
+ get 'last_sent_email' => 'mailer#last_sent'
+ end
+ end
end
diff --git a/config/webpacker.yml b/config/webpacker.yml
deleted file mode 100644
index 90aca9f51dc..00000000000
--- a/config/webpacker.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-# Note: You must restart bin/webpack-dev-server for changes to take effect
-
-default: &default
- public_output_path: webpack
-
- # Reload manifest.json on all requests so we reload latest compiled packs
- cache_manifest: false
- # We will do precompilation of packs manually.
- compile: false
-
-development:
- <<: *default
-
- dev_server:
- host: localhost
- port: 8080
- hmr: false
- https: false
-
-test:
- <<: *default
-
-production:
- <<: *default
-
- # Cache manifest.json for performance
- cache_manifest: true
diff --git a/env b/env
new file mode 100644
index 00000000000..594a43deb55
--- /dev/null
+++ b/env
@@ -0,0 +1,3 @@
+CODAVERI_API_KEY = ""
+ROLLBAR_ACCESS_TOKEN = ""
+RAILS_HOSTNAME = ""
diff --git a/lib/extensions/attachable/action_view/helpers.rb b/lib/extensions/attachable/action_view/helpers.rb
deleted file mode 100644
index 79f0f7c6379..00000000000
--- a/lib/extensions/attachable/action_view/helpers.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-# frozen_string_literal: true
-module Extensions::Attachable::ActionView::Helpers; end
diff --git a/lib/extensions/attachable/action_view/helpers/form_builder.rb b/lib/extensions/attachable/action_view/helpers/form_builder.rb
deleted file mode 100644
index c6503e9fd50..00000000000
--- a/lib/extensions/attachable/action_view/helpers/form_builder.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-module Extensions::Attachable::ActionView::Helpers::FormBuilder
- # Method from ActsAsAttachable framework.
- # Hepler to support f.attachments in form
- #
- # @param [Boolean] allow_delete Specify if attachments can be deleted.
- def attachments(allow_delete: true)
- multiple = !object.respond_to?(:attachment)
- @template.render 'layouts/attachment_uploader',
- f: self, multiple: multiple, allow_delete: allow_delete
- end
- alias_method :attachment, :attachments
-end
diff --git a/lib/extensions/high_voltage_page_action_class.rb b/lib/extensions/high_voltage_page_action_class.rb
deleted file mode 100644
index acb1a65f74f..00000000000
--- a/lib/extensions/high_voltage_page_action_class.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-# frozen_string_literal: true
-module Extensions::HighVoltagePageActionClass; end
diff --git a/lib/extensions/high_voltage_page_action_class/action_view.rb b/lib/extensions/high_voltage_page_action_class/action_view.rb
deleted file mode 100644
index 87c5f12b660..00000000000
--- a/lib/extensions/high_voltage_page_action_class/action_view.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-# frozen_string_literal: true
-module Extensions::HighVoltagePageActionClass::ActionView; end
diff --git a/lib/extensions/high_voltage_page_action_class/action_view/base.rb b/lib/extensions/high_voltage_page_action_class/action_view/base.rb
deleted file mode 100644
index 2a3f06d017d..00000000000
--- a/lib/extensions/high_voltage_page_action_class/action_view/base.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-module Extensions::HighVoltagePageActionClass::ActionView::Base
- def page_action_class
- if controller.is_a?(HighVoltage::PagesController)
- # TODO: Depends on thoughtbot/high_voltage#235
- current_page = controller.send(:current_page)
- current_page.sub(/^pages\//, '')
- else
- super
- end
- end
-end
diff --git a/lib/extensions/inherited_nested_layouts.rb b/lib/extensions/inherited_nested_layouts.rb
deleted file mode 100644
index 6ded661c42d..00000000000
--- a/lib/extensions/inherited_nested_layouts.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-# frozen_string_literal: true
-module Extensions::InheritedNestedLayouts; end
diff --git a/lib/extensions/inherited_nested_layouts/action_controller.rb b/lib/extensions/inherited_nested_layouts/action_controller.rb
deleted file mode 100644
index 3a4274766c3..00000000000
--- a/lib/extensions/inherited_nested_layouts/action_controller.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-# frozen_string_literal: true
-module Extensions::InheritedNestedLayouts::ActionController; end
diff --git a/lib/extensions/inherited_nested_layouts/action_controller/base.rb b/lib/extensions/inherited_nested_layouts/action_controller/base.rb
deleted file mode 100644
index d352a754497..00000000000
--- a/lib/extensions/inherited_nested_layouts/action_controller/base.rb
+++ /dev/null
@@ -1,71 +0,0 @@
-# frozen_string_literal: true
-module Extensions::InheritedNestedLayouts::ActionController::Base
- # Gets the current layout used by this controller.
- #
- # @return [String] The layout used by the current controller.
- def current_layout
- _layout(nil, formats)
- end
-
- # Gets the parent layout of the given layout, as specified in the layout hierarchy.
- #
- # @param [String] of_layout The layout to obtain the parent of. If this is nil, obtains the
- # current controller's parent layout.
- # @return [String] The parent layout of the given layout.
- def parent_layout(of_layout: nil)
- layout = of_layout || current_layout
- layout_index = layout_hierarchy.find_index(layout)
- return nil if layout_index.nil? || layout_index == 0
-
- layout_hierarchy[layout_index - 1]
- end
-
- # Gets the layout hierarchy, from the outermost to the innermost layout.
- #
- # @return [Array] The layout hierarchy for this controller.
- def layout_hierarchy
- extension_module = Extensions::InheritedNestedLayouts::ActionController::Base
- @layout_hierarchy ||=
- extension_module.class_hierarchy(self.class).
- select { |klass| klass < ActionController::Base }.
- map { |klass| extension_module.class_layout(klass, self, formats) }.
- reject(&:nil?).
- uniq.
- reverse!
- end
-
- # Gets the superclass hierarchy for the given class. Object is not part of the returned result.
- #
- # @param [Class] klass The class to obtain the hierarchy of.
- # @return [Array] The superclass hierarchy for the given class.
- def self.class_hierarchy(klass)
- result = []
- while klass != Object
- result << klass
- klass = klass.superclass
- end
-
- result
- end
-
- # Gets the layout for objects of the given class.
- #
- # @param [Class] klass The class to obtain the layout of. This must be a subclass of
- # ActionController::Base
- # @param [ActionController::Base] self_ The instance to query against the class hierarchy.
- # @return [String] The layout to use for instances of +klass+.
- def self.class_layout(klass, self_, formats)
- layout_method = klass.instance_method(:_layout)
- layout = layout_method.bind(self_)
- layout.call(nil, formats)
- end
-
- # Overrides {ActionController::Rendering#render} to keep track of the :layout rendering option.
- def render(*args)
- options = args.extract_options!
- layout_hierarchy << options[:layout] if options.try(:key?, :layout)
-
- args << options
- super
- end
-end
diff --git a/lib/extensions/render_within_layout.rb b/lib/extensions/render_within_layout.rb
deleted file mode 100644
index 6bb7e4d36e9..00000000000
--- a/lib/extensions/render_within_layout.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-# frozen_string_literal: true
-module Extensions::RenderWithinLayout; end
diff --git a/lib/extensions/render_within_layout/action_view.rb b/lib/extensions/render_within_layout/action_view.rb
deleted file mode 100644
index 5db41cd636b..00000000000
--- a/lib/extensions/render_within_layout/action_view.rb
+++ /dev/null
@@ -1,2 +0,0 @@
-# frozen_string_literal: true
-module Extensions::RenderWithinLayout::ActionView; end
diff --git a/lib/extensions/render_within_layout/action_view/renderer.rb b/lib/extensions/render_within_layout/action_view/renderer.rb
deleted file mode 100644
index ce2ed8dc507..00000000000
--- a/lib/extensions/render_within_layout/action_view/renderer.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-module Extensions::RenderWithinLayout::ActionView::Renderer
- def render_within_layout(context, layout, *_, &block)
- context.view_flow.set(:layout, context.capture(&block))
- context.render template: "layouts/#{layout}"
- end
-end
diff --git a/lib/templates/slim/scaffold/_form.html.slim b/lib/templates/slim/scaffold/_form.html.slim
deleted file mode 100644
index a2ff775acb9..00000000000
--- a/lib/templates/slim/scaffold/_form.html.slim
+++ /dev/null
@@ -1,10 +0,0 @@
-= simple_form_for(@<%= singular_table_name %>) do |f|
- = f.error_notification
-
- .form-inputs
-<%- attributes.each do |attribute| -%>
- = f.<%= attribute.reference? ? :association : :input %> :<%= attribute.name %>
-<%- end -%>
-
- .form-actions
- = f.button :submit
diff --git a/public/403.json b/public/403.json
deleted file mode 100644
index 1f27118243c..00000000000
--- a/public/403.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "error": {
- "title": "Can't verify CSRF token authenticity"
- }
-}
diff --git a/public/404.json b/public/404.json
deleted file mode 100644
index e7dffc38313..00000000000
--- a/public/404.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "error": {
- "title": "The page you are looking for doesn't exist"
- }
-}
diff --git a/public/422.json b/public/422.json
deleted file mode 100644
index eb3034e043b..00000000000
--- a/public/422.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "error": {
- "title": "The change you wanted was rejected"
- }
-}
diff --git a/public/analytics.txt b/public/analytics.txt
deleted file mode 100644
index c6568fdbac4..00000000000
--- a/public/analytics.txt
+++ /dev/null
@@ -1 +0,0 @@
-GooGhywoiu9839t543j0s7543uw1 - pls add coursemology2@gmail.com to GA account UA-88563941-1 with ‘Manage Users and Edit’ permissions - date 11/21/2022.
\ No newline at end of file
diff --git a/public/favicon.ico b/public/favicon.ico
deleted file mode 100644
index e69de29bb2d..00000000000
diff --git a/public/robots.txt b/public/robots.txt
deleted file mode 100644
index 3c9c7c01f30..00000000000
--- a/public/robots.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
-#
-# To ban all spiders from the entire site uncomment the next two lines:
-# User-agent: *
-# Disallow: /
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index 857b6ff3b39..7f116c03888 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -117,30 +117,10 @@ def publicly_accessible?
end
end
- describe ApplicationThemingConcern do
- context 'when the instance has a theme' do
- it 'uses the theme' do
- pending 'an instance with a theme'
- raise
- end
- end
-
- context 'when the instance does not have a theme' do
- it 'uses the default theme' do
- expect(controller.send(:deduce_theme)).to eq('default')
- end
- end
- end
-
describe ApplicationUserConcern do
context 'when the action raises a CanCan::AccessDenied' do
run_rescue
- it 'renders the access denied page to /pages/403' do
- post :create
- expect(response).to render_template('pages/403')
- end
-
it 'returns HTTP status 403' do
post :create
expect(response.status).to eq(403)
@@ -158,14 +138,10 @@ def controller.index
end
end
- it 'renders the component not found page to /public/404' do
- get :index
- expect(response).to render_template(file: Rails.root.join('public', '404.html').to_s)
- end
-
it 'returns HTTP status 404' do
get :index
expect(response.status).to eq(404)
+ expect(response.body).to include('Component not found')
end
end
end
@@ -187,24 +163,10 @@ def controller.index
end
end
- it 'renders the request rejected page /public/422' do
- get :index
- expect(response).to render_template(file: Rails.root.join('public', '422.html').to_s)
- end
-
it 'returns HTTP status 422' do
get :index
expect(response.status).to eq(422)
end
-
- context 'when the request only accepts a json response' do
- before { request.accept = 'application/json' }
-
- it 'renders the correct template' do
- get :index
- expect(response.body).to eq(File.read(Rails.root.join('public', '422.json').to_s))
- end
- end
end
context 'when the action raises ActionController::InvalidAuthenticityToken' do
@@ -216,25 +178,11 @@ def controller.index
end
end
- it 'renders the request rejected page /public/403' do
- get :index
- expect(response).to render_template(file: Rails.root.join('public', '403.html').to_s)
- end
-
it 'returns HTTP status 403' do
# Replaced specific error check due to potential false positives
# expect { get :index }.to_not raise_error ActionController::InvalidAuthenticityToken
expect { get :index }.to_not raise_error
expect(response.status).to eq(403)
end
-
- context 'when the request only accepts a json response' do
- before { request.accept = 'application/json' }
-
- it 'renders the correct template' do
- get :index
- expect(response.body).to eq(File.read(Rails.root.join('public', '403.json').to_s))
- end
- end
end
end
diff --git a/spec/controllers/course/admin/admin_controller_spec.rb b/spec/controllers/course/admin/admin_controller_spec.rb
index d0e63cb3716..be560d104e0 100644
--- a/spec/controllers/course/admin/admin_controller_spec.rb
+++ b/spec/controllers/course/admin/admin_controller_spec.rb
@@ -8,7 +8,7 @@
before { sign_in(user) }
describe '#index' do
- subject { get :index, params: { course_id: course } }
+ subject { get :index, as: :json, params: { course_id: course } }
context 'when the user is a Course Manager' do
let(:user) { create(:course_manager, course: course).user }
diff --git a/spec/controllers/course/assessment/assessments_controller_spec.rb b/spec/controllers/course/assessment/assessments_controller_spec.rb
index 6f8e061d651..c8d4b2e2a35 100644
--- a/spec/controllers/course/assessment/assessments_controller_spec.rb
+++ b/spec/controllers/course/assessment/assessments_controller_spec.rb
@@ -20,27 +20,25 @@
describe '#index' do
context 'when a category is given' do
before do
- post :index,
- params: {
- course_id: course,
- id: immutable_assessment,
- assessment: { title: '' },
- category: category
- }
+ post :index, as: :json, params: {
+ course_id: course,
+ id: immutable_assessment,
+ assessment: { title: '' },
+ category: category
+ }
end
it { expect(controller.instance_variable_get(:@category)).to eq(category) }
end
context 'when a tab is given' do
before do
- post :index,
- params: {
- course_id: course,
- id: immutable_assessment,
- assessment: { title: '' },
- category: category,
- tab: tab
- }
+ post :index, as: :json, params: {
+ course_id: course,
+ id: immutable_assessment,
+ assessment: { title: '' },
+ category: category,
+ tab: tab
+ }
end
it { expect(controller.instance_variable_get(:@tab)).to eq(tab) }
end
@@ -53,7 +51,7 @@
assessment
end
- subject { get :edit, params: { course_id: course, id: assessment } }
+ subject { get :edit, as: :json, params: { course_id: course, id: assessment } }
context 'when edit page is loaded' do
it 'sanitizes the description text' do
diff --git a/spec/controllers/course/assessment/question/forum_post_responses_controller_spec.rb b/spec/controllers/course/assessment/question/forum_post_responses_controller_spec.rb
index 1faaa15bdac..9eb20c0e12b 100644
--- a/spec/controllers/course/assessment/question/forum_post_responses_controller_spec.rb
+++ b/spec/controllers/course/assessment/question/forum_post_responses_controller_spec.rb
@@ -51,12 +51,11 @@
end
subject do
- get :edit,
- params: {
- course_id: course,
- assessment_id: assessment,
- id: forum_post_response
- }
+ get :edit, as: :json, params: {
+ course_id: course,
+ assessment_id: assessment,
+ id: forum_post_response
+ }
end
context 'when edit page is loaded' do
diff --git a/spec/controllers/course/assessment/question/text_responses_controller_spec.rb b/spec/controllers/course/assessment/question/text_responses_controller_spec.rb
index ac39d068d9d..37aa04a24f2 100644
--- a/spec/controllers/course/assessment/question/text_responses_controller_spec.rb
+++ b/spec/controllers/course/assessment/question/text_responses_controller_spec.rb
@@ -51,12 +51,11 @@
end
subject do
- get :edit,
- params: {
- course_id: course,
- assessment_id: assessment,
- id: text_response
- }
+ get :edit, as: :json, params: {
+ course_id: course,
+ assessment_id: assessment,
+ id: text_response
+ }
end
context 'when edit page is loaded' do
diff --git a/spec/controllers/course/assessment/submission/submissions_controller_spec.rb b/spec/controllers/course/assessment/submission/submissions_controller_spec.rb
index 5626e52aa99..e9560fb2246 100644
--- a/spec/controllers/course/assessment/submission/submissions_controller_spec.rb
+++ b/spec/controllers/course/assessment/submission/submissions_controller_spec.rb
@@ -150,7 +150,7 @@
describe '#extract_instance_variables' do
subject do
- get :edit, params: { course_id: course, assessment_id: assessment, id: immutable_submission }
+ get :edit, as: :json, params: { course_id: course, assessment_id: assessment, id: immutable_submission }
end
it 'extracts instance variables from services' do
diff --git a/spec/controllers/course/assessment/submissions_controller_spec.rb b/spec/controllers/course/assessment/submissions_controller_spec.rb
index b5a58a3f959..0979b2edd42 100644
--- a/spec/controllers/course/assessment/submissions_controller_spec.rb
+++ b/spec/controllers/course/assessment/submissions_controller_spec.rb
@@ -13,7 +13,7 @@
describe '#index' do
context 'when no category is specified' do
- before { get :index, params: { course_id: course } }
+ before { get :index, as: :json, params: { course_id: course } }
it 'sets the category to the first category' do
first_category = course.assessment_categories.first
@@ -29,7 +29,7 @@
let!(:submission) do
create(:submission, :published, creator: student, assessment: assessment)
end
- before { get :index, params: { course_id: course, category: category } }
+ before { get :index, as: :json, params: { course_id: course, category: category } }
it 'sets the category to be the specified category' do
expect(controller.instance_variable_get(:@category)).to eq(category)
diff --git a/spec/controllers/course/courses_controller_spec.rb b/spec/controllers/course/courses_controller_spec.rb
index 919cf74ca4f..756878bef6d 100644
--- a/spec/controllers/course/courses_controller_spec.rb
+++ b/spec/controllers/course/courses_controller_spec.rb
@@ -23,7 +23,7 @@
describe '#index' do
context 'when there is no user logged in' do
it 'allows unauthenticated access' do
- get :index
+ get :index, as: :json
expect(response).to be_successful
end
end
@@ -33,7 +33,7 @@
it 'allows access' do
sign_in(user)
- get :index
+ get :index, as: :json
expect(response).to be_successful
end
end
diff --git a/spec/controllers/course/discussion/topics_controller_spec.rb b/spec/controllers/course/discussion/topics_controller_spec.rb
index 001e78ef3d6..f9092d44038 100644
--- a/spec/controllers/course/discussion/topics_controller_spec.rb
+++ b/spec/controllers/course/discussion/topics_controller_spec.rb
@@ -19,7 +19,7 @@
let(:topics) { controller.instance_variable_get(:@topics) }
describe '#index' do
- subject { get :index, params: { course_id: course } }
+ subject { get :index, as: :json, params: { course_id: course } }
context 'when a course staff visits the page' do
before { sign_in(staff) }
diff --git a/spec/controllers/course/learning_map/learning_map_controller_spec.rb b/spec/controllers/course/learning_map/learning_map_controller_spec.rb
index d3567d07f00..98b28597e2c 100644
--- a/spec/controllers/course/learning_map/learning_map_controller_spec.rb
+++ b/spec/controllers/course/learning_map/learning_map_controller_spec.rb
@@ -16,7 +16,7 @@
before do
allow(controller).to receive_message_chain('current_component_host.[]').and_return(nil)
end
- subject { get :index, params: { course_id: course.id } }
+ subject { get :index, as: :json, params: { course_id: course.id } }
it 'raises a component not found error' do
expect { subject }.to raise_error(ComponentNotFoundError)
end
@@ -27,7 +27,7 @@
let!(:achievement2) { create(:course_achievement, course: course) }
subject do
- post :add_parent_node, params: {
+ post :add_parent_node, as: :json, params: {
course_id: course.id, parent_node_id: "achievement-#{achievement1.id}",
node_id: "achievement-#{achievement2.id}"
}
@@ -52,7 +52,7 @@
end
subject do
- post :remove_parent_node, params: {
+ post :remove_parent_node, as: :json, params: {
course_id: course.id, parent_node_id: "achievement-#{achievement1.id}",
node_id: "achievement-#{achievement2.id}"
}
@@ -74,7 +74,7 @@
satisfiability_type: :all_conditions)
end
subject do
- post :toggle_satisfiability_type, params: {
+ post :toggle_satisfiability_type, as: :json, params: {
course_id: course.id, node_id: "achievement-#{achievement.id}"
}
end
@@ -94,7 +94,7 @@
satisfiability_type: :at_least_one_condition)
end
subject do
- post :toggle_satisfiability_type, params: {
+ post :toggle_satisfiability_type, as: :json, params: {
course_id: course.id, node_id: "achievement-#{achievement.id}"
}
end
diff --git a/spec/controllers/course/material/materials_controller_spec.rb b/spec/controllers/course/material/materials_controller_spec.rb
index 10d4a2a2647..935e99a9098 100644
--- a/spec/controllers/course/material/materials_controller_spec.rb
+++ b/spec/controllers/course/material/materials_controller_spec.rb
@@ -21,7 +21,9 @@
let(:material) { create(:material, folder: folder) }
subject { get :show, params: { course_id: course, folder_id: folder, id: material } }
- it { is_expected.to redirect_to(material.attachment.url) }
+ it 'renders the attachment url' do
+ expect(subject.body).to include(material.attachment.url)
+ end
context 'when a material is uploaded for an assessment' do
let!(:assessment) do
@@ -37,7 +39,7 @@
subject
expect(assessment.submissions.length).to eq(1)
expect(assessment.submissions.first.answers.length).to eq(assessment.questions.length)
- is_expected.to redirect_to(material_assessment.attachment.url)
+ expect(response.body).to include(material_assessment.attachment.url)
end
end
end
diff --git a/spec/controllers/course/personal_times_controller_spec.rb b/spec/controllers/course/personal_times_controller_spec.rb
index e26c0d91e8a..42bf399b050 100644
--- a/spec/controllers/course/personal_times_controller_spec.rb
+++ b/spec/controllers/course/personal_times_controller_spec.rb
@@ -8,7 +8,7 @@
let(:course_user) { create(:course_user, course: course) }
describe '#index' do
- subject { get :index, params: { course_id: course, user_id: course_user } }
+ subject { get :index, as: :json, params: { course_id: course, user_id: course_user } }
context 'when a Normal User visits the page' do
let(:user) { create(:user) }
diff --git a/spec/controllers/course/reference_timelines_controller_spec.rb b/spec/controllers/course/reference_timelines_controller_spec.rb
index f04506630b2..b0318215e89 100644
--- a/spec/controllers/course/reference_timelines_controller_spec.rb
+++ b/spec/controllers/course/reference_timelines_controller_spec.rb
@@ -13,7 +13,7 @@
before { sign_in(user) }
describe '#index' do
- subject { get :index, params: { course_id: course } }
+ subject { get :index, as: :json, params: { course_id: course } }
context 'when the user is a manager of the course' do
let(:user) { create(:course_manager, course: course).user }
diff --git a/spec/controllers/course/statistics/statistics_controller_spec.rb b/spec/controllers/course/statistics/statistics_controller_spec.rb
index 06e3499b070..0c8ff3be76d 100644
--- a/spec/controllers/course/statistics/statistics_controller_spec.rb
+++ b/spec/controllers/course/statistics/statistics_controller_spec.rb
@@ -9,7 +9,7 @@
let(:course_user) { create(:course_user, course: course) }
describe '#index' do
- subject { get :index, params: { course_id: course, user_id: course_user } }
+ subject { get :index, as: :json, params: { course_id: course, user_id: course_user } }
context 'when a Normal User visits the page' do
let(:user) { create(:user) }
diff --git a/spec/controllers/course/survey/responses_controller_spec.rb b/spec/controllers/course/survey/responses_controller_spec.rb
index 4264d5774a7..4293b993b38 100644
--- a/spec/controllers/course/survey/responses_controller_spec.rb
+++ b/spec/controllers/course/survey/responses_controller_spec.rb
@@ -32,12 +32,6 @@
describe '#index' do
let(:user) { create(:administrator) }
- context 'when html page is requested' do
- subject { get :index, params: { course_id: course.id, survey_id: survey.id } }
-
- it { is_expected.to render_template('index') }
- end
-
context 'when json data is requested' do
render_views
subject { get :index, params: { format: :json, course_id: course.id, survey_id: survey.id } }
diff --git a/spec/controllers/course/survey/surveys_controller_spec.rb b/spec/controllers/course/survey/surveys_controller_spec.rb
index 2d3b402e9e7..af556b2a602 100644
--- a/spec/controllers/course/survey/surveys_controller_spec.rb
+++ b/spec/controllers/course/survey/surveys_controller_spec.rb
@@ -33,24 +33,6 @@
describe '#index' do
let!(:published_survey) { create(:survey, :published, course: course) }
- context 'when html page is requested' do
- let(:user) { student.user }
- subject { get :index, params: { course_id: course.id } }
-
- it { is_expected.to render_template('index') }
-
- context 'when survey component is disabled' do
- before do
- allow(controller).
- to receive_message_chain('current_component_host.[]').and_return(nil)
- end
-
- it 'raises an component not found error' do
- expect { subject }.to raise_error(ComponentNotFoundError)
- end
- end
- end
-
context 'when json data is requested' do
render_views
subject { get :index, as: :json, params: { course_id: course.id } }
@@ -100,13 +82,6 @@
describe '#show' do
let(:survey_traits) { :published }
- context 'when html page is requested' do
- let(:user) { student.user }
- subject { get :show, params: { course_id: course.id, id: survey.id } }
-
- it { is_expected.to render_template('index') }
- end
-
context 'when json data is requested' do
render_views
let(:user) { manager.user }
@@ -223,12 +198,6 @@
describe '#results' do
let(:user) { admin }
- context 'when html page is requested' do
- subject { get :results, params: { course_id: course.id, id: survey.id } }
-
- it { is_expected.to render_template('index') }
- end
-
context 'when json data is requested' do
render_views
let(:response_traits) { :submitted }
@@ -359,13 +328,13 @@
let(:user) { admin }
subject do
- get :download, params: { course_id: course.id, id: survey.id }
+ get :download, as: :json, params: { course_id: course.id, id: survey.id }
end
- it 'returns a html file' do
+ it 'renders a submitted job response' do
subject
- expect(response.header['Content-Type']).to include('text/html')
- expect(response.status).to eq(302)
+ expect(response).to render_template(partial: 'jobs/_submitted')
+ expect(response).to have_http_status(:ok)
end
end
end
diff --git a/spec/controllers/course/user_email_subscriptions_controller_spec.rb b/spec/controllers/course/user_email_subscriptions_controller_spec.rb
index 604e081212e..b4643228f5d 100644
--- a/spec/controllers/course/user_email_subscriptions_controller_spec.rb
+++ b/spec/controllers/course/user_email_subscriptions_controller_spec.rb
@@ -11,12 +11,6 @@
let!(:student) { create(:course_student, course: course) }
let(:json_response) { JSON.parse(response.body) }
- describe '#edit html' do
- before { sign_in(staff.user) }
- subject { get :edit, params: { course_id: course, user_id: staff } }
- it { is_expected.to render_template(:edit) }
- end
-
describe '#edit json' do
before { sign_in(student.user) }
context 'when an unsubscription link for surveys closing reminder is clicked' do
diff --git a/spec/controllers/course/user_registrations_controller_spec.rb b/spec/controllers/course/user_registrations_controller_spec.rb
index 14615ad1bb1..d9d052f4cd7 100644
--- a/spec/controllers/course/user_registrations_controller_spec.rb
+++ b/spec/controllers/course/user_registrations_controller_spec.rb
@@ -18,22 +18,14 @@
let!(:course_student) { create(:course_student, course: course, user: user) }
it { expect { subject }.not_to(change { course.course_users.count }) }
- it { is_expected.to redirect_to(course_path(course)) }
- it 'sets the proper flash message' do
- subject
- expect(flash[:info]).to eq(I18n.t('course.users.new.already_registered'))
- end
+ it { is_expected.to have_http_status(:conflict) }
end
context 'when the user is a manager of the course' do
let!(:course_manager) { create(:course_manager, course: course, user: user) }
it { expect { subject }.not_to(change { course.course_users.reload.count }) }
- it { is_expected.to redirect_to(course_path(course)) }
- it 'sets the proper flash message' do
- subject
- expect(flash[:info]).to eq(I18n.t('course.users.new.already_registered'))
- end
+ it { is_expected.to have_http_status(:conflict) }
end
end
end
@@ -67,11 +59,7 @@
let(:registration_code) { invitation.invitation_key }
it { expect { subject }.not_to(change { course.course_users.reload.count }) }
- it { is_expected.to redirect_to(course_path(course)) }
- it 'sets the proper flash message' do
- subject
- expect(flash[:info]).to eq(I18n.t('course.users.new.already_registered'))
- end
+ it { is_expected.to have_http_status(:conflict) }
end
end
@@ -102,11 +90,7 @@
before { create(:course_student, course: course, user: user) }
it { expect { subject }.not_to(change { course.course_users.count }) }
- it { is_expected.to redirect_to(course_path(course)) }
- it 'sets the proper flash message' do
- subject
- expect(flash[:info]).to eq(I18n.t('course.users.new.already_registered'))
- end
+ it { is_expected.to have_http_status(:conflict) }
end
end
diff --git a/spec/controllers/course/users_controller_spec.rb b/spec/controllers/course/users_controller_spec.rb
index 438498be536..39b486869b4 100644
--- a/spec/controllers/course/users_controller_spec.rb
+++ b/spec/controllers/course/users_controller_spec.rb
@@ -15,7 +15,7 @@
describe '#students' do
before { sign_in(user) }
- subject { get :students, params: { course_id: course } }
+ subject { get :students, as: :json, params: { course_id: course } }
context 'when a course manager visits the page' do
let!(:course_lecturer) { create(:course_manager, course: course, user: user) }
@@ -35,7 +35,7 @@
describe '#staff' do
before { sign_in(user) }
- subject { get :staff, params: { course_id: course } }
+ subject { get :staff, as: :json, params: { course_id: course } }
context 'when a course manager visits the page' do
let!(:course_lecturer) { create(:course_manager, course: course, user: user) }
diff --git a/spec/controllers/course/video_submissions_controller_spec.rb b/spec/controllers/course/video_submissions_controller_spec.rb
index 71d5120080b..8702d60d03e 100644
--- a/spec/controllers/course/video_submissions_controller_spec.rb
+++ b/spec/controllers/course/video_submissions_controller_spec.rb
@@ -8,7 +8,7 @@
let!(:course_user) { create(:course_user, course: course) }
describe '#index' do
- subject { get :index, params: { course_id: course, user_id: course_user } }
+ subject { get :index, as: :json, params: { course_id: course, user_id: course_user } }
before { sign_in(user) }
context 'when a Normal User visits the page' do
diff --git a/spec/controllers/system/admin/admin_controller_spec.rb b/spec/controllers/system/admin/admin_controller_spec.rb
index 40c5486646c..044b056abbf 100644
--- a/spec/controllers/system/admin/admin_controller_spec.rb
+++ b/spec/controllers/system/admin/admin_controller_spec.rb
@@ -9,7 +9,7 @@
before { sign_in(user) }
describe '#index' do
- before { get :index }
+ before { get :index, as: :json }
it { is_expected.to render_template('index') }
end
end
diff --git a/spec/controllers/system/admin/courses_controller_spec.rb b/spec/controllers/system/admin/courses_controller_spec.rb
index f1c6bb73f32..651ab76ef8a 100644
--- a/spec/controllers/system/admin/courses_controller_spec.rb
+++ b/spec/controllers/system/admin/courses_controller_spec.rb
@@ -10,7 +10,7 @@
let(:normal_user) { create(:user) }
describe '#index' do
- subject { get :index }
+ subject { get :index, as: :json }
context 'when a system administrator visits the page' do
before { sign_in(admin) }
diff --git a/spec/controllers/system/admin/instance/admin_controller_spec.rb b/spec/controllers/system/admin/instance/admin_controller_spec.rb
index bc7717018ea..90a90303da1 100644
--- a/spec/controllers/system/admin/instance/admin_controller_spec.rb
+++ b/spec/controllers/system/admin/instance/admin_controller_spec.rb
@@ -9,7 +9,7 @@
before { sign_in(user) }
describe '#index' do
- subject { get :index }
+ subject { get :index, as: :json }
context 'when a system administrator visits the page' do
let(:user) { create(:administrator) }
it { is_expected.to render_template(:index) }
diff --git a/spec/controllers/system/admin/instance/courses_controller_spec.rb b/spec/controllers/system/admin/instance/courses_controller_spec.rb
index 7bbdc7e1851..2a1bf81dba0 100644
--- a/spec/controllers/system/admin/instance/courses_controller_spec.rb
+++ b/spec/controllers/system/admin/instance/courses_controller_spec.rb
@@ -9,7 +9,7 @@
let(:normal_user) { create(:user) }
describe '#index' do
- subject { get :index }
+ subject { get :index, as: :json }
context 'when an administrator visits the page' do
before { sign_in(instance_admin) }
diff --git a/spec/controllers/system/admin/instance/user_invitations_controller_spec.rb b/spec/controllers/system/admin/instance/user_invitations_controller_spec.rb
index edcae37efa0..67cb7ea7acd 100644
--- a/spec/controllers/system/admin/instance/user_invitations_controller_spec.rb
+++ b/spec/controllers/system/admin/instance/user_invitations_controller_spec.rb
@@ -8,7 +8,7 @@
let(:normal_user) { create(:user) }
describe '#index' do
- subject { get :index }
+ subject { get :index, as: :json }
context 'when a system administrator visits the page' do
before { sign_in(instance_admin) }
diff --git a/spec/controllers/system/admin/instance/users_controller_spec.rb b/spec/controllers/system/admin/instance/users_controller_spec.rb
index 57536349ce7..29c14f43571 100644
--- a/spec/controllers/system/admin/instance/users_controller_spec.rb
+++ b/spec/controllers/system/admin/instance/users_controller_spec.rb
@@ -9,7 +9,7 @@
let(:normal_user) { create(:user) }
describe '#index' do
- subject { get :index }
+ subject { get :index, as: :json }
context 'when an instance administrator visits the page' do
before { sign_in(instance_admin) }
diff --git a/spec/controllers/system/admin/users_controller_spec.rb b/spec/controllers/system/admin/users_controller_spec.rb
index 0f6fc09374d..aef5f9f4d2c 100644
--- a/spec/controllers/system/admin/users_controller_spec.rb
+++ b/spec/controllers/system/admin/users_controller_spec.rb
@@ -10,7 +10,7 @@
let(:normal_user) { create(:user) }
describe '#index' do
- subject { get :index }
+ subject { get :index, as: :json }
context 'when a system administrator visits the page' do
before { sign_in(admin) }
diff --git a/spec/controllers/user/profiles_controller_spec.rb b/spec/controllers/user/profiles_controller_spec.rb
index 4bd5ffbeedc..4173852b8e3 100644
--- a/spec/controllers/user/profiles_controller_spec.rb
+++ b/spec/controllers/user/profiles_controller_spec.rb
@@ -8,22 +8,18 @@
let!(:user) { create(:user) }
describe '#edit' do
- subject { get :edit }
+ subject { get :edit, as: :json }
context 'when user is signed in' do
before { sign_in(user) }
it { is_expected.to render_template(:edit) }
end
-
- context 'when user is not signed in' do
- it { is_expected.to redirect_to(new_user_session_path) }
- end
end
describe '#update' do
let(:new_name) { 'New Name' }
- subject { patch :update, params: { user: { name: new_name } } }
+ subject { patch :update, as: :json, params: { user: { name: new_name } } }
context 'when user is signed in' do
before { sign_in(user) }
@@ -33,10 +29,6 @@
expect(user.reload.name).to eq(new_name)
end
end
-
- context 'when user is not signed in' do
- it { is_expected.to redirect_to(new_user_session_path) }
- end
end
end
end
diff --git a/spec/controllers/user/registration_controller_spec.rb b/spec/controllers/user/registration_controller_spec.rb
index b45ab66829d..92f60b4d3ba 100644
--- a/spec/controllers/user/registration_controller_spec.rb
+++ b/spec/controllers/user/registration_controller_spec.rb
@@ -34,11 +34,9 @@
@request.env['devise.mapping'] = Devise.mappings[:user]
end
- it 'flashes error message and no new user is registered' do
+ it 'does not register any new users' do
allow(controller).to receive(:verify_recaptcha).and_return(false)
expect { subject }.to change { User.count }.by(0)
- expect(flash[:alert]).to be_present
- expect(response).to render_template(:new)
end
end
end
diff --git a/spec/coverage_helper.rb b/spec/coverage_helper.rb
deleted file mode 100644
index a19625473e9..00000000000
--- a/spec/coverage_helper.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-# frozen_string_literal: true
-require 'simplecov'
-
-if ENV['CI']
- require 'codecov'
- SimpleCov.formatter = SimpleCov::Formatter::Codecov
-
- # Code coverage exclusions
- SimpleCov.start('rails') do
- # SimpleCov configuration
- # Helpers for schema migrations. We don't test schema migrations, so these would never run.
- add_filter '/lib/extensions/legacy/active_record/connection_adapters/table_definition.rb'
-
- # Extra statistics to be placed in `rake stats`. We don't run that on CI, so coverage is not
- # important.
- add_filter '/lib/tasks/coursemology/stats_setup.rake'
-
- # Rake task to seed dev database with course and assessment data.
- add_filter '/lib/tasks/coursemology/seed.rake'
-
- # take tasks is excluded for coverage
- add_filter '/lib/tasks/'
- end
-end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index c3d245f8b88..4a8d23d98b3 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -7,15 +7,22 @@
factory :user, aliases: [:creator, :updater, :actor] do
transient do
emails_count { 1 }
+ email { nil }
end
name
role { :normal }
- password { 'lolololol' }
+ password { Application::Application.config.x.default_user_password }
after(:build) do |user, evaluator|
emails = build_list(:user_email, evaluator.emails_count, primary: false, user: user)
- emails.take(1).each { |user_email| user_email.primary = true }
+
+ if (email = evaluator.email)
+ user.emails << build(:user_email, email: email, primary: true, user: user)
+ else
+ emails.take(1).each { |user_email| user_email.primary = true }
+ end
+
user.emails.concat(emails)
end
diff --git a/spec/features/course/assessment/answer/forum_post_response_answer_spec.rb b/spec/features/course/assessment/answer/forum_post_response_answer_spec.rb
index 01b8335a9cd..f249f6ba8bd 100644
--- a/spec/features/course/assessment/answer/forum_post_response_answer_spec.rb
+++ b/spec/features/course/assessment/answer/forum_post_response_answer_spec.rb
@@ -8,12 +8,9 @@
with_tenant(:instance) do
let(:course) { create(:course) }
let(:assessment) { create(:assessment, :published_with_forum_post_response_question, course: course) }
- before { login_as(user, scope: :user) }
+ let(:submission) { create(:submission, :attempting, assessment: assessment, creator: user) }
- let(:submission) do
- create(:submission, *submission_traits, assessment: assessment, creator: user)
- end
- let(:submission_traits) { nil }
+ before { login_as(user, scope: :user) }
context 'As a Course Student' do
let(:user) { create(:course_student, course: course).user }
diff --git a/spec/features/course/assessment/answer/multiple_response_answer_spec.rb b/spec/features/course/assessment/answer/multiple_response_answer_spec.rb
index bb0a35346a7..b2b18393fa5 100644
--- a/spec/features/course/assessment/answer/multiple_response_answer_spec.rb
+++ b/spec/features/course/assessment/answer/multiple_response_answer_spec.rb
@@ -75,7 +75,7 @@
visit edit_course_assessment_submission_path(course, assessment, submission)
option = assessment.questions.first.actable.options.first
- element = find('b', text: option.option).find('div')
+ element = find('p', text: option.option)
expect(element.style('background-color')['background-color']).to eq('rgba(232, 245, 233, 1)')
end
diff --git a/spec/features/course/assessment/answer/programming_answer_spec.rb b/spec/features/course/assessment/answer/programming_answer_spec.rb
index ff9e1fa14a8..63389efccc8 100644
--- a/spec/features/course/assessment/answer/programming_answer_spec.rb
+++ b/spec/features/course/assessment/answer/programming_answer_spec.rb
@@ -1,119 +1,77 @@
# frozen_string_literal: true
require 'rails_helper'
-RSpec.describe 'Course: Assessments: Submissions: Programming Answers' do
+RSpec.describe 'Course: Assessments: Submissions: Programming Answers', js: true do
let(:instance) { Instance.default }
with_tenant(:instance) do
let(:course) { create(:course) }
let(:assessment) { create(:assessment, :published_with_programming_question, course: course) }
- let(:assessment2) { create(:assessment, :published_with_programming_question, course: course) }
- let(:submission) do
- create(:submission, *submission_traits, assessment: assessment, creator: user)
- end
- let(:submission2) do
- create(:submission, *submission_traits2, assessment: assessment2, creator: user)
- end
+ let(:submission) { create(:submission, *submission_traits, assessment: assessment, creator: user) }
let(:submission_traits) { nil }
- let(:submission_traits2) { nil }
before { login_as(user, scope: :user) }
context 'As a Course Student' do
let(:user) { create(:course_student, course: course).user }
+ let(:submission_traits) { :attempting }
+ let(:answer_code) { 'this is a testing code whatever lol' }
- scenario 'I can save my submission', js: true do
- pending 'Removed add/delete file links for CS1010S'
+ scenario 'I can save my submission' do
visit edit_course_assessment_submission_path(course, assessment, submission)
- # Fill in every single successive item
- within find(content_tag_selector(submission.answers.first)) do
- all('div.files div.nested-fields').each_with_index do |file, i|
- within file do
- fill_in 'filename', with: "test #{i}.py"
- end
- end
- end
-
- click_button I18n.t('course.assessment.submission.submissions.buttons.save')
- expect(current_path).to eq(
- edit_course_assessment_submission_path(course, assessment, submission)
- )
+ find('div', class: 'ace_editor').click
+ send_keys answer_code
+ click_button 'Save Draft'
+ wait_for_page
- submission.answers.first.specific.files.reload.each_with_index do |_, i|
- expect(page).to have_field('filename', with: "test #{i}.py")
- end
-
- # Add a new file
- click_link(I18n.t('course.assessment.answer.programming.programming.add_file'))
- new_file_name = 'new_file.py'
- expected_files = [new_file_name] + submission.answers.first.specific.files.map(&:filename)
- within find(content_tag_selector(submission.answers.first)) do
- within all('div.files div.nested-fields').first do
- fill_in 'filename', with: new_file_name
- end
- end
- click_button I18n.t('course.assessment.submission.submissions.buttons.save')
-
- expected_files.each do |file|
- expect(page).to have_field('filename', with: file)
- end
-
- # Delete files
- within find(content_tag_selector(submission.answers.first)) do
- all(:link, I18n.t('course.assessment.answer.programming.file_fields.delete')).
- each(&:click)
- end
- click_button I18n.t('course.assessment.submission.submissions.buttons.save')
-
- expect(page).not_to have_field('filename')
+ file = submission.answers.first.specific.files.reload.first
+ expect(file.content).to eq(answer_code)
end
- pending 'I can only see public test cases but cannot update my finalized submission ' do
+ scenario 'I can only see public test cases but cannot update my finalized submission ' do
create(:course_assessment_question_programming,
assessment: assessment, test_case_count: 1, private_test_case_count: 1,
evaluation_test_case_count: 1)
visit edit_course_assessment_submission_path(course, assessment, submission)
- expect(page).to have_selector('.code')
- click_button I18n.t('course.assessment.submission.submissions.buttons.finalise')
+ expect(page).to have_selector('.ace_editor')
- within find(content_tag_selector(submission.answers.first)) do
- expect(page).not_to have_selector('.code')
- end
+ click_button 'Finalise Submission'
+ click_button 'Continue'
+
+ expect(page).not_to have_selector('.ace_editor')
- # Check that student can only see public but not private and evalution test cases.
- expect(page).
- to have_text(I18n.t('course.assessment.answer.programming.test_cases.public'))
- expect(page).
- not_to have_text(I18n.t('course.assessment.answer.programming.test_cases.private'))
- expect(page).
- not_to have_text(I18n.t('course.assessment.answer.programming.test_cases.evaluation'))
+ expect(page).to have_text('Public Test Cases')
+ expect(page).not_to have_text('Private Test Cases')
+ expect(page).not_to have_text('Evaluation Test Cases')
end
end
context 'As Course Staff' do
let(:user) { create(:course_teaching_assistant, course: course).user }
- let(:submission_traits) { :submitted }
- let(:submission_traits2) { :attempting }
- pending 'I can view the test cases' do
- # View test cases for submitted submission
- visit edit_course_assessment_submission_path(course, assessment, submission)
+ context 'when submission is submitted' do
+ let(:submission_traits) { :submitted }
+
+ scenario 'I can view the test cases' do
+ visit edit_course_assessment_submission_path(course, assessment, submission)
- within find(content_tag_selector(submission.answers.first)) do
assessment.questions.first.actable.test_cases.each do |test_case|
- expect(page).to have_content_tag_for(test_case)
+ expect(page).to have_text(test_case.identifier)
end
end
+ end
- # View test cases for attempting submission
- visit edit_course_assessment_submission_path(course, assessment_2, submission2)
+ context 'when submission is attempting' do
+ let(:submission) { create(:submission, :attempting, assessment: assessment, creator: user) }
- within find(content_tag_selector(submission2.answers.first)) do
- assessment_2.questions.first.actable.test_cases.each do |solution|
- expect(page).to have_content_tag_for(solution)
+ scenario 'I can view the test cases' do
+ visit edit_course_assessment_submission_path(course, assessment, submission)
+
+ assessment.questions.first.actable.test_cases.each do |test_case|
+ expect(page).to have_text(test_case.identifier)
end
end
end
diff --git a/spec/features/course/assessment/answer/text_response_answer_spec.rb b/spec/features/course/assessment/answer/text_response_answer_spec.rb
index a534a227e10..f0913f4a316 100644
--- a/spec/features/course/assessment/answer/text_response_answer_spec.rb
+++ b/spec/features/course/assessment/answer/text_response_answer_spec.rb
@@ -6,60 +6,59 @@
with_tenant(:instance) do
let(:course) { create(:course) }
- let(:assessment) { create(:assessment, :published_with_text_response_question, course: course) }
- before { login_as(user, scope: :user) }
+ let(:user) { create(:course_student, course: course).user }
+ let(:submission) { create(:submission, :attempting, assessment: assessment, creator: user) }
- let(:submission) do
- create(:submission, *submission_traits, assessment: assessment, creator: user)
- end
- let(:submission_traits) { nil }
+ before { login_as(user, scope: :user) }
context 'As a Course Student' do
- let(:user) { create(:course_student, course: course).user }
- let(:file_path) do
- File.join(Rails.root, '/spec/fixtures/files/text.txt')
- end
+ let(:file_path) { File.join(Rails.root, '/spec/fixtures/files/text.txt') }
- scenario 'I cannot update my submission after finalising' do
- visit edit_course_assessment_submission_path(course, assessment, submission)
+ context 'when it is a file upload question' do
+ let(:assessment) { create(:assessment, :published_with_text_response_question, course: course) }
- answer_id = submission.answers.first.id
- find_field(name: "#{answer_id}.answer_text").set('Test')
- click_button('Finalise Submission')
- accept_confirm_dialog do
- wait_for_job
+ scenario 'I cannot update my submission after finalising' do
+ visit edit_course_assessment_submission_path(course, assessment, submission)
+
+ answer_id = submission.answers.first.id
+ find_field(name: "#{answer_id}.answer_text").set('Test')
+ click_button('Finalise Submission')
+ accept_confirm_dialog do
+ wait_for_job
+ end
+ expect(page).not_to have_field(name: "#{answer_id}[answer_text]")
end
- expect(page).not_to have_field(name: "#{answer_id}[answer_text]")
- end
- scenario 'I upload an attachment to the answer' do
- visit edit_course_assessment_submission_path(course, assessment, submission)
- answer = submission.answers.last
- file_view = find('strong', text: 'Uploaded Files:').find(:xpath, '..')
- dropzone = find('.dropzone-input')
- file_input = dropzone.find('input', visible: false)
+ scenario 'I upload an attachment to the answer' do
+ visit edit_course_assessment_submission_path(course, assessment, submission)
+ answer = submission.answers.last
+ file_view = all('div', text: 'Uploaded Files').last
+ dropzone = find('.dropzone-input')
+ file_input = dropzone.find('input', visible: false)
- file_input.set(file_path)
+ file_input.set(file_path)
- # The file should show in the dropzone
- expect(dropzone).to have_css('span', text: 'text.txt')
+ # The file should show in the dropzone
+ expect(dropzone).to have_css('span', text: 'text.txt')
- click_button('Save Draft')
+ click_button('Save Draft')
- expect(dropzone).to have_no_css('span', text: 'text.txt')
- expect(file_view).to have_css('span', text: 'text.txt')
- expect(file_view).to have_css('span', count: 2)
- expect(answer.specific.attachments).not_to be_empty
+ expect(dropzone).to have_no_css('span', text: 'text.txt')
+ expect(file_view).to have_css('span', text: 'text.txt')
+ expect(file_view).to have_css('span', count: 2)
+ expect(answer.specific.attachments).not_to be_empty
+ end
end
- scenario 'I cannot see the text box for a file upload question' do
- assessment = create(:assessment, :published_with_file_upload_question, course: course)
- submission = create(:submission, assessment: assessment, creator: user)
+ context 'when it is a text response question' do
+ let(:assessment) { create(:assessment, :published_with_file_upload_question, course: course) }
- visit edit_course_assessment_submission_path(course, assessment, submission)
+ scenario 'I cannot see the text box for a file upload question' do
+ visit edit_course_assessment_submission_path(course, assessment, submission)
- file_upload_answer = submission.answers.first
- expect(page).not_to have_field(name: "#{file_upload_answer.id}[answer_text]")
+ file_upload_answer = submission.answers.first
+ expect(page).not_to have_field(name: "#{file_upload_answer.id}[answer_text]")
+ end
end
end
end
diff --git a/spec/features/course/assessment/assessment_attempt_spec.rb b/spec/features/course/assessment/assessment_attempt_spec.rb
index 8d0dfb0e51b..60d0dcee01e 100644
--- a/spec/features/course/assessment/assessment_attempt_spec.rb
+++ b/spec/features/course/assessment/assessment_attempt_spec.rb
@@ -213,8 +213,7 @@
expect(submission.answers.map(&:reload).all?(&:evaluated?)).to be(true)
# This field should be filled when page loads
- correct_exp = (assessment.base_exp * submission.grade /
- assessment.questions.map(&:maximum_grade).sum).to_i
+ correct_exp = (assessment.base_exp * submission.grade / assessment.questions.map(&:maximum_grade).sum).to_i
expect(find_field('submission_draft_points_awarded').value).to eq(correct_exp.to_s)
submission_maximum_grade = 0
@@ -253,7 +252,7 @@
visit edit_course_assessment_submission_path(course, assessment, submission)
- click_button I18n.t('course.assessment.submission.submissions.buttons.unsubmit')
+ click_button 'Unsubmit Submission'
expect(submission.reload.attempting?).to be_truthy
expect(submission.points_awarded).to be_nil
expect(submission.reload.latest_answers.all?(&:attempting?)).to be_truthy
@@ -265,7 +264,7 @@
visit edit_course_assessment_submission_path(course, assessment, submission)
- click_button I18n.t('course.assessment.submission.submissions.buttons.unsubmit')
+ click_button 'Unsubmit Submission'
expect(submission.reload.attempting?).to be_truthy
expect(submission.points_awarded).to be_nil
expect(submission.latest_answers.all?(&:attempting?)).to be_truthy
diff --git a/spec/features/course/assessment/assessment_viewing_spec.rb b/spec/features/course/assessment/assessment_viewing_spec.rb
index 4b2c1be486c..22d42c06403 100644
--- a/spec/features/course/assessment/assessment_viewing_spec.rb
+++ b/spec/features/course/assessment/assessment_viewing_spec.rb
@@ -53,10 +53,7 @@
create(:submission, assessment: assessment, creator: student_user)
visit course_assessment_path(course, assessment)
- expect(page).to have_link(
- 'Attempt',
- href: course_assessment_path(course, assessment)
- )
+ expect(page).to have_link('Attempt', href: course_assessment_attempt_path(course, assessment))
end
end
diff --git a/spec/features/course/assessment/question/forum_post_response_management_spec.rb b/spec/features/course/assessment/question/forum_post_response_management_spec.rb
index 97434ca9f30..b8d72ccfd95 100644
--- a/spec/features/course/assessment/question/forum_post_response_management_spec.rb
+++ b/spec/features/course/assessment/question/forum_post_response_management_spec.rb
@@ -106,10 +106,10 @@
context 'As a Student' do
let(:user) { create(:course_student, course: course).user }
- scenario 'I cannot add questions', js: false do
+ scenario 'I cannot add questions' do
visit new_course_assessment_question_forum_post_response_path(course, assessment)
- expect(page.status_code).to eq(403)
+ expect_forbidden
end
end
end
diff --git a/spec/features/course/assessment/question/multiple_response_management_spec.rb b/spec/features/course/assessment/question/multiple_response_management_spec.rb
index 585db80e1ad..4e1e705a938 100644
--- a/spec/features/course/assessment/question/multiple_response_management_spec.rb
+++ b/spec/features/course/assessment/question/multiple_response_management_spec.rb
@@ -212,10 +212,10 @@
context 'As a Student' do
let(:user) { create(:course_student, course: course).user }
- scenario 'I cannot add questions', js: false do
+ scenario 'I cannot add questions' do
visit new_course_assessment_question_multiple_response_path(course, assessment)
- expect(page.status_code).to eq(403)
+ expect_forbidden
end
end
end
diff --git a/spec/features/course/assessment/question/programming_management_spec.rb b/spec/features/course/assessment/question/programming_management_spec.rb
index d398d5bae91..036ae690e3c 100644
--- a/spec/features/course/assessment/question/programming_management_spec.rb
+++ b/spec/features/course/assessment/question/programming_management_spec.rb
@@ -269,10 +269,10 @@
context 'As a Student' do
let(:user) { create(:course_student, course: course).user }
- scenario 'I cannot add questions', js: false do
+ scenario 'I cannot add questions' do
visit new_course_assessment_question_programming_path(course, assessment)
- expect(page.status_code).to eq(403)
+ expect_forbidden
end
end
end
diff --git a/spec/features/course/assessment/question/text_response_management_spec.rb b/spec/features/course/assessment/question/text_response_management_spec.rb
index 905044dbbdd..8808f6db3e3 100644
--- a/spec/features/course/assessment/question/text_response_management_spec.rb
+++ b/spec/features/course/assessment/question/text_response_management_spec.rb
@@ -163,10 +163,10 @@
context 'As a Student' do
let(:user) { create(:course_student, course: course).user }
- scenario 'I cannot add questions', js: false do
+ scenario 'I cannot add questions' do
visit new_course_assessment_question_text_response_path(course, assessment)
- expect(page.status_code).to eq(403)
+ expect_forbidden
end
end
end
diff --git a/spec/features/course/assessment/question/voice_response_management_spec.rb b/spec/features/course/assessment/question/voice_response_management_spec.rb
index 611e7f094b9..e46f7c68442 100644
--- a/spec/features/course/assessment/question/voice_response_management_spec.rb
+++ b/spec/features/course/assessment/question/voice_response_management_spec.rb
@@ -81,10 +81,10 @@
context 'As a Student' do
let(:user) { create(:course_student, course: course).user }
- scenario 'I cannot add questions', js: false do
+ scenario 'I cannot add questions' do
visit new_course_assessment_question_voice_response_path(course, assessment)
- expect(page.status_code).to eq(403)
+ expect_forbidden
end
end
end
diff --git a/spec/features/course/assessment/submission/log_spec.rb b/spec/features/course/assessment/submission/log_spec.rb
index 32cba111692..613de002975 100644
--- a/spec/features/course/assessment/submission/log_spec.rb
+++ b/spec/features/course/assessment/submission/log_spec.rb
@@ -44,19 +44,19 @@
# Logout and login again and visit the same submission
click_on 'OK'
- perform_logout_in_course CourseUser.for_user(user).first.name
-
+ logout
login_as(user)
- wait_for_page
expect do
visit edit_course_assessment_submission_path(course, protected_assessment, protected_submission)
+ wait_for_page
end.to change { protected_submission.logs.count }.by(1)
expect(protected_submission.logs.last.valid_attempt?).to be(false)
expect do
fill_in 'password', with: protected_assessment.session_password
click_button('Submit')
+ wait_for_page
end.to change { protected_submission.logs.count }.by(1)
wait_for_page
diff --git a/spec/features/course/assessment/submission/manually_graded_spec.rb b/spec/features/course/assessment/submission/manually_graded_spec.rb
index 77e053cd232..22083d5fdf1 100644
--- a/spec/features/course/assessment/submission/manually_graded_spec.rb
+++ b/spec/features/course/assessment/submission/manually_graded_spec.rb
@@ -173,7 +173,7 @@
# Refresh and check for the late submission warning.
visit edit_course_assessment_submission_path(course, assessment, submission)
- expect(page).to have_css('#late-submission', text: late_submission_text)
+ expect(page).to have_text(late_submission_text)
# Create an extra question after submission is submitted, user should still be able to
# grade the submission in this case.
diff --git a/spec/features/course/assessment/submission/password_protected_and_delayed_publishing_spec.rb b/spec/features/course/assessment/submission/password_protected_and_delayed_publishing_spec.rb
index 3578fecee49..a4cc5443d36 100644
--- a/spec/features/course/assessment/submission/password_protected_and_delayed_publishing_spec.rb
+++ b/spec/features/course/assessment/submission/password_protected_and_delayed_publishing_spec.rb
@@ -12,9 +12,7 @@
end
let(:mrq_questions) { assessment.reload.questions.map(&:specific) }
let(:student) { create(:course_student, course: course).user }
- let(:submission) do
- create(:submission, assessment: assessment, creator: student)
- end
+ let(:submission) { create(:submission, :attempting, assessment: assessment, creator: student) }
before { login_as(user, scope: :user) }
@@ -33,11 +31,11 @@
# The user should be redirect to submission edit page
wait_for_page
expect(current_path).to eq(edit_course_assessment_submission_path(course, assessment, last_submission))
+ click_button 'OK'
# Logout and login again and visit the same submission
logout
login_as(user)
- wait_for_page
visit edit_course_assessment_submission_path(course, assessment, last_submission)
diff --git a/spec/features/course/duplication_spec.rb b/spec/features/course/duplication_spec.rb
index 6d1462f75fd..9f28f8aa0e1 100644
--- a/spec/features/course/duplication_spec.rb
+++ b/spec/features/course/duplication_spec.rb
@@ -114,10 +114,10 @@
expect(find_sidebar).not_to have_text(I18n.t('layouts.duplication.title'))
end
- scenario 'I cannot access the duplication page', js: false do
+ scenario 'I cannot access the duplication page' do
visit course_duplication_path(course)
- expect(page.status_code).to eq(403)
+ expect_forbidden
end
end
end
diff --git a/spec/features/course/homepage_spec.rb b/spec/features/course/homepage_spec.rb
index cab4ca9b92a..e2336a01fd1 100644
--- a/spec/features/course/homepage_spec.rb
+++ b/spec/features/course/homepage_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'rails_helper'
-RSpec.feature 'Course: Homepage' do
+RSpec.feature 'Course: Homepage', js: true do
let(:instance) { Instance.default }
with_tenant(:instance) do
@@ -109,13 +109,13 @@
expect(course_user.reload.last_active_at).to be_within(1.hour).of(Time.zone.now)
end
- scenario 'I am able to see announcements in course homepage', js: true do
+ scenario 'I am able to see announcements in course homepage' do
valid_announcement = create(:course_announcement, course: course)
visit course_path(course)
expect(page).to have_selector("#announcement-#{valid_announcement.id}")
end
- scenario 'I am able to see the activity feed in course homepage', js: true do
+ scenario 'I am able to see the activity feed in course homepage' do
feed_notifications
visit course_path(course)
@@ -124,7 +124,7 @@
end
end
- scenario 'I am unable to see activities with deleted objects in my course homepage', js: true do
+ scenario 'I am unable to see activities with deleted objects in my course homepage' do
feed_notifications.each do |notification|
notification.activity.object.delete
end
@@ -135,7 +135,7 @@
end
end
- scenario 'I can view and ignore the relevant todos in my homepage', js: true do
+ scenario 'I can view and ignore the relevant todos in my homepage' do
assessment_todos
video_todo
survey_todo
@@ -158,7 +158,7 @@
end
find("#todo-ignore-button-#{assessment_todos[:in_progress].id}").click
- expect(page).to have_selector('div.Toastify__toast-body', text: 'Pending task successfully ignored')
+ expect_toastify 'Pending task successfully ignored'
# Reload page to load other todos
visit course_path(course)
@@ -174,13 +174,13 @@
context 'As a user not registered for the course' do
let(:user) { create(:user) }
- scenario 'I am not able to see announcements in course homepage', js: true do
+ scenario 'I am not able to see announcements in course homepage' do
valid_announcement = create(:course_announcement, course: course)
visit course_path(course)
expect(page).to_not have_selector("#announcement-#{valid_announcement.id}")
end
- scenario 'I am not able to see the activity feed in course homepage', js: true do
+ scenario 'I am not able to see the activity feed in course homepage' do
feed_notifications
visit course_path(course)
@@ -189,7 +189,7 @@
end
end
- scenario 'I am able to see owner and managers in instructors list', js: true do
+ scenario 'I am able to see owner and managers in instructors list' do
manager = create(:course_manager, course: course)
teaching_assistant = create(:course_teaching_assistant, course: course)
visit course_path(course)
@@ -200,7 +200,7 @@
expect(page).not_to have_selector("#instructor-#{teaching_assistant.user_id}")
end
- scenario 'I am able to see the course description', js: true do
+ scenario 'I am able to see the course description' do
visit course_path(course)
expect(page).to have_text('Description')
expect(page).to have_text(course.description)
diff --git a/spec/features/course/registration_spec.rb b/spec/features/course/registration_spec.rb
deleted file mode 100644
index 9772859290d..00000000000
--- a/spec/features/course/registration_spec.rb
+++ /dev/null
@@ -1,64 +0,0 @@
-# frozen_string_literal: true
-require 'rails_helper'
-
-RSpec.feature 'Courses: Registration' do
- let!(:instance) { Instance.default }
-
- with_tenant(:instance) do
- let(:course) { create(:course) }
- let(:user) { create(:user) }
- before { login_as(user, scope: :user) }
-
- context 'when the course has unconfirmed invitations' do
- let!(:invitation) { create(:course_user_invitation, course: course) }
-
- scenario 'Users can register course using invitation code', js: true do
- visit course_path(course)
-
- # No code input
- find('#register-button').click
- expect(page).
- to have_selector('div.Toastify__toast-body', text: 'Please enter an invitation code')
-
- # Enter wrong registration code
- fill_in 'registration-code', with: 'defintielyTheWrongCode'
- find('#register-button').click
- expect(page).
- to have_selector('div.Toastify__toast-body', text: 'Your code is incorrect')
-
- # Correct code
- fill_in 'registration-code', with: invitation.invitation_key
- find('#register-button').click
- end
- end
-
- context 'when the course allows enrol requests' do
- let(:course) { create(:course, :enrollable) }
-
- scenario 'Users can create and cancel enrol requests', js: true do
- visit course_path(course)
-
- expect(page).to have_text(course.description)
-
- expect(ActionMailer::Base.deliveries.count).to eq(0)
- find('#submit-enrol-request-button').click
- expect(page).to have_selector('div.Toastify__toast-body', text: 'Your enrol request has been submitted.')
- expect(ActionMailer::Base.deliveries.count).not_to eq(0)
-
- # Cancel request
- find('#cancel-enrol-request-button').click
- expect(page).to have_selector('div.Toastify__toast-body', text: 'Your enrol request has been cancelled.')
- end
-
- context 'when the user has been enrolled' do
- let!(:enrolled_student) { create(:course_student, course: course, user: user) }
-
- scenario 'user cannot de-register or re-register for the course', js: true do
- visit course_path(course)
- expect(page).not_to have_selector('#submit-enrol-request-button')
- expect(page).not_to have_selector('#cancel-enrol-request-button')
- end
- end
- end
- end
-end
diff --git a/spec/features/course/staff_management_spec.rb b/spec/features/course/staff_management_spec.rb
index 23ee632374f..4f9daf754c7 100644
--- a/spec/features/course/staff_management_spec.rb
+++ b/spec/features/course/staff_management_spec.rb
@@ -30,9 +30,10 @@
expect(find_sidebar).to have_text(I18n.t('layouts.course_users.title'))
end
- scenario 'I cannot access the staff list', js: false do
+ scenario 'I cannot access the staff list' do
visit course_users_staff_path(course)
- expect(page.status_code).to eq(403)
+
+ expect_forbidden
end
end
diff --git a/spec/features/course/staff_statistics_spec.rb b/spec/features/course/staff_statistics_spec.rb
index 826ee9964fc..02d8ced8b6a 100644
--- a/spec/features/course/staff_statistics_spec.rb
+++ b/spec/features/course/staff_statistics_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'rails_helper'
-RSpec.feature 'Course: Statistics: Staff' do
+RSpec.feature 'Course: Statistics: Staff', js: true do
subject { page }
let!(:instance) { Instance.default }
@@ -79,34 +79,32 @@
end
scenario 'I can view staff summary' do
- pending 'Migrated staff statistics to React-side'
- visit course_statistics_staff_path(course)
-
- expect(page).to have_selector('li', text: I18n.t('course.statistics.staff.header'))
-
- within find(content_tag_selector(tutor1)) do
- expect(page).to have_selector('td', text: '1') # S/N
- expect(page).to have_selector('td', text: tutor1.name)
- expect(page).to have_selector('td', text: tutor1_submissions.size)
- expect(page).to have_selector('td', text: "1 #{I18n.t('time.day')} 01:01:01")
+ visit course_statistics_path(course)
+ click_button 'Staff'
+
+ within find('tr', text: tutor1.name) do |row|
+ expect(row).to have_selector('td', text: '1') # S/N
+ expect(row).to have_selector('td', text: tutor1.name)
+ expect(row).to have_selector('td', text: tutor1_submissions.size)
+ expect(row).to have_selector('td', text: "1 #{I18n.t('time.day')} 01:01:01")
end
- within find(content_tag_selector(tutor2)) do
- expect(page).to have_selector('td', text: '2')
- expect(page).to have_selector('td', text: tutor2.name)
- expect(page).to have_selector('td', text: tutor2_submissions.size)
- expect(page).to have_selector('td', text: "2 #{I18n.t('time.day')} 00:00:00")
+ within find('tr', text: tutor2.name) do |row|
+ expect(row).to have_selector('td', text: '2')
+ expect(row).to have_selector('td', text: tutor2.name)
+ expect(row).to have_selector('td', text: tutor2_submissions.size)
+ expect(row).to have_selector('td', text: "2 #{I18n.t('time.day')} 00:00:00")
end
# Do not reflect staff submissions as part of staff statistics.
- within find(content_tag_selector(tutor3)) do
- expect(page).to have_selector('td', text: '3')
- expect(page).to have_selector('td', text: tutor3.name)
- expect(page).to have_selector('td', text: '1')
- expect(page).to have_selector('td', text: "3 #{I18n.t('time.day')} 00:00:00")
+ within find('tr', text: tutor3.name) do |row|
+ expect(row).to have_selector('td', text: '3')
+ expect(row).to have_selector('td', text: tutor3.name)
+ expect(row).to have_selector('td', text: '1')
+ expect(row).to have_selector('td', text: "3 #{I18n.t('time.day')} 00:00:00")
end
- expect(page).not_to have_content_tag_for(tutor4)
+ expect(page).not_to have_text(tutor4.name)
end
end
@@ -116,7 +114,7 @@
scenario 'I cannot see the sidebar item' do
visit course_path(course)
- expect(page).not_to have_selector('li', text: I18n.t('course.statistics.header'))
+ expect(find_sidebar).not_to have_text(I18n.t('course.statistics.header'))
end
end
end
diff --git a/spec/features/course/students_statistics_spec.rb b/spec/features/course/students_statistics_spec.rb
index 6cf2cd77089..164cbe4fe30 100644
--- a/spec/features/course/students_statistics_spec.rb
+++ b/spec/features/course/students_statistics_spec.rb
@@ -1,14 +1,14 @@
# frozen_string_literal: true
require 'rails_helper'
-RSpec.feature 'Course: Student Statistics' do
+RSpec.feature 'Course: Student Statistics', js: true do
subject { page }
let!(:instance) { Instance.default }
with_tenant(:instance) do
let(:course) { create(:course) }
- let(:students) { create_list(:course_student, 2, course: course) }
+ let!(:students) { create_list(:course_student, 2, course: course) }
before do
login_as(user, scope: :user)
@@ -27,42 +27,38 @@
create(:course_group_user, group: other_group, course_user: students.last)
end
- scenario 'I can only view all student statistics when I am not a group manager', js: true do
- pending 'Migrated students statistics to React-side'
- students
- visit course_statistics_all_students_path(course)
+ scenario 'I can only view all student statistics when I am not a group manager' do
+ visit course_statistics_path(course)
- expect(page).to have_link(I18n.t('course.statistics.student.header'),
- href: course_statistics_all_students_path(course))
-
- students.each do |student|
- expect(page).to have_content_tag_for(student)
- end
-
- expect(page).not_to have_text(I18n.t('course.statistics.tabs.my_students_tab'))
- expect(page).
- not_to have_text(I18n.t('course.statistics.course_student_statistics.phantom_students'))
+ students.each { |student| expect(page).to have_text(student.name) }
+ expect(page).not_to have_text('Show My Students Only')
+ expect(page).not_to have_text('Phantom')
# Test that phantom students are rendered only if they exist
phantom_student = students.first
phantom_student.phantom = true
phantom_student.save
- visit course_statistics_all_students_path(course)
- students.each do |student|
- expect(page).to have_content_tag_for(student)
- end
- expect(page).
- to have_text(I18n.t('course.statistics.course_student_statistics.phantom_students'))
+
+ visit course_statistics_path(course)
+
+ students.each { |student| expect(page).to have_text(student.name) }
+ expect(page).to have_text('Phantom')
end
end
context 'As a Course Student' do
let(:user) { create(:course_student, course: course).user }
- scenario 'I cannot see the sidebar item' do
+ scenario 'I cannot see the statistics sidebar item' do
visit course_path(course)
- expect(page).not_to have_selector('li', text: I18n.t('course.statistics.header'))
+ expect(find_sidebar).not_to have_text(I18n.t('course.statistics.header'))
+ end
+
+ scenario 'I cannot access the statistics page' do
+ visit course_statistics_path(course)
+
+ expect_forbidden
end
end
end
diff --git a/spec/features/course_management_spec.rb b/spec/features/course_management_spec.rb
index ca8e3fc90ea..2a39a3c4be8 100644
--- a/spec/features/course_management_spec.rb
+++ b/spec/features/course_management_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'rails_helper'
-RSpec.feature 'Courses' do
+RSpec.feature 'Courses', js: true do
subject { page }
let(:instance) { create(:instance) }
@@ -9,7 +9,7 @@
let(:user) { create(:instance_user, :instructor).user }
before { login_as(user, scope: :user) }
- scenario 'Users can see a list of published courses', js: true do
+ scenario 'Users can see a list of published courses' do
unpublished_course = create(:course)
published_course = create(:course, :published)
@@ -30,7 +30,7 @@
expect(page).not_to have_link(other_course.title, href: course_path(other_course))
end
- scenario 'Users can create a new course', js: true do
+ scenario 'Users can create a new course' do
visit courses_path
find('button.new-course-button').click
diff --git a/spec/features/instance_user_role_requests_management_spec.rb b/spec/features/instance_user_role_requests_management_spec.rb
index c87f20671fa..e667b210f21 100644
--- a/spec/features/instance_user_role_requests_management_spec.rb
+++ b/spec/features/instance_user_role_requests_management_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'rails_helper'
-RSpec.feature 'Instance::UserRoleRequests' do
+RSpec.feature 'Instance::UserRoleRequests', js: true do
subject { page }
let(:instance) { create(:instance) }
@@ -10,7 +10,7 @@
before { login_as(user, scope: :user) }
context 'As a normal instance user' do
- scenario 'I can create a new role request', type: :mailer, js: true do
+ scenario 'I can create a new role request', type: :mailer do
visit courses_path
find('#role-request-button').click
@@ -33,7 +33,7 @@
expect(request_created.reason).to eq(request.reason)
end
- scenario 'I can edit my existing role request', js: true do
+ scenario 'I can edit my existing role request' do
request = create(:role_request, user: user, instance: instance)
visit courses_path
find('#role-request-button').click
@@ -46,7 +46,7 @@
end
end
- context 'As an instance admin', js: true do
+ context 'As an instance admin' do
let(:user) { create(:instance_administrator).user }
let!(:requests) { create_list(:role_request, 2, instance: instance) }
diff --git a/spec/features/system/admin/instance/instance_announcement_management_spec.rb b/spec/features/system/admin/instance/instance_announcement_management_spec.rb
index 2a8e4f01e66..294d8023763 100644
--- a/spec/features/system/admin/instance/instance_announcement_management_spec.rb
+++ b/spec/features/system/admin/instance/instance_announcement_management_spec.rb
@@ -7,13 +7,10 @@
let!(:instance) { create(:instance) }
with_tenant(:instance) do
- before do
- login_as(user, scope: :user)
- end
+ let(:user) { create(:instance_administrator, instance: instance).user }
+ before { login_as(user, scope: :user) }
context 'As an Instance Administrator' do
- let(:user) { create(:instance_administrator).user }
-
scenario 'I can create instance announcements' do
visit admin_instance_announcements_path
diff --git a/spec/features/system/admin/masquerades_spec.rb b/spec/features/system/admin/masquerades_spec.rb
index ec84113692d..bd240303ee3 100644
--- a/spec/features/system/admin/masquerades_spec.rb
+++ b/spec/features/system/admin/masquerades_spec.rb
@@ -17,8 +17,11 @@
scenario 'I can masquerade a user' do
visit admin_users_path
+
find(".user-masquerade-#{user_to_masquerade.id}").click
- expect(page).to have_selector('li', text: user_to_masquerade.name)
+ wait_for_page
+
+ expect(page).to have_text("Masquerading as #{user_to_masquerade.name}")
end
end
@@ -28,8 +31,7 @@
scenario 'I cannot masquerade a user' do
visit masquerade_path(user_to_masquerade)
- expect(page).not_to have_selector('li', text: user_to_masquerade.name)
- expect(page).to have_selector('div', text: 'pages.403.header')
+ expect_forbidden
end
end
@@ -39,8 +41,7 @@
scenario 'I cannot masquerade a user' do
visit masquerade_path(user_to_masquerade)
- expect(page).not_to have_selector('li', text: user_to_masquerade.name)
- expect(page).to have_selector('div', text: 'pages.403.header')
+ expect_forbidden
end
end
end
diff --git a/spec/features/user/email_management_spec.rb b/spec/features/user/email_management_spec.rb
index c286de4e14a..a7bed04dddf 100644
--- a/spec/features/user/email_management_spec.rb
+++ b/spec/features/user/email_management_spec.rb
@@ -64,11 +64,12 @@
click_button 'Add email address'
fill_in 'newEmail', with: invitation1.email
click_button 'Add email address'
+ wait_for_page
expect(page).to have_selector('section', text: invitation1.email)
end.to change { user.emails.count }.by(1)
expect do
- confirm_registartion_token_via_email
+ confirm_registration_token_via_email
end.to change(course1.users, :count).by(1).
and change(course2.users, :count).by(1)
end
@@ -89,7 +90,7 @@
end.to change(user.emails, :count).by(1).
and change(other_user.emails, :count).by(-1)
- confirm_registartion_token_via_email
+ confirm_registration_token_via_email
expect(user.emails.last.reload.confirmed_at).not_to be_nil
end
end
diff --git a/spec/features/user/password_management_spec.rb b/spec/features/user/password_management_spec.rb
deleted file mode 100644
index 9e613af50b5..00000000000
--- a/spec/features/user/password_management_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-require 'rails_helper'
-
-RSpec.feature 'Users: Change password' do
- let(:instance) { Instance.default }
- with_tenant(:instance) do
- let!(:user) { create(:user) }
- before { login_as(user, scope: :user) }
-
- context 'As a User' do
- scenario 'I am able to change my password' do
- visit edit_user_profile_path
- end
- end
- end
-end
diff --git a/spec/features/user/profile_edit_spec.rb b/spec/features/user/profile_edit_spec.rb
index 29952f362ed..abdad4cb647 100644
--- a/spec/features/user/profile_edit_spec.rb
+++ b/spec/features/user/profile_edit_spec.rb
@@ -17,7 +17,7 @@
time_zone = 'Singapore'
fill_in 'name', with: new_name
- select time_zone, from: 'timezone'
+ select time_zone, from: 'timeZone'
click_button 'Save changes'
wait_for_page
diff --git a/spec/features/user_sign_in_spec.rb b/spec/features/user_sign_in_spec.rb
deleted file mode 100644
index 6a6af255650..00000000000
--- a/spec/features/user_sign_in_spec.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-require 'rails_helper'
-
-RSpec.feature 'Users: Sign In' do
- let(:instance) { Instance.default }
- let(:other_instance) { create(:instance) }
- let(:password) { '12345678' }
-
- with_tenant(:instance) do
- context 'As a user from another instance' do
- let(:user) do
- user = nil
- ActsAsTenant.with_tenant(other_instance) do
- user = create(:user, password: password)
- end
- user
- end
-
- scenario 'I can sign in to current instance' do
- visit new_user_session_path
- fill_in 'user_email', with: user.email
- fill_in 'user_password', with: user.password
-
- expect do
- click_button I18n.t('user.sessions.new.sign_in')
- end.to change { instance.instance_users.exists?(user: user) }.from(false).to(true)
- end
-
- context 'As a system administrator' do
- let(:user) do
- user = nil
- ActsAsTenant.with_tenant(other_instance) do
- user = create(:administrator, password: password)
- end
- user
- end
-
- scenario 'I can sign in to current instance' do
- visit new_user_session_path
- fill_in 'user_email', with: user.email
- fill_in 'user_password', with: password
-
- click_button I18n.t('user.sessions.new.sign_in')
- expect(page).to have_selector('div.alert', text: I18n.t('user.signed_in'))
- expect(instance.instance_users.exists?(user: user)).to be_falsy
- end
- end
- end
- end
-end
diff --git a/spec/features/user_sign_up_spec.rb b/spec/features/user_sign_up_spec.rb
deleted file mode 100644
index 010c3273123..00000000000
--- a/spec/features/user_sign_up_spec.rb
+++ /dev/null
@@ -1,141 +0,0 @@
-# frozen_string_literal: true
-require 'rails_helper'
-
-RSpec.feature 'Users: Sign Up' do
- let(:instance) { Instance.default }
- with_tenant(:instance) do
- context 'As an unregistered user' do
- scenario 'I can register for an account' do
- visit new_user_registration_path
-
- expect do
- click_button I18n.t('user.registrations.new.sign_up')
- end.not_to change(User, :count)
- expect(page).to have_selector('div.has-error')
-
- valid_user = attributes_for(:user).reverse_merge(email: generate(:email))
- fill_in 'user_name', with: valid_user[:name]
- fill_in 'user_email', with: valid_user[:email]
- fill_in 'user_password', with: valid_user[:password]
- fill_in 'user_password_confirmation', with: valid_user[:password]
-
- expect do
- click_button I18n.t('user.registrations.new.sign_up')
- end.to change(User, :count).by(1)
- user = User::Email.find_by!(email: valid_user[:email]).user_id
- expect(instance.users.exists?(user)).to be_truthy
- end
- end
-
- context 'As a user invited by course staffs' do
- let(:course) { create(:course) }
- let(:invitation) { create(:course_user_invitation, :phantom, course: course) }
- let(:invited_email) { invitation.email }
-
- scenario 'I can register for an account' do
- visit new_user_registration_path(invitation: invitation.invitation_key)
-
- invited_user = attributes_for(:user)
- fill_in 'user_password', with: invited_user[:password]
- fill_in 'user_password_confirmation', with: invited_user[:password]
-
- expect do
- click_button I18n.t('user.registrations.new.sign_up')
- end.to change(course.users, :count).by(1)
-
- email = User::Email.find_by(email: invited_email)
- user = email.user
- course_user = CourseUser.where(user: user, course: course).first
- expect(email).to be_primary
- expect(email).to be_confirmed
- expect(invitation.reload).to be_confirmed
- expect(invitation.confirmer).to eq(email.user)
- expect(course_user).to be_phantom
- end
-
- context 'when the invitation code is confirmed' do
- let(:invitation) { create(:course_user_invitation, :confirmed, course: course) }
-
- scenario 'I am redirected with an error' do
- visit new_user_registration_path(invitation: invitation.invitation_key)
-
- expect(current_path).to eq(root_path)
- expect(page).to have_selector('div.alert', text: I18n.t('user.registrations.new.used_with_email'))
- end
- end
- end
-
- context 'As a user invited by course staffs to multiple courses' do
- let(:course1) { create(:course) }
- let(:course2) { create(:course) }
- let!(:invitation1) { create(:course_user_invitation, :phantom, name: 'course1_user', course: course1) }
- let!(:invitation2) do
- create(:course_user_invitation, name: 'course2_user', email: invitation1.email, course: course2)
- end
- let(:invited_email) { invitation1.email }
-
- scenario 'I can register for an account via the registration links' do
- visit new_user_registration_path(invitation: invitation1.invitation_key)
-
- invited_user = attributes_for(:user)
- fill_in 'user_password', with: invited_user[:password]
- fill_in 'user_password_confirmation', with: invited_user[:password]
-
- expect do
- click_button I18n.t('user.registrations.new.sign_up')
- end.to change(course1.users, :count).by(1).
- and change(course2.users, :count).by(1)
-
- email = User::Email.find_by(email: invited_email)
- user = email.user
- first_course_user = CourseUser.where(user: user, course: course1).first
- second_course_user = CourseUser.where(user: user, course: course2).first
-
- expect(email).to be_primary
- expect(email).to be_confirmed
- expect(invitation1.reload).to be_confirmed
- expect(invitation1.confirmer).to eq(email.user)
- expect(first_course_user.name).to eq(invitation1.name)
- expect(first_course_user).to be_phantom
-
- expect(invitation2.reload).to be_confirmed
- expect(invitation2.confirmer).to eq(email.user)
- expect(second_course_user.name).to eq(invitation2.name)
- expect(second_course_user).not_to be_phantom
- end
-
- scenario 'I can register for an account without using the registration link', type: :notifier do
- visit new_user_registration_path
-
- valid_user = attributes_for(:user).reverse_merge(email: invitation1.email)
- fill_in 'user_name', with: valid_user[:name]
- fill_in 'user_email', with: valid_user[:email]
- fill_in 'user_password', with: valid_user[:password]
- fill_in 'user_password_confirmation', with: valid_user[:password]
-
- expect do
- click_button I18n.t('user.registrations.new.sign_up')
- confirm_registartion_token_via_email
- end.to change(course1.users, :count).by(1).
- and change(course2.users, :count).by(1)
-
- email = User::Email.find_by(email: invited_email)
- user = email.user
- first_course_user = CourseUser.where(user: user, course: course1).first
- second_course_user = CourseUser.where(user: user, course: course2).first
-
- expect(email).to be_primary
- expect(email).to be_confirmed
- expect(invitation1.reload).to be_confirmed
- expect(invitation1.confirmer).to eq(email.user)
- expect(first_course_user.name).to eq(invitation1.name)
- expect(first_course_user).to be_phantom
-
- expect(invitation2.reload).to be_confirmed
- expect(invitation2.confirmer).to eq(email.user)
- expect(second_course_user.name).to eq(invitation2.name)
- expect(second_course_user).not_to be_phantom
- end
- end
- end
-end
diff --git a/spec/fixtures/helpers/application_theming_helper/layouts/page_class.html.erb b/spec/fixtures/helpers/application_theming_helper/layouts/page_class.html.erb
deleted file mode 100644
index 55b1b37ef69..00000000000
--- a/spec/fixtures/helpers/application_theming_helper/layouts/page_class.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-
- <%= yield %>
-
diff --git a/spec/fixtures/helpers/application_theming_helper/layouts/page_class_nested.html.erb b/spec/fixtures/helpers/application_theming_helper/layouts/page_class_nested.html.erb
deleted file mode 100644
index 663e8388efb..00000000000
--- a/spec/fixtures/helpers/application_theming_helper/layouts/page_class_nested.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-
- <%= yield %>
-
diff --git a/spec/fixtures/helpers/application_theming_helper/page_class_nested_inside.html.erb b/spec/fixtures/helpers/application_theming_helper/page_class_nested_inside.html.erb
deleted file mode 100644
index 9853cd021f5..00000000000
--- a/spec/fixtures/helpers/application_theming_helper/page_class_nested_inside.html.erb
+++ /dev/null
@@ -1,4 +0,0 @@
-<%= render within_layout: 'page_class_nested' do %>
-
-
-<% end %>
diff --git a/spec/helpers/application_cocoon_helper_spec.rb b/spec/helpers/application_cocoon_helper_spec.rb
deleted file mode 100644
index 011036766d6..00000000000
--- a/spec/helpers/application_cocoon_helper_spec.rb
+++ /dev/null
@@ -1,55 +0,0 @@
-# frozen_string_literal: true
-require 'rails_helper'
-
-RSpec.describe ApplicationCocoonHelper, type: :helper do
- describe '#link_to_add_association' do
- let(:course_group) { Course::Group.new }
- let(:form_object) { double(object: course_group, object_name: course_group.class.name) }
- let(:html_options) do
- {
- find_using: 'next',
- selector: 'tbody',
- insert_using: 'append'
- }
- end
-
- let(:expected_options) do
- {
- 'data-association-insertion-traversal' => 'next',
- 'data-association-insertion-node' => 'tbody',
- 'data-association-insertion-method' => 'append'
- }
- end
-
- before do
- allow(view).to receive(:render_association).and_return('form')
- end
-
- context 'when block it not given' do
- let(:args) { ['Add User', form_object, :group_users] }
- subject { view.link_to_add_association(*args, html_options) }
-
- it 'generates the correct options and the name' do
- expect(subject).to have_tag('a', with: expected_options) do
- with_text 'Add User'
- end
- end
- end
-
- context 'when a block is given' do
- let(:block_args) { [form_object, :group_users] }
-
- subject do
- view.link_to_add_association(*block_args, html_options) do
- 'New Name'
- end
- end
-
- it 'generates the correct options and the name' do
- expect(subject).to have_tag('a', with: expected_options) do
- with_text 'New Name'
- end
- end
- end
- end
-end
diff --git a/spec/helpers/application_formatters_helper_spec.rb b/spec/helpers/application_formatters_helper_spec.rb
index 3dcc48981ff..bcaa3069421 100644
--- a/spec/helpers/application_formatters_helper_spec.rb
+++ b/spec/helpers/application_formatters_helper_spec.rb
@@ -260,50 +260,6 @@ def hello:
result.define_singleton_method(:ended?) { Time.zone.now > end_at }
end
end
-
- describe '#time_period_class' do
- subject { helper.time_period_class(stub) }
-
- context 'when the object is not started' do
- let(:start_at) { Time.zone.now + 1.day }
- let(:end_at) { Time.zone.now + 2.days }
- it { is_expected.to eq(['not-started']) }
- end
-
- context 'when the object is currently active' do
- let(:start_at) { Time.zone.now - 1.day }
- let(:end_at) { Time.zone.now + 1.day }
- it { is_expected.to eq(['currently-active']) }
- end
-
- context 'when the object is ended' do
- let(:start_at) { Time.zone.now - 1.week }
- let(:end_at) { Time.zone.now - 1.day }
- it { is_expected.to eq(['ended']) }
- end
- end
-
- describe '#time_period_message' do
- subject { helper.time_period_message(stub) }
-
- context 'when the object is not started' do
- let(:start_at) { Time.zone.now + 1.day }
- let(:end_at) { Time.zone.now + 2.days }
- it { is_expected.to eq(I18n.t('common.not_started')) }
- end
-
- context 'when the object is currently active' do
- let(:start_at) { Time.zone.now - 1.day }
- let(:end_at) { Time.zone.now + 1.day }
- it { is_expected.to be_nil }
- end
-
- context 'when the object is ended' do
- let(:start_at) { Time.zone.now - 1.week }
- let(:end_at) { Time.zone.now - 1.day }
- it { is_expected.to eq(I18n.t('common.ended')) }
- end
- end
end
describe 'draft helper' do
@@ -314,32 +270,4 @@ def hello:
end
end
end
-
- describe 'unread helper' do
- let(:stub) do
- double.tap do |result|
- me = self
- result.define_singleton_method(:unread?) { |_| !me.read_status }
- end
- end
-
- describe '#unread_class' do
- subject { helper.unread_class(stub) }
- before { controller.define_singleton_method(:current_user) { nil } }
-
- context 'when the user has not read the item' do
- let(:read_status) { false }
- it 'returns ["unread"]' do
- expect(subject).to eq(['unread'])
- end
- end
-
- context 'when the user has read the item' do
- let(:read_status) { true }
- it 'returns an empty array' do
- expect(subject).to eq([])
- end
- end
- end
- end
end
diff --git a/spec/helpers/application_theming_helper_spec.rb b/spec/helpers/application_theming_helper_spec.rb
deleted file mode 100644
index 5b9549c9b0f..00000000000
--- a/spec/helpers/application_theming_helper_spec.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-require 'rails_helper'
-
-RSpec.describe ApplicationThemingHelper, type: :helper do
- module ApplicationThemingHelper
- include RenderWithinLayoutHelper
- end
-
- describe '#page_class' do
- subject { helper.page_class }
-
- context 'when it has never been called before' do
- it 'returns the page class' do
- expect(subject).not_to be_blank
- end
- end
-
- context 'when it has been called before' do
- before { helper.page_class }
- it { is_expected.to be_blank }
- end
-
- describe 'nested layouts' do
- let(:views_directory) do
- path = Pathname.new("#{__dir__}/../fixtures/helpers/application_theming_helper")
- path.realpath
- end
-
- before { controller.prepend_view_path(views_directory) }
- subject { render template: 'page_class_nested_inside', layout: 'layouts/page_class' }
-
- it 'does not label the root container with the page class' do
- expect(subject).not_to have_tag('div.action-view-test-case-test#root')
- end
-
- it 'does not label the nested layout container with the page class' do
- expect(subject).not_to have_tag('div.action-view-test-case-test#nested')
- end
-
- it 'labels the deepest-nested container with the page class' do
- expect(subject).to have_tag('div.action-view-test-case-test#nested-inside')
- end
- end
- end
-end
diff --git a/spec/helpers/application_widgets_helper_spec.rb b/spec/helpers/application_widgets_helper_spec.rb
deleted file mode 100644
index aa26d370257..00000000000
--- a/spec/helpers/application_widgets_helper_spec.rb
+++ /dev/null
@@ -1,180 +0,0 @@
-# frozen_string_literal: true
-require 'rails_helper'
-
-RSpec.describe ApplicationWidgetsHelper, type: :helper do
- def stub_resource_button
- helper.define_singleton_method(:resource_button) do |_, _, _, url_options, _|
- url_options
- end
- end
-
- let(:instance) { Instance.default }
- with_tenant(:instance) do
- describe '#delete_button' do
- let(:announcement) { create(:course_announcement) }
- subject { helper.delete_button([announcement.course, announcement]) }
- it 'defaults to a btn-primary class' do
- expect(subject).to have_tag('a.btn.btn-danger')
- end
- it 'defaults to a file button' do
- expect(subject).to have_tag('i.fa-trash')
- end
- it 'sets the method as delete' do
- expect(subject).to have_tag('a', with: { 'data-method' => 'delete' })
- end
- end
-
- describe '#resource_button' do
- let(:announcement) { create(:course_announcement) }
- before { I18n.backend.store_translations(:en, en: { helpers: { buttons: { new: 'new' } } }) }
- after { I18n.backend.store_translations(:en, en: { helpers: { buttons: { new: nil } } }) }
-
- let(:body) { 'meh' }
- subject do
- helper.send(:resource_button, :new, 'btn-warning', body,
- [announcement.course, announcement], nil)
- end
-
- it 'uses the key to determine the translation' do
- expect(subject).to have_tag('a.btn.btn-warning', text: body)
- end
-
- it 'adds the key to the button classes' do
- expect(subject).to have_tag('a.btn.new', text: body)
- end
-
- context 'when a block is provided to the body argument' do
- let(:text) { 'block!' }
- let(:body) { proc { text } }
- it 'calls the block to provide the body of the link' do
- expect(subject).to have_tag('a.btn.btn-warning', text: text)
- end
- end
-
- context 'when a resource is provided to the url_options argument' do
- it 'gives the title to the link' do
- title = 'helpers.buttons.announcement.new'
- expect(subject).to have_tag('a', with: { title: title })
- end
- end
- end
-
- describe '#deduce_resource_button_class' do
- let(:specified_classes) { [] }
- let(:default_class) { 'ignore' }
- let(:key) { :new }
- subject { helper.send(:deduce_resource_button_class, key, specified_classes, default_class) }
-
- it 'adds the btn class' do
- expect(subject).to include('btn')
- end
-
- it 'adds the key' do
- expect(subject).to include(key)
- end
-
- context 'when a button type is specified' do
- let(:specified_classes) { ['btn-default'] }
- it 'does not add the specified default class' do
- expect(subject).not_to include(default_class)
- end
- end
-
- context 'when no button type is specified' do
- it 'adds the specified default class' do
- expect(subject).to include('ignore')
- end
- end
- end
-
- describe '#deduce_resource_button_title' do
- before { helper.define_singleton_method(:t) { |key, hash| [key] + hash[:default] } }
- let(:announcement) { build(:course_announcement) }
- subject { helper.send(:deduce_resource_button_title, :edit, url_options) }
-
- context 'when given an array of resources' do
- let(:url_options) { [announcement] }
- it 'picks the last resource' do
- expect(subject).to contain_exactly(
- :'helpers.buttons.announcement.edit',
- :'helpers.buttons.edit',
- 'Edit Announcement'
- )
- end
-
- context 'when given an array with an options hash' do
- let(:url_options) { [announcement, test: 'something'] }
- it 'picks the last resource' do
- expect(subject).to contain_exactly(
- :'helpers.buttons.announcement.edit',
- :'helpers.buttons.edit',
- 'Edit Announcement'
- )
- end
- end
- end
-
- context 'when given a single resource' do
- let(:url_options) { announcement }
- it 'looks up the model name' do
- expect(subject).to contain_exactly(
- :'helpers.buttons.announcement.edit',
- :'helpers.buttons.edit',
- 'Edit Announcement'
- )
- end
- end
-
- context 'when given a symbol' do
- let(:url_options) { :announcement }
- it 'guesses the human name of the symbol' do
- expect(subject).to contain_exactly(
- :'helpers.buttons.announcement.edit',
- :'helpers.buttons.edit',
- 'Edit Announcement'
- )
- end
- end
- end
-
- describe '#display_progress_bar' do
- let(:default_class) { 'progress-bar-info' }
- subject { helper.display_progress_bar(50) }
-
- it 'returns a progress bar' do
- expect(subject).to have_tag('div.progress-bar', with: { role: 'progressbar' })
- end
-
- it 'specifies the correct percentage of the progress bar' do
- expect(subject).to have_tag('div.progress-bar', style: 'width: 50%')
- end
-
- it 'defaults to .progress-bar-info' do
- expect(subject).to include(default_class)
- end
-
- context 'when opts are specified' do
- let(:tooltip_title) { 'Foo' }
- let(:opts) { { class: ['progress-bar-striped'], title: tooltip_title } }
- subject { helper.display_progress_bar(50, opts) }
-
- it 'is reflected in the progress bar' do
- expect(subject).to have_tag('div.progress-bar.progress-bar-striped')
- expect(subject).to have_tag('div.progress-bar', title: tooltip_title)
- end
- end
-
- context 'when a block is given' do
- it 'appends the text within the progress bar' do
- expect(helper.display_progress_bar(50) { '30%' }).to include('30%')
- end
-
- it 'renders the block in the context of the helper' do
- message = 'foo'
- helper.define_singleton_method(:some_method) { message }
- expect(helper.display_progress_bar(50) { helper.some_method }).to include(message)
- end
- end
- end
- end
-end
diff --git a/spec/helpers/course/achievement/controller_helper_spec.rb b/spec/helpers/course/achievement/controller_helper_spec.rb
index 3ad3b5b0770..485a7eff034 100644
--- a/spec/helpers/course/achievement/controller_helper_spec.rb
+++ b/spec/helpers/course/achievement/controller_helper_spec.rb
@@ -39,15 +39,6 @@
end
end
- describe '#display_achievement_badge' do
- subject { helper.display_achievement_badge(achievement) }
-
- it 'displays the default achievement badge' do
- expect(subject).to have_tag('span.image')
- expect(subject).to have_tag('img')
- end
- end
-
describe '#achievement_status_class' do
subject { helper.achievement_status_class(achievement, course_user) }
diff --git a/spec/helpers/course/assessment/submission/submissions_helper_spec.rb b/spec/helpers/course/assessment/submission/submissions_helper_spec.rb
deleted file mode 100644
index 760c761279b..00000000000
--- a/spec/helpers/course/assessment/submission/submissions_helper_spec.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-# frozen_string_literal: true
-require 'rails_helper'
-
-RSpec.describe Course::Assessment::Submission::SubmissionsHelper do
- let(:instance) { Instance.default }
-
- with_tenant(:instance) do
- describe '#max_step' do
- let(:course) { create(:course) }
- let(:student_user) { create(:course_student, course: course).user }
- let(:assessment) { build(:assessment, :autograded, :published_with_mcq_question) }
- let(:submission) { create(:submission, assessment: assessment, creator: student_user) }
- before do
- helper.instance_variable_set(:@assessment, assessment)
- helper.instance_variable_set(:@submission, submission)
- end
- subject { helper.max_step }
-
- context 'when all questions have been answered' do
- before { allow(helper).to receive(:next_unanswered_question).and_return(nil) }
-
- it { is_expected.to eq(assessment.questions.length) }
- end
- end
-
- describe '#nav_class' do
- subject { helper.nav_class(step) }
- before do
- allow(helper).to receive(:max_step).and_return(5)
- allow(helper).to receive(:current_step).and_return(3)
- end
-
- context 'when step is greater than max_step' do
- let(:step) { 6 }
- it { is_expected.to eq('disabled') }
- end
-
- context 'when step is current_step' do
- let(:step) { 3 }
- it { is_expected.to eq('active') }
- end
-
- context 'when step less than current_step' do
- let(:step) { 2 }
- it { is_expected.to eq('completed') }
- end
- end
- end
-end
diff --git a/spec/helpers/course/lesson_plan/todos_helper_spec.rb b/spec/helpers/course/lesson_plan/todos_helper_spec.rb
deleted file mode 100644
index d28db6bf803..00000000000
--- a/spec/helpers/course/lesson_plan/todos_helper_spec.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-require 'rails_helper'
-
-RSpec.describe Course::LessonPlan::TodosHelper do
- let!(:instance) { Instance.default }
- with_tenant(:instance) do
- describe '#todo_status_class' do
- let(:todo) do
- item = create(:course_lesson_plan_item, start_at: end_at - 1.day, end_at: end_at)
- create(:course_lesson_plan_todo, item: item)
- end
- subject { helper.todo_status_class(todo.item) }
-
- context 'when end_at has not passed' do
- let(:end_at) { 2.days.from_now }
- it { is_expected.to eq([]) }
- end
-
- context 'when end_at has passed' do
- let(:end_at) { 2.days.ago }
- it { is_expected.to contain_exactly('danger') }
- end
- end
- end
-end
diff --git a/spec/libraries/form_for_resource_spec.rb b/spec/libraries/form_for_resource_spec.rb
deleted file mode 100644
index c99c208892a..00000000000
--- a/spec/libraries/form_for_resource_spec.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-require 'rails_helper'
-
-RSpec.describe 'Extension: form_for with resource', type: :view do
- let(:instance) { Instance.default }
- with_tenant(:instance) do
- it 'does not allow :url to be used with :resource' do
- expect do
- form_for(build(:course), resource: :course, url: courses_path) {}
- end.to raise_error(ArgumentError)
- end
-
- it 'requires :resource to be a symbol' do
- expect do
- form_for(build(:course), resource: 'course') {}
- end.to raise_error(ArgumentError)
- end
-
- it 'automatically adds the `path` suffix for route helpers' do
- expect(form_for(build(:course), resource: :course) {}).to have_form(courses_path, :post)
- end
-
- context 'when the resource is new' do
- subject { form_for(build(:course), resource: :course_path) {} }
- it 'generates the plural route' do
- expect(subject).to have_form(courses_path, :post)
- end
- end
-
- context 'when the resource is persisted' do
- let(:course) { create(:course) }
- subject { form_for(course, resource: :course_path) {} }
- it 'generates the singular route' do
- expect(subject).to have_form(course_path(course), :post)
- end
- end
- end
-end
diff --git a/spec/libraries/has_one_many_attachments_spec.rb b/spec/libraries/has_one_many_attachments_spec.rb
index 20afc19277f..95f49bdd93d 100644
--- a/spec/libraries/has_one_many_attachments_spec.rb
+++ b/spec/libraries/has_one_many_attachments_spec.rb
@@ -274,49 +274,4 @@ class self::SampleController < ActionController::Base; end
end
end
end
-
- describe 'form_builder helper' do
- class self::SampleView < ActionView::Base
- include ApplicationFormattersHelper
- include Rails.application.routes.url_helpers
- end
-
- class self::SampleFormBuilder < ActionView::Helpers::FormBuilder; end
-
- let(:attachment) { create(:attachment_reference) }
- let(:template) { self.class::SampleView.new(ActionView::LookupContext.new(Rails.root.join('app', 'views'))) }
- let(:resource) do
- stub = self.class::SampleModelMultiple.new
- allow(stub).to receive(:attachments).and_return([attachment])
- stub
- end
- let(:form_builder) { self.class::SampleFormBuilder.new(:sample, resource, template, {}) }
- subject { form_builder }
-
- it { is_expected.to respond_to(:attachments) }
-
- describe '#attachments' do
- before { I18n.locale = I18n.default_locale }
- subject { form_builder.attachments }
-
- context 'when has many attachments' do
- it do
- is_expected.
- to have_tag('strong', text: I18n.t('layouts.attachment_uploader.uploaded_files'))
- end
- end
-
- context 'when has one attachment' do
- let(:resource) do
- model = self.class::SampleModelSingular.new
- model.attachment = attachment
- model
- end
- it do
- is_expected.
- to have_tag('strong', text: I18n.t('layouts.attachment_uploader.uploaded_file'))
- end
- end
- end
- end
end
diff --git a/spec/libraries/high_voltage_pages_spec.rb b/spec/libraries/high_voltage_pages_spec.rb
deleted file mode 100644
index 60fa9c120bc..00000000000
--- a/spec/libraries/high_voltage_pages_spec.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-require 'rails_helper'
-
-RSpec.describe 'Extension: High Voltage Page Action Class', type: :controller do
- controller(HighVoltage::PagesController) do
- def action_has_layout?
- false
- end
- end
-
- it 'gets the correct action class' do
- get :show, params: { id: 'home' }
- expect(controller.view_context.page_action_class).to eq('home')
- end
-end
diff --git a/spec/libraries/inherited_nested_layouts_spec.rb b/spec/libraries/inherited_nested_layouts_spec.rb
deleted file mode 100644
index c1cb3866e00..00000000000
--- a/spec/libraries/inherited_nested_layouts_spec.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-# frozen_string_literal: true
-require 'rails_helper'
-
-RSpec.describe 'Extension: Inherited Nested Layouts', type: :controller do
- class self::ControllerA < ApplicationController
- prepend_view_path File.join(__dir__, '../fixtures/libraries/inherited_nested_layouts')
- layout 'testA'
-
- protected
-
- def publicly_accessible?
- true
- end
- end
-
- class self::ControllerB < self::ControllerA
- layout :controller_b_layout
-
- def controller_b_layout
- 'testB'
- end
- end
-
- class self::ControllerC < self::ControllerB
- layout 'testC'
- end
-
- controller(self::ControllerC) do
- def index
- render template: 'content', layout: 'test_layout'
- end
- end
-
- it 'gets the correct current layout' do
- expect(controller.current_layout).to eq('testC')
- end
-
- it 'gets the correct parent layout' do
- expect(controller.parent_layout).to eq('testB')
- end
-
- it 'gets the correct parent layout of the specified parent' do
- expect(controller.parent_layout(of_layout: 'testB')).to eq('testA')
- end
-
- it 'gets the correct layout hierarchy' do
- expect(controller.layout_hierarchy).to eq([
- 'default',
- 'testA',
- 'testB',
- 'testC'
- ])
- end
-
- describe '#render' do
- context 'when rendering with an explicit :layout' do
- it 'gets the correct layout hierarchy' do
- get :index
- expect(controller.layout_hierarchy).to eq([
- 'default',
- 'testA',
- 'testB',
- 'testC',
- 'test_layout'
- ])
- end
- end
- end
-end
diff --git a/spec/libraries/render_within_layout_spec.rb b/spec/libraries/render_within_layout_spec.rb
deleted file mode 100644
index a36ad5c778d..00000000000
--- a/spec/libraries/render_within_layout_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-require 'rails_helper'
-
-RSpec.describe 'Extension: render within_layout', type: :view do
- let(:views_directory) do
- path = Pathname.new("#{__dir__}/../fixtures/libraries/render_within_layout")
- path.realpath
- end
-
- before do
- controller.prepend_view_path views_directory
- end
-
- it 'properly nests' do
- render template: 'content', layout: 'inner_layout'
- expect(rendered).to have_tag('div.outer') do
- with_tag('div.inner') do
- with_text(/test!/)
- end
- end
- end
-end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index de10a293571..6387b0d2f41 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -15,7 +15,6 @@
# users commonly want.
#
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
-require 'coverage_helper'
require 'rspec/retry' if ENV['CI']
require File.expand_path('../config/environment', __dir__)
@@ -125,4 +124,5 @@
config.server = :puma, { Silent: true }
config.default_max_wait_time = 5
config.enable_aria_label = true
+ config.server_port = Application::Application.config.x.server_port
end
diff --git a/spec/support/acts_as_tenant.rb b/spec/support/acts_as_tenant.rb
index 8690f69f07b..8290d479f18 100644
--- a/spec/support/acts_as_tenant.rb
+++ b/spec/support/acts_as_tenant.rb
@@ -2,8 +2,7 @@
# Test group helpers for setting the tenant for tests.
module ActsAsTenant::TestGroupHelpers
def self.build_host(instance)
- port = Capybara.current_session&.server&.port
- if port
+ if (port = Application::Application.config.x.client_port)
"http://#{instance.host}:#{port}"
else
"http://#{instance.host}"
diff --git a/spec/support/authentication_performers.rb b/spec/support/authentication_performers.rb
new file mode 100644
index 00000000000..68bbdcd3d57
--- /dev/null
+++ b/spec/support/authentication_performers.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+module AuthenticationPerformersTestHelpers
+ include Warden::Test::Helpers
+
+ alias_method :warden_logout, :logout
+
+ def login_as(user, _ = {})
+ # For some reasons, sometimes new scenarios are automatically logged in as the previous user.
+ # Clearing cookies isn't enough and subsequent requests still carries over an old, authenticated
+ # session cookie. We force the server to log out all remaining sessions before logging in.
+ warden_logout
+
+ visit new_user_session_path
+
+ fill_in 'Email address', with: user.email
+ fill_in 'Password', with: password_for(user)
+ click_button 'Sign in'
+
+ wait_for_page
+ end
+
+ def logout(*_)
+ # We expect all pages should have a user menu button.
+ find('div[data-testid="user-menu-button"]').click
+ find('li', text: 'Sign out').click
+
+ super
+ wait_for_page
+ end
+
+ private
+
+ def password_for(user)
+ user.password || Application::Application.config.x.default_user_password
+ end
+end
+
+RSpec.configure do |config|
+ config.include AuthenticationPerformersTestHelpers, type: :feature
+end
diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb
index f8776528776..cf5fa6b3c09 100644
--- a/spec/support/capybara.rb
+++ b/spec/support/capybara.rb
@@ -83,17 +83,9 @@ def fill_in_rails_summernote(selector, text)
# to ensure certain changes are made before continuing with the tests.
def expect_toastify(message)
wait_for_page # To ensure toast is open
- found = false
- find_all('div.Toastify__toast').each do |toast|
- within toast do
- toast_text = find('div.Toastify__toast-body', visible: true).text
- found = true if toast_text.include? message
- find('div.Toastify__toast-body', visible: true).click
- break if found
- end
- end
- wait_for_page # To ensure toast is closed
- expect(found).to be_truthy
+ container = find_all('.Toastify').first
+ expect(container).to have_text(message)
+ find('p', text: message).click
end
# Finds a react-beautiful-dnd draggable element
@@ -148,15 +140,14 @@ def find_sidebar
all('aside').first
end
- def perform_logout_in_course(user_name)
- find_sidebar.find_all('div', text: user_name).first.click
- find('span', text: 'Sign out').click
- wait_for_page
+ def expect_forbidden
+ expect(page).to have_content("You don't have permission to access")
end
- def confirm_registartion_token_via_email
+ def confirm_registration_token_via_email
token = ActionMailer::Base.deliveries.last.body.match(/confirmation_token=.*(?=")/)
visit "/users/confirmation?#{token}"
+ wait_for_page
end
end
end
diff --git a/spec/support/devise.rb b/spec/support/devise.rb
index cdb9877d6ad..454605c8d44 100644
--- a/spec/support/devise.rb
+++ b/spec/support/devise.rb
@@ -16,5 +16,4 @@ def requires_login(as: nil) # rubocop:disable Naming/MethodParameterName
config.include Devise::Test::ControllerHelpers, type: :controller
config.extend DeviseControllerMacros, type: :controller
config.include Warden::Test::Helpers, type: :request
- config.include Warden::Test::Helpers, type: :feature
end
diff --git a/tests/coverage.ts b/tests/coverage.ts
new file mode 100644
index 00000000000..beab59e59bf
--- /dev/null
+++ b/tests/coverage.ts
@@ -0,0 +1,38 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import * as crypto from 'crypto';
+import type { BrowserContext } from '@playwright/test';
+
+import { coverage as config } from './package.json';
+
+const DEFAULT_OUTPUT_PATH = path.join(process.cwd(), config.outputDir);
+
+const getJSONFileName = () =>
+ `${config.fileNamePrefix}${crypto.randomBytes(16).toString('hex')}.json`;
+
+const collectCoverage = () =>
+ window.collectCoverage(JSON.stringify(window.__coverage__));
+
+export const configureCoverage = async (
+ context: BrowserContext,
+ use: (r: BrowserContext) => Promise,
+) => {
+ await context.addInitScript(() => {
+ window.addEventListener('beforeunload', collectCoverage);
+ });
+
+ await fs.promises.mkdir(DEFAULT_OUTPUT_PATH, { recursive: true });
+
+ await context.exposeFunction('collectCoverage', async (coverage: string) => {
+ if (!coverage) return;
+
+ const saveLocation = path.join(DEFAULT_OUTPUT_PATH, getJSONFileName());
+ fs.writeFileSync(saveLocation, coverage);
+ });
+
+ await use(context);
+
+ for (const page of context.pages()) {
+ await page.evaluate(collectCoverage);
+ }
+};
diff --git a/tests/declaration.d.ts b/tests/declaration.d.ts
new file mode 100644
index 00000000000..87a88092385
--- /dev/null
+++ b/tests/declaration.d.ts
@@ -0,0 +1,4 @@
+interface Window {
+ collectCoverage: (coverage: string) => void;
+ __coverage__: object;
+}
diff --git a/tests/helpers.ts b/tests/helpers.ts
new file mode 100644
index 00000000000..0516cb5ff0e
--- /dev/null
+++ b/tests/helpers.ts
@@ -0,0 +1,177 @@
+import {
+ APIRequestContext,
+ Locator,
+ Page as BasePage,
+ test as base,
+} from '@playwright/test';
+
+import packageJSON from './package.json';
+import { configureCoverage } from './coverage';
+
+interface User {
+ id: number;
+ name: string;
+ email: string;
+ password: string;
+ role: string;
+}
+
+interface Page extends BasePage {
+ getReCAPTCHA: () => Locator;
+ getUserMenuButton: () => Locator;
+}
+
+interface SignInPage extends Page {
+ originalPage: Page;
+ getEmailField: () => Locator;
+ getPasswordField: () => Locator;
+ getSignInButton: () => Locator;
+ manufactureUser: () => Promise;
+}
+
+interface SignUpPage extends Page {
+ getNameField: () => Locator;
+ getEmailField: () => Locator;
+ getPasswordField: () => Locator;
+ getConfirmPasswordField: () => Locator;
+ getSignUpButton: () => Locator;
+ gotoSignUpPage: () => ReturnType;
+ gotoInvitation: (token: string) => ReturnType;
+ getFieldMocks: () => Pick;
+}
+
+interface AuthenticatedPage extends Page {
+ user: User;
+ signOut: () => Promise;
+}
+
+interface TestFixtures {
+ page: Page;
+ signInPage: SignInPage;
+ signUpPage: SignUpPage;
+ authedPage: AuthenticatedPage;
+}
+
+let apiContext: APIRequestContext;
+
+const getEmail = (index: number) => `${Date.now()}+${index}@example.org`;
+
+const extend = (
+ use: (r: T) => Promise,
+ page: Page,
+ extension: Omit,
+) => use(Object.assign(page, extension) as T);
+
+export const test = base.extend({
+ context: async ({ context }, use) => {
+ await configureCoverage(context, use);
+ },
+ page: async ({ page }, use) => {
+ await extend(use, page, {
+ getReCAPTCHA: () =>
+ page.frameLocator('[title="reCAPTCHA"]').getByLabel("I'm not a robot"),
+ getUserMenuButton: () => page.getByTestId('user-menu-button'),
+ } satisfies Omit