Compare commits
72 Commits
open-relea
...
ahtisham/I
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b3fc507bf | ||
|
|
a1a9e3b21e | ||
|
|
e1d8af4498 | ||
|
|
6f96b0d6ef | ||
|
|
087adf6562 | ||
|
|
bb8f4b1c50 | ||
|
|
2a50edc334 | ||
|
|
5c75651481 | ||
|
|
a218c29831 | ||
|
|
5f563ab702 | ||
|
|
e6d560733b | ||
|
|
6e03d5bfe8 | ||
|
|
fd4dbef2e0 | ||
|
|
f614c42cc5 | ||
|
|
928108e96c | ||
|
|
aade749d54 | ||
|
|
1494741fae | ||
|
|
01547730a8 | ||
|
|
58e724d724 | ||
|
|
9c576ff3dc | ||
|
|
3f890401e8 | ||
|
|
3622d46538 | ||
|
|
4eaac1eb03 | ||
|
|
2a5e643562 | ||
|
|
b47fc9b3e9 | ||
|
|
bb6e47ce70 | ||
|
|
f378b21e32 | ||
|
|
c6a81e6d15 | ||
|
|
283e16a477 | ||
|
|
0c71e8b5b7 | ||
|
|
49d6fbed3c | ||
|
|
b1c1f1c024 | ||
|
|
af029b43a2 | ||
|
|
b7aff94513 | ||
|
|
c26c7d34e6 | ||
|
|
2eee6c3ca2 | ||
|
|
e7a41b2391 | ||
|
|
ad42959e56 | ||
|
|
67b0b33a81 | ||
|
|
da108a2054 | ||
|
|
1005752bf1 | ||
|
|
31a66f6832 | ||
|
|
37053e9bd3 | ||
|
|
9f84230c17 | ||
|
|
d60f1afa6b | ||
|
|
df6a0d4293 | ||
|
|
e8bd91b418 | ||
|
|
bdaa13a7ad | ||
|
|
5ab324c9ca | ||
|
|
00ab8283e2 | ||
|
|
1719315681 | ||
|
|
8e19eed468 | ||
|
|
11460a3d26 | ||
|
|
a3d0273de6 | ||
|
|
f1d2de6694 | ||
|
|
1169de04f6 | ||
|
|
aa7a5a8cc1 | ||
|
|
9d9377bb8c | ||
|
|
f45f47f2e0 | ||
|
|
bea247f6e5 | ||
|
|
9e878fc916 | ||
|
|
21176131a7 | ||
|
|
b23846b1e4 | ||
|
|
7912d70388 | ||
|
|
42f1efd0a0 | ||
|
|
8e449acde7 | ||
|
|
5f477cb93f | ||
|
|
cf8f08172f | ||
|
|
b72dbae4f0 | ||
|
|
f805f73447 | ||
|
|
27c4d7e3d6 | ||
|
|
b976e812dc |
@@ -120,28 +120,27 @@ function FilterBar({
|
||||
<div className="d-flex flex-row py-2 justify-content-between">
|
||||
{filters.map((value) => (
|
||||
<Form.RadioSet
|
||||
key={value.name}
|
||||
name={value.name}
|
||||
className="d-flex flex-column list-group list-group-flush"
|
||||
value={selectedFilters[value.name]}
|
||||
onChange={onFilterChange}
|
||||
>
|
||||
{
|
||||
value.filters.map(filterName => {
|
||||
const element = allFilters.find(obj => obj.id === filterName);
|
||||
if (element) {
|
||||
return (
|
||||
<ActionItem
|
||||
id={element.id}
|
||||
label={element.label}
|
||||
value={element.value}
|
||||
selected={selectedFilters[value.name]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
}
|
||||
|
||||
{value.filters.map(filterName => {
|
||||
const element = allFilters.find(obj => obj.id === filterName);
|
||||
if (element) {
|
||||
return (
|
||||
<ActionItem
|
||||
key={element.id}
|
||||
id={element.id}
|
||||
label={element.label}
|
||||
value={element.value}
|
||||
selected={selectedFilters[value.name]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return false;
|
||||
})}
|
||||
</Form.RadioSet>
|
||||
))}
|
||||
</div>
|
||||
|
||||
3
src/components/NavigationBar/data/selectors.js
Normal file
3
src/components/NavigationBar/data/selectors.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
export const selectCourseTabs = state => state.courseTabs;
|
||||
@@ -37,9 +37,10 @@ function PostPreviewPane({
|
||||
&& (
|
||||
<Button
|
||||
variant="link"
|
||||
size="md"
|
||||
size="sm"
|
||||
onClick={() => setShowPreviewPane(true)}
|
||||
className={`text-primary-500 px-0 ${editExisting && 'mb-4.5'}`}
|
||||
className={`text-primary-500 p-0 ${editExisting && 'mb-4.5'}`}
|
||||
style={{ lineHeight: '26px' }}
|
||||
>
|
||||
{intl.formatMessage(messages.showPreviewButton)}
|
||||
</Button>
|
||||
|
||||
@@ -32,6 +32,7 @@ import 'tinymce/plugins/lists';
|
||||
import 'tinymce/plugins/emoticons';
|
||||
import 'tinymce/plugins/emoticons/js/emojis';
|
||||
import 'tinymce/plugins/charmap';
|
||||
import 'tinymce/plugins/paste';
|
||||
/* eslint import/no-webpack-loader-syntax: off */
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import edxBrandCss from '!!raw-loader!sass-loader!../index.scss';
|
||||
@@ -100,12 +101,13 @@ export default function TinyMCEEditor(props) {
|
||||
skin: false,
|
||||
menubar: false,
|
||||
branding: false,
|
||||
paste_data_images: false,
|
||||
contextmenu: false,
|
||||
browser_spellcheck: true,
|
||||
a11y_advanced_options: true,
|
||||
autosave_interval: '1s',
|
||||
autosave_restore_when_empty: false,
|
||||
plugins: 'autoresize autosave codesample link lists image imagetools code emoticons charmap',
|
||||
plugins: 'autoresize autosave codesample link lists image imagetools code emoticons charmap paste',
|
||||
toolbar: 'undo redo'
|
||||
+ ' | formatselect | bold italic underline'
|
||||
+ ' | link blockquote openedx_code image'
|
||||
@@ -117,6 +119,7 @@ export default function TinyMCEEditor(props) {
|
||||
content_css: false,
|
||||
content_style: contentStyle,
|
||||
body_class: 'm-2 text-editor',
|
||||
relative_urls: false,
|
||||
default_link_target: '_blank',
|
||||
target_list: false,
|
||||
images_upload_handler: uploadHandler,
|
||||
|
||||
@@ -190,6 +190,8 @@ export const Routes = {
|
||||
CATEGORY: `${BASE_PATH}/category/:category`,
|
||||
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId`,
|
||||
TOPIC: `${BASE_PATH}/topics/:topicId`,
|
||||
TOPIC_POST: `${BASE_PATH}/topics/:topicId/posts/:postId`,
|
||||
TOPIC_POST_EDIT: `${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -38,12 +38,6 @@ export const selectTopicsUnderCategory = createSelector(
|
||||
),
|
||||
);
|
||||
|
||||
export const selectSequences = createSelector(
|
||||
selectChapters,
|
||||
selectBlocks,
|
||||
(chapterIds, blocks) => chapterIds?.flatMap(cId => blocks[cId].children.map(seqId => blocks[seqId])) || [],
|
||||
);
|
||||
|
||||
export const selectArchivedTopics = createSelector(
|
||||
state => state.topics.topics,
|
||||
state => state.topics.archivedIds || [],
|
||||
|
||||
@@ -77,47 +77,65 @@ function DiscussionCommentsView({
|
||||
isLoading,
|
||||
handleLoadMoreResponses,
|
||||
} = usePostComments(postId, endorsed);
|
||||
const sortedComments = useMemo(() => [...filterPosts(comments, 'endorsed'),
|
||||
...filterPosts(comments, 'unendorsed')], [comments]);
|
||||
|
||||
const endorsedComments = useMemo(() => [...filterPosts(comments, 'endorsed')], [comments]);
|
||||
const unEndorsedComments = useMemo(() => [...filterPosts(comments, 'unendorsed')], [comments]);
|
||||
|
||||
const handleDefinition = (message, commentsLength) => (
|
||||
<div className="mx-4 text-primary-700" role="heading" aria-level="2" style={{ lineHeight: '28px' }}>
|
||||
{intl.formatMessage(message, { num: commentsLength })}
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleComments = (postComments, showAddResponse = false, showLoadMoreResponses = false) => (
|
||||
<div className="mx-4" role="list">
|
||||
{postComments.map(comment => (
|
||||
<Comment comment={comment} key={comment.id} postType={postType} isClosedPost={isClosed} />
|
||||
))}
|
||||
{hasMorePages && !isLoading && !showLoadMoreResponses && (
|
||||
<Button
|
||||
onClick={handleLoadMoreResponses}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="card p-4 mb-4 font-weight-500 font-size-14"
|
||||
style={{
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
data-testid="load-more-comments"
|
||||
>
|
||||
{intl.formatMessage(messages.loadMoreResponses)}
|
||||
</Button>
|
||||
)}
|
||||
{isLoading && !showLoadMoreResponses && (
|
||||
<div className="card my-4 p-4 d-flex align-items-center">
|
||||
<Spinner animation="border" variant="primary" />
|
||||
</div>
|
||||
)}
|
||||
{!!postComments.length && !isClosed && showAddResponse
|
||||
&& <ResponseEditor postId={postId} addWrappingDiv />}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{((hasMorePages && isLoading) || !isLoading)
|
||||
&& (
|
||||
<div className="mx-4 text-primary-700" role="heading" aria-level="2" style={{ lineHeight: '28px' }}>
|
||||
{endorsed === EndorsementStatus.ENDORSED
|
||||
? intl.formatMessage(messages.endorsedResponseCount, { num: sortedComments.length })
|
||||
: intl.formatMessage(messages.responseCount, { num: sortedComments.length })}
|
||||
</div>
|
||||
{((hasMorePages && isLoading) || !isLoading) && (
|
||||
<>
|
||||
{endorsedComments.length > 0 && (
|
||||
<>
|
||||
{handleDefinition(messages.endorsedResponseCount, endorsedComments.length)}
|
||||
{endorsed === EndorsementStatus.DISCUSSION
|
||||
? handleComments(endorsedComments, false, true)
|
||||
: handleComments(endorsedComments)}
|
||||
</>
|
||||
)}
|
||||
{endorsed !== EndorsementStatus.ENDORSED && (
|
||||
<>
|
||||
{handleDefinition(messages.responseCount, unEndorsedComments.length)}
|
||||
{unEndorsedComments.length === 0 && <br />}
|
||||
{handleComments(unEndorsedComments, true)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mx-4" role="list">
|
||||
{sortedComments.map(comment => (
|
||||
<Comment comment={comment} key={comment.id} postType={postType} isClosedPost={isClosed} />
|
||||
))}
|
||||
{hasMorePages && !isLoading && (
|
||||
<Button
|
||||
onClick={handleLoadMoreResponses}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="card p-4 mb-4 font-weight-500 font-size-14"
|
||||
style={{
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
data-testid="load-more-comments"
|
||||
>
|
||||
{intl.formatMessage(messages.loadMoreResponses)}
|
||||
</Button>
|
||||
)}
|
||||
{isLoading
|
||||
&& (
|
||||
<div className="card my-4 p-4 d-flex align-items-center">
|
||||
<Spinner animation="border" variant="primary" />
|
||||
</div>
|
||||
)}
|
||||
{!!sortedComments.length && !isClosed
|
||||
&& <ResponseEditor postId={postId} addWrappingDiv />}
|
||||
</div>
|
||||
</>
|
||||
|
||||
);
|
||||
@@ -141,7 +159,7 @@ function CommentsView({ intl }) {
|
||||
const location = useLocation();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const {
|
||||
courseId, learnerUsername, category, topicId, page, inContext,
|
||||
courseId, learnerUsername, category, topicId, page, enableInContextSidebar,
|
||||
} = useContext(DiscussionContext);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -162,7 +180,7 @@ function CommentsView({ intl }) {
|
||||
return (
|
||||
<>
|
||||
{!isOnDesktop && (
|
||||
inContext ? (
|
||||
enableInContextSidebar ? (
|
||||
<>
|
||||
<div className="px-4 py-1.5 bg-white">
|
||||
<Button
|
||||
@@ -194,12 +212,12 @@ function CommentsView({ intl }) {
|
||||
)
|
||||
)}
|
||||
<div className={classNames('discussion-comments d-flex flex-column card', {
|
||||
'm-4 p-4.5': !inContext,
|
||||
'p-4 rounded-0 border-0 mb-4': inContext,
|
||||
'm-4 p-4.5': !enableInContextSidebar,
|
||||
'p-4 rounded-0 border-0 mb-4': enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
<Post post={thread} />
|
||||
{!thread.closed && <ResponseEditor postId={postId} /> }
|
||||
{!thread.closed && <ResponseEditor postId={postId} />}
|
||||
</div>
|
||||
{thread.type === ThreadType.DISCUSSION && (
|
||||
<DiscussionCommentsView
|
||||
|
||||
@@ -384,6 +384,7 @@ describe('CommentsView', () => {
|
||||
});
|
||||
expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
|
||||
});
|
||||
|
||||
it('should allow pinning the post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await act(async () => {
|
||||
@@ -397,6 +398,7 @@ describe('CommentsView', () => {
|
||||
});
|
||||
assertLastUpdateData({ pinned: false });
|
||||
});
|
||||
|
||||
it('should allow reporting the post', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
await act(async () => {
|
||||
@@ -408,6 +410,11 @@ describe('CommentsView', () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /report/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
assertLastUpdateData({ abuse_flagged: true });
|
||||
});
|
||||
|
||||
@@ -426,12 +433,8 @@ describe('CommentsView', () => {
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['endorsing comments', 'Endorse', { endorsed: true }],
|
||||
['reporting comments', 'Report', { abuse_flagged: true }],
|
||||
])('handles %s', async (label, buttonLabel, patchData) => {
|
||||
it('handles endorsing comments', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
// Wait for the content to load
|
||||
await screen.findByText('comment number 7', { exact: false });
|
||||
|
||||
@@ -441,11 +444,36 @@ describe('CommentsView', () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(actionButtons[1]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: buttonLabel }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Endorse/i }));
|
||||
});
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject(patchData);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
|
||||
});
|
||||
|
||||
it('handles reporting comments', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
// Wait for the content to load
|
||||
await screen.findByText('comment number 7', { exact: false });
|
||||
|
||||
// There should be three buttons, one for the post, the second for the
|
||||
// comment and the third for a response to that comment
|
||||
const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(actionButtons[1]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Report/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -627,12 +655,8 @@ describe('CommentsView', () => {
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['endorsing comments', 'Endorse', { endorsed: true }],
|
||||
['reporting comments', 'Report', { abuse_flagged: true }],
|
||||
])('handles %s', async (label, buttonLabel, patchData) => {
|
||||
it('handles endorsing comments', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
|
||||
// Wait for the content to load
|
||||
await screen.findByText('comment number 7', { exact: false });
|
||||
|
||||
@@ -642,11 +666,36 @@ describe('CommentsView', () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(actionButtons[1]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: buttonLabel }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Endorse/i }));
|
||||
});
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject(patchData);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
|
||||
});
|
||||
|
||||
it('handles reporting comments', async () => {
|
||||
renderComponent(discussionPostId);
|
||||
// Wait for the content to load
|
||||
await screen.findByText('comment number 7', { exact: false });
|
||||
|
||||
// There should be three buttons, one for the post, the second for the
|
||||
// comment and the third for a response to that comment
|
||||
const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(actionButtons[1]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Report/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
|
||||
expect(axiosMock.history.patch).toHaveLength(2);
|
||||
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,11 +9,10 @@ import { Button, useToggle } from '@edx/paragon';
|
||||
|
||||
import HTMLLoader from '../../../components/HTMLLoader';
|
||||
import { ContentActions } from '../../../data/constants';
|
||||
import { AlertBanner, DeleteConfirmation, EndorsedAlertBanner } from '../../common';
|
||||
import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../common';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { selectBlackoutDate } from '../../data/selectors';
|
||||
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
|
||||
import { fetchThread } from '../../posts/data/thunks';
|
||||
import { inBlackoutDateRange } from '../../utils';
|
||||
import CommentIcons from '../comment-icons/CommentIcons';
|
||||
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
|
||||
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
|
||||
@@ -36,10 +35,11 @@ function Comment({
|
||||
const inlineReplies = useSelector(selectCommentResponses(comment.id));
|
||||
const [isEditing, setEditing] = useState(false);
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
const [isReplying, setReplying] = useState(false);
|
||||
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
|
||||
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
const {
|
||||
courseId,
|
||||
} = useContext(DiscussionContext);
|
||||
@@ -50,6 +50,24 @@ function Comment({
|
||||
}
|
||||
}, [comment.id]);
|
||||
|
||||
const handleAbusedFlag = () => {
|
||||
if (comment.abuseFlagged) {
|
||||
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirmation = () => {
|
||||
dispatch(removeComment(comment.id));
|
||||
hideDeleteConfirmation();
|
||||
};
|
||||
|
||||
const handleReportConfirmation = () => {
|
||||
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const actionHandlers = {
|
||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||
[ContentActions.ENDORSE]: async () => {
|
||||
@@ -57,7 +75,7 @@ function Comment({
|
||||
await dispatch(fetchThread(comment.threadId, courseId));
|
||||
},
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
|
||||
const handleLoadMoreComments = () => (
|
||||
@@ -67,16 +85,25 @@ function Comment({
|
||||
return (
|
||||
<div className={classNames({ 'py-2 my-3': showFullThread })}>
|
||||
<div className="d-flex flex-column card" data-testid={`comment-${comment.id}`} role="listitem">
|
||||
<DeleteConfirmation
|
||||
<Confirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteResponseTitle)}
|
||||
description={intl.formatMessage(messages.deleteResponseDescription)}
|
||||
onClose={hideDeleteConfirmation}
|
||||
onDelete={() => {
|
||||
dispatch(removeComment(comment.id));
|
||||
hideDeleteConfirmation();
|
||||
}}
|
||||
comfirmAction={handleDeleteConfirmation}
|
||||
closeButtonVaraint="tertiary"
|
||||
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||
/>
|
||||
{!comment.abuseFlagged && (
|
||||
<Confirmation
|
||||
isOpen={isReporting}
|
||||
title={intl.formatMessage(messages.reportResponseTitle)}
|
||||
description={intl.formatMessage(messages.reportResponseDescription)}
|
||||
onClose={hideReportConfirmation}
|
||||
comfirmAction={handleReportConfirmation}
|
||||
confirmButtonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
<EndorsedAlertBanner postType={postType} content={comment} />
|
||||
<div className="d-flex flex-column p-4.5">
|
||||
<AlertBanner content={comment} />
|
||||
@@ -130,18 +157,18 @@ function Comment({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{(!isClosedPost && !inBlackoutDateRange(blackoutDateRange))
|
||||
{!isClosedPost && userCanAddThreadInBlackoutDate
|
||||
&& (
|
||||
<Button
|
||||
className="d-flex flex-grow mt-3 py-2 font-size-14"
|
||||
variant="outline-primary"
|
||||
style={{
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
onClick={() => setReplying(true)}
|
||||
>
|
||||
{intl.formatMessage(messages.addComment)}
|
||||
</Button>
|
||||
<Button
|
||||
className="d-flex flex-grow mt-3 py-2 font-size-14"
|
||||
variant="outline-primary"
|
||||
style={{
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
onClick={() => setReplying(true)}
|
||||
>
|
||||
{intl.formatMessage(messages.addComment)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
||||
|
||||
@@ -66,19 +66,15 @@ function CommentHeader({
|
||||
<div className="d-flex align-items-center">
|
||||
|
||||
{actionIcons && (
|
||||
<span className="btn-icon btn-icon-sm mr-1 align-items-center">
|
||||
<Icon
|
||||
data-testid="check-icon"
|
||||
onClick={
|
||||
() => {
|
||||
handleIcons(actionIcons.action);
|
||||
}
|
||||
}
|
||||
src={actionIcons.icon}
|
||||
className={['endorse', 'unendorse'].includes(actionIcons.id) ? 'text-dark-500' : 'text-success-500'}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
<span className="btn-icon btn-icon-sm mr-1 align-items-center pointer-cursor-hover">
|
||||
<Icon
|
||||
data-testid="check-icon"
|
||||
onClick={() => handleIcons(actionIcons.action)}
|
||||
src={actionIcons.icon}
|
||||
className={['endorse', 'unendorse'].includes(actionIcons.id) ? 'text-dark-500' : 'text-success-500'}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<ActionsDropdown
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Avatar, useToggle } from '@edx/paragon';
|
||||
import HTMLLoader from '../../../components/HTMLLoader';
|
||||
import { AvatarOutlineAndLabelColors, ContentActions } from '../../../data/constants';
|
||||
import {
|
||||
ActionsDropdown, AlertBanner, AuthorLabel, DeleteConfirmation,
|
||||
ActionsDropdown, AlertBanner, AuthorLabel, Confirmation,
|
||||
} from '../../common';
|
||||
import timeLocale from '../../common/time-locale';
|
||||
import { useAlertBannerVisible } from '../../data/hooks';
|
||||
@@ -29,6 +29,26 @@ function Reply({
|
||||
const dispatch = useDispatch();
|
||||
const [isEditing, setEditing] = useState(false);
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
|
||||
const handleAbusedFlag = () => {
|
||||
if (reply.abuseFlagged) {
|
||||
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirmation = () => {
|
||||
dispatch(removeComment(reply.id));
|
||||
hideDeleteConfirmation();
|
||||
};
|
||||
|
||||
const handleReportConfirmation = () => {
|
||||
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged }));
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const actionHandlers = {
|
||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||
[ContentActions.ENDORSE]: () => dispatch(editComment(
|
||||
@@ -37,7 +57,7 @@ function Reply({
|
||||
ContentActions.ENDORSE,
|
||||
)),
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[ContentActions.REPORT]: () => dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })),
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
const authorAvatars = useSelector(selectAuthorAvatars(reply.author));
|
||||
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
|
||||
@@ -45,17 +65,25 @@ function Reply({
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column mt-4.5" data-testid={`reply-${reply.id}`} role="listitem">
|
||||
<DeleteConfirmation
|
||||
<Confirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteCommentTitle)}
|
||||
description={intl.formatMessage(messages.deleteCommentDescription)}
|
||||
onClose={hideDeleteConfirmation}
|
||||
onDelete={() => {
|
||||
dispatch(removeComment(reply.id));
|
||||
hideDeleteConfirmation();
|
||||
}}
|
||||
comfirmAction={handleDeleteConfirmation}
|
||||
closeButtonVaraint="tertiary"
|
||||
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||
/>
|
||||
|
||||
{!reply.abuseFlagged && (
|
||||
<Confirmation
|
||||
isOpen={isReporting}
|
||||
title={intl.formatMessage(messages.reportCommentTitle)}
|
||||
description={intl.formatMessage(messages.reportCommentDescription)}
|
||||
onClose={hideReportConfirmation}
|
||||
comfirmAction={handleReportConfirmation}
|
||||
confirmButtonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
{hasAnyAlert && (
|
||||
<div className="d-flex">
|
||||
<div className="d-flex invisible">
|
||||
@@ -85,13 +113,15 @@ function Reply({
|
||||
>
|
||||
<div className="d-flex flex-row justify-content-between align-items-center mb-0.5">
|
||||
<AuthorLabel author={reply.author} authorLabel={reply.authorLabel} labelColor={colorClass && `text-${colorClass}`} linkToProfile />
|
||||
<ActionsDropdown
|
||||
commentOrPost={{
|
||||
...reply,
|
||||
postType,
|
||||
}}
|
||||
actionHandlers={actionHandlers}
|
||||
/>
|
||||
<div className="ml-auto d-flex">
|
||||
<ActionsDropdown
|
||||
commentOrPost={{
|
||||
...reply,
|
||||
postType,
|
||||
}}
|
||||
actionHandlers={actionHandlers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isEditing
|
||||
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
|
||||
|
||||
@@ -2,14 +2,12 @@ import React, { useContext, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { selectBlackoutDate } from '../../data/selectors';
|
||||
import { inBlackoutDateRange } from '../../utils';
|
||||
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
|
||||
import messages from '../messages';
|
||||
import CommentEditor from './CommentEditor';
|
||||
|
||||
@@ -18,15 +16,14 @@ function ResponseEditor({
|
||||
intl,
|
||||
addWrappingDiv,
|
||||
}) {
|
||||
const { inContext } = useContext(DiscussionContext);
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const [addingResponse, setAddingResponse] = useState(false);
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
|
||||
useEffect(() => {
|
||||
setAddingResponse(false);
|
||||
}, [postId]);
|
||||
|
||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
||||
|
||||
return addingResponse
|
||||
? (
|
||||
<div className={classNames({ 'bg-white p-4 mb-4 rounded': addWrappingDiv })}>
|
||||
@@ -37,11 +34,11 @@ function ResponseEditor({
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: !inBlackoutDateRange(blackoutDateRange) && (
|
||||
: userCanAddThreadInBlackoutDate && (
|
||||
<div className={classNames({ 'mb-4': addWrappingDiv }, 'actions d-flex')}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={classNames('px-2.5 py-2 font-size-14', { 'w-100': inContext })}
|
||||
className={classNames('px-2.5 py-2 font-size-14', { 'w-100': enableInContextSidebar })}
|
||||
onClick={() => setAddingResponse(true)}
|
||||
style={{
|
||||
lineHeight: '20px',
|
||||
|
||||
@@ -148,6 +148,31 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Are you sure you want to permanently delete this comment?',
|
||||
description: 'Text displayed in confirmation dialog when deleting a comment',
|
||||
},
|
||||
deleteConfirmationDelete: {
|
||||
id: 'discussions.delete.confirmation.button.delete',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Delete button shown on delete confirmation dialog',
|
||||
},
|
||||
reportResponseTitle: {
|
||||
id: 'discussions.editor.response.response.title',
|
||||
defaultMessage: 'Report inappropriate content?',
|
||||
description: 'Title of confirmation dialog shown when reporting a response',
|
||||
},
|
||||
reportResponseDescription: {
|
||||
id: 'discussions.editor.response.description',
|
||||
defaultMessage: 'The discussion moderation team will review this content and take appropriate action.',
|
||||
description: 'Text displayed in confirmation dialog when deleting a response',
|
||||
},
|
||||
reportCommentTitle: {
|
||||
id: 'discussions.editor.report.comment.title',
|
||||
defaultMessage: 'Report inappropriate content?',
|
||||
description: 'Title of confirmation dialog shown when reporting a comment',
|
||||
},
|
||||
reportCommentDescription: {
|
||||
id: 'discussions.editor.report.comment.description',
|
||||
defaultMessage: 'The discussion moderation team will review this content and take appropriate action.',
|
||||
description: 'Text displayed in confirmation dialog when deleting a response',
|
||||
},
|
||||
editReasonCode: {
|
||||
id: 'discussions.editor.comments.editReasonCode',
|
||||
defaultMessage: 'Reason for editing',
|
||||
|
||||
@@ -27,7 +27,7 @@ function ActionsDropdown({
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState(null);
|
||||
const actions = useActions(commentOrPost);
|
||||
const { inContext } = useContext(DiscussionContext);
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const handleActions = (action) => {
|
||||
const actionFunction = actionHandlers[action];
|
||||
if (actionFunction) {
|
||||
@@ -52,37 +52,39 @@ function ActionsDropdown({
|
||||
size="sm"
|
||||
ref={setTarget}
|
||||
/>
|
||||
<ModalPopup
|
||||
onClose={close}
|
||||
positionRef={target}
|
||||
isOpen={isOpen}
|
||||
placement={inContext ? 'left' : 'auto-start'}
|
||||
>
|
||||
<div
|
||||
className="bg-white p-1 shadow d-flex flex-column"
|
||||
data-testid="actions-dropdown-modal-popup"
|
||||
<div className="actions-dropdown">
|
||||
<ModalPopup
|
||||
onClose={close}
|
||||
positionRef={target}
|
||||
isOpen={isOpen}
|
||||
placement={enableInContextSidebar ? 'left' : 'auto-start'}
|
||||
>
|
||||
{actions.map(action => (
|
||||
<React.Fragment key={action.id}>
|
||||
{action.action === ContentActions.DELETE
|
||||
<div
|
||||
className="bg-white p-1 shadow d-flex flex-column"
|
||||
data-testid="actions-dropdown-modal-popup"
|
||||
>
|
||||
{actions.map(action => (
|
||||
<React.Fragment key={action.id}>
|
||||
{(action.action === ContentActions.DELETE)
|
||||
&& <Dropdown.Divider />}
|
||||
|
||||
<Dropdown.Item
|
||||
as={Button}
|
||||
variant="tertiary"
|
||||
size="inline"
|
||||
onClick={() => {
|
||||
close();
|
||||
handleActions(action.action);
|
||||
}}
|
||||
className="d-flex justify-content-start py-1.5 mr-4"
|
||||
>
|
||||
<Icon src={action.icon} className="mr-1" /> {intl.formatMessage(action.label)}
|
||||
</Dropdown.Item>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</ModalPopup>
|
||||
<Dropdown.Item
|
||||
as={Button}
|
||||
variant="tertiary"
|
||||
size="inline"
|
||||
onClick={() => {
|
||||
close();
|
||||
handleActions(action.action);
|
||||
}}
|
||||
className="d-flex justify-content-start py-1.5 mr-4"
|
||||
>
|
||||
<Icon src={action.icon} className="mr-1" /> {intl.formatMessage(action.label)}
|
||||
</Dropdown.Item>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</ModalPopup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ import { useSelector } from 'react-redux';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
import { Report } from '@edx/paragon/icons';
|
||||
|
||||
import { commentShape } from '../comments/comment/proptypes';
|
||||
import messages from '../comments/messages';
|
||||
import { selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../data/selectors';
|
||||
import {
|
||||
selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
|
||||
} from '../data/selectors';
|
||||
import { postShape } from '../posts/post/proptypes';
|
||||
import AuthorLabel from './AuthorLabel';
|
||||
|
||||
@@ -20,15 +22,18 @@ function AlertBanner({
|
||||
}) {
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const userIsGlobalStaff = useSelector(selectUserIsStaff);
|
||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
||||
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
|
||||
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
|
||||
const canSeeReportedBanner = content?.abuseFlagged;
|
||||
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsGroupTa
|
||||
|| userIsGlobalStaff || userIsContentAuthor
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{canSeeReportedBanner && (
|
||||
<Alert icon={Error} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
|
||||
<Alert icon={Report} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
|
||||
{intl.formatMessage(messages.abuseFlaggedMessage)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -38,7 +38,7 @@ function AuthorLabel({
|
||||
|
||||
const isRetiredUser = author ? author.startsWith('retired__user') : false;
|
||||
|
||||
const className = classNames('d-flex align-items-center', labelColor);
|
||||
const className = classNames('d-flex align-items-center mb-0.5', labelColor);
|
||||
|
||||
const showUserNameAsLink = useShowLearnersTab()
|
||||
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
|
||||
|
||||
@@ -6,13 +6,16 @@ import { ActionRow, Button, ModalDialog } from '@edx/paragon';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function DeleteConfirmation({
|
||||
function Confirmation({
|
||||
intl,
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
onClose,
|
||||
onDelete,
|
||||
comfirmAction,
|
||||
closeButtonVaraint,
|
||||
confirmButtonVariant,
|
||||
confirmButtonText,
|
||||
}) {
|
||||
return (
|
||||
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
|
||||
@@ -26,11 +29,11 @@ function DeleteConfirmation({
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages.deleteConfirmationCancel)}
|
||||
<ModalDialog.CloseButton variant={closeButtonVaraint}>
|
||||
{intl.formatMessage(messages.confirmationCancel)}
|
||||
</ModalDialog.CloseButton>
|
||||
<Button variant="primary" onClick={onDelete}>
|
||||
{intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||
<Button variant={confirmButtonVariant} onClick={comfirmAction}>
|
||||
{ confirmButtonText || intl.formatMessage(messages.confirmationConfirm)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
@@ -38,13 +41,22 @@ function DeleteConfirmation({
|
||||
);
|
||||
}
|
||||
|
||||
DeleteConfirmation.propTypes = {
|
||||
Confirmation.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
comfirmAction: PropTypes.func.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
closeButtonVaraint: PropTypes.string,
|
||||
confirmButtonVariant: PropTypes.string,
|
||||
confirmButtonText: PropTypes.string,
|
||||
};
|
||||
|
||||
export default injectIntl(DeleteConfirmation);
|
||||
Confirmation.defaultProps = {
|
||||
closeButtonVaraint: 'default',
|
||||
confirmButtonVariant: 'primary',
|
||||
confirmButtonText: '',
|
||||
};
|
||||
|
||||
export default injectIntl(Confirmation);
|
||||
@@ -6,7 +6,7 @@ export const DiscussionContext = React.createContext({
|
||||
courseId: null,
|
||||
postId: null,
|
||||
topicId: null,
|
||||
inContext: false,
|
||||
enableInContextSidebar: false,
|
||||
category: null,
|
||||
learnerUsername: null,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { default as ActionsDropdown } from './ActionsDropdown';
|
||||
export { default as AlertBanner } from './AlertBanner';
|
||||
export { default as AuthorLabel } from './AuthorLabel';
|
||||
export { default as DeleteConfirmation } from './DeleteConfirmation';
|
||||
export { default as Confirmation } from './Confirmation';
|
||||
export { default as EndorsedAlertBanner } from './EndorsedAlertBanner';
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import {
|
||||
useContext, useEffect, useRef, useState,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -10,20 +13,30 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { selectTopicsUnderCategory } from '../../data/selectors';
|
||||
import { fetchCourseBlocks } from '../../data/thunks';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { clearRedirect } from '../posts/data';
|
||||
import { threadsLoadingStatus } from '../posts/data/selectors';
|
||||
import { selectTopics } from '../topics/data/selectors';
|
||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||
import { discussionsPath } from '../utils';
|
||||
import tourCheckpoints from '../tours/constants';
|
||||
import { selectTours } from '../tours/data/selectors';
|
||||
import { updateTourShowStatus } from '../tours/data/thunks';
|
||||
import messages from '../tours/messages';
|
||||
import { discussionsPath, inBlackoutDateRange } from '../utils';
|
||||
import {
|
||||
selectAreThreadsFiltered, selectLearnersTabEnabled,
|
||||
selectAreThreadsFiltered,
|
||||
selectBlackoutDate,
|
||||
selectEnableInContext,
|
||||
selectIsCourseAdmin,
|
||||
selectIsCourseStaff,
|
||||
selectLearnersTabEnabled,
|
||||
selectModerationSettings,
|
||||
selectPostThreadCount,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
selectUserIsStaff,
|
||||
} from './selectors';
|
||||
import { fetchCourseConfig } from './thunks';
|
||||
|
||||
@@ -34,27 +47,28 @@ export function useTotalTopicThreadCount() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Object.keys(topics).reduce((total, topicId) => {
|
||||
const topic = topics[topicId];
|
||||
return total + topic.threadCounts.discussion + topic.threadCounts.question;
|
||||
}, 0);
|
||||
return Object.keys(topics)
|
||||
.reduce((total, topicId) => {
|
||||
const topic = topics[topicId];
|
||||
return total + topic.threadCounts.discussion + topic.threadCounts.question;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export const useSidebarVisible = () => {
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const isViewingTopics = useRouteMatch(Routes.TOPICS.ALL);
|
||||
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
|
||||
const isFiltered = useSelector(selectAreThreadsFiltered);
|
||||
const totalThreads = useSelector(selectPostThreadCount);
|
||||
const isViewingTopics = useRouteMatch(Routes.TOPICS.PATH);
|
||||
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
|
||||
const isThreadsEmpty = Boolean(useSelector(threadsLoadingStatus()) === RequestStatus.SUCCESSFUL && !totalThreads);
|
||||
const isIncontextTopicsView = Boolean(useRouteMatch(Routes.TOPICS.PATH) && enableInContext);
|
||||
const hideSidebar = Boolean(isThreadsEmpty && !isFiltered && !(isViewingTopics?.isExact || isViewingLearners));
|
||||
|
||||
if (isFiltered) {
|
||||
if (isIncontextTopicsView) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isViewingTopics || isViewingLearners) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return totalThreads > 0;
|
||||
return !hideSidebar;
|
||||
};
|
||||
|
||||
export function useCourseDiscussionData(courseId) {
|
||||
@@ -64,7 +78,6 @@ export function useCourseDiscussionData(courseId) {
|
||||
useEffect(() => {
|
||||
async function fetchBaseData() {
|
||||
await dispatch(fetchCourseConfig(courseId));
|
||||
await dispatch(fetchCourseTopics(courseId));
|
||||
await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
|
||||
}
|
||||
|
||||
@@ -72,7 +85,7 @@ export function useCourseDiscussionData(courseId) {
|
||||
}, [courseId]);
|
||||
}
|
||||
|
||||
export function useRedirectToThread(courseId, inContext) {
|
||||
export function useRedirectToThread(courseId, enableInContextSidebar) {
|
||||
const dispatch = useDispatch();
|
||||
const redirectToThread = useSelector(
|
||||
(state) => state.threads.redirectToThread,
|
||||
@@ -85,7 +98,7 @@ export function useRedirectToThread(courseId, inContext) {
|
||||
// stored in redirectToThread
|
||||
if (redirectToThread) {
|
||||
dispatch(clearRedirect());
|
||||
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[inContext ? 'topics' : 'my-posts'], {
|
||||
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[enableInContextSidebar ? 'topics' : 'my-posts'], {
|
||||
courseId,
|
||||
postId: redirectToThread.threadId,
|
||||
topicId: redirectToThread.topicId,
|
||||
@@ -97,7 +110,7 @@ export function useRedirectToThread(courseId, inContext) {
|
||||
|
||||
export function useIsOnDesktop() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.large.minWidth;
|
||||
return windowSize.width >= breakpoints.medium.minWidth;
|
||||
}
|
||||
|
||||
export function useIsOnXLDesktop() {
|
||||
@@ -174,3 +187,40 @@ export const useCurrentDiscussionTopic = () => {
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useUserCanAddThreadInBlackoutDate = () => {
|
||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
||||
const isUserAdmin = useSelector(selectUserIsStaff);
|
||||
const userHasModerationPrivilages = useSelector(selectUserHasModerationPrivileges);
|
||||
const isUserGroupTA = useSelector(selectUserIsGroupTa);
|
||||
const isCourseAdmin = useSelector(selectIsCourseAdmin);
|
||||
const isCourseStaff = useSelector(selectIsCourseStaff);
|
||||
const isInBlackoutDateRange = inBlackoutDateRange(blackoutDateRange);
|
||||
|
||||
return (!(isInBlackoutDateRange)
|
||||
|| (isUserAdmin || userHasModerationPrivilages || isUserGroupTA || isCourseAdmin || isCourseStaff));
|
||||
};
|
||||
|
||||
function camelToConstant(string) {
|
||||
return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase();
|
||||
}
|
||||
|
||||
export const useTourConfiguration = (intl) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const tours = useSelector(selectTours);
|
||||
|
||||
return tours.map((tour) => (
|
||||
{
|
||||
tourId: tour.tourName,
|
||||
advanceButtonText: intl.formatMessage(messages.advanceButtonText),
|
||||
dismissButtonText: intl.formatMessage(messages.dismissButtonText),
|
||||
endButtonText: intl.formatMessage(messages.endButtonText),
|
||||
enabled: tour && Boolean(tour.showTour && !enableInContextSidebar),
|
||||
onDismiss: () => dispatch(updateTourShowStatus(tour.id)),
|
||||
onEnd: () => dispatch(updateTourShowStatus(tour.id)),
|
||||
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { useCurrentDiscussionTopic } from './hooks';
|
||||
import { getCourseConfigApiUrl } from './api';
|
||||
import { useCurrentDiscussionTopic, useUserCanAddThreadInBlackoutDate } from './hooks';
|
||||
import { fetchCourseConfig } from './thunks';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
let store;
|
||||
initializeMockApp();
|
||||
let axiosMock;
|
||||
|
||||
const generateApiResponse = (blackouts = [], isCourseAdmin = false) => ({
|
||||
blackouts,
|
||||
hasModerationPrivileges: false,
|
||||
isGroupTa: false,
|
||||
isCourseAdmin,
|
||||
isCourseStaff: false,
|
||||
isUserAdmin: false,
|
||||
});
|
||||
|
||||
describe('Hooks', () => {
|
||||
describe('useCurrentDiscussionTopic', () => {
|
||||
function ComponentWithHook() {
|
||||
@@ -39,6 +57,7 @@ describe('Hooks', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMockApp();
|
||||
store = initializeStore({
|
||||
blocks: {
|
||||
blocks: {
|
||||
@@ -82,4 +101,75 @@ describe('Hooks', () => {
|
||||
expect(queryByText('null')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUserCanAddThreadInBlackoutDate', () => {
|
||||
function ComponentWithHook() {
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
return (
|
||||
<div>
|
||||
{String(userCanAddThreadInBlackoutDate)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<ComponentWithHook />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
describe('User can add Thread in blackoutdates ', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
Factory.resetAll();
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
test('when blackoutdates are not active and Role is Learner return true', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
||||
.reply(200, generateApiResponse([], false));
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
const { queryByText } = renderComponent();
|
||||
expect(queryByText('true')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when blackoutdates are not active and Role is not Learner return true', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
||||
.reply(200, generateApiResponse([], true));
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
const { queryByText } = renderComponent();
|
||||
expect(queryByText('true')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when blackoutdates are active and Role is Learner return false', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
||||
.reply(200, generateApiResponse([{
|
||||
start: '2022-11-25T00:00:00Z',
|
||||
end: '2050-11-25T23:59:00Z',
|
||||
}], false));
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
const { queryByText } = renderComponent();
|
||||
expect(queryByText('false')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when blackoutdates are active and Role is not Learner return true', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
|
||||
.reply(200, generateApiResponse([
|
||||
{ start: '2022-11-25T00:00:00Z', end: '2050-11-25T23:59:00Z' }], true));
|
||||
const { queryByText } = renderComponent();
|
||||
expect(queryByText('true')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,12 @@ export const selectBlackoutDate = state => state.config.blackouts;
|
||||
|
||||
export const selectGroupAtSubsection = state => state.config.groupAtSubsection;
|
||||
|
||||
export const selectIsCourseAdmin = state => state.config.isCourseAdmin;
|
||||
|
||||
export const selectIsCourseStaff = state => state.config.isCourseStaff;
|
||||
|
||||
export const selectEnableInContext = state => state.config.enableInContext;
|
||||
|
||||
export const selectModerationSettings = state => ({
|
||||
postCloseReasons: state.config.postCloseReasons,
|
||||
editReasons: state.config.editReasons,
|
||||
@@ -47,7 +53,7 @@ export function selectAreThreadsFiltered(state) {
|
||||
|
||||
export function selectTopicThreadCount(topicId) {
|
||||
return state => {
|
||||
const topic = state.topics.topics[topicId];
|
||||
const topic = topicId && state.topics?.topics[topicId];
|
||||
if (!topic) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ const configSlice = createSlice({
|
||||
groupAtSubsection: false,
|
||||
hasModerationPrivileges: false,
|
||||
isGroupTa: false,
|
||||
isCourseAdmin: false,
|
||||
isCourseStaff: false,
|
||||
isUserAdmin: false,
|
||||
learnersTabEnabled: false,
|
||||
settings: {
|
||||
@@ -25,6 +27,7 @@ const configSlice = createSlice({
|
||||
reasonCodesEnabled: false,
|
||||
editReasons: [],
|
||||
postCloseReasons: [],
|
||||
enableInContext: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchConfigRequest: (state) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import {
|
||||
LearnersOrdering,
|
||||
DiscussionProvider, LearnersOrdering,
|
||||
PostsStatusFilter,
|
||||
} from '../../data/constants';
|
||||
import { setSortedBy } from '../learners/data';
|
||||
@@ -36,7 +36,10 @@ export function fetchCourseConfig(courseId) {
|
||||
learnerSort = LearnersOrdering.BY_FLAG;
|
||||
}
|
||||
|
||||
dispatch(fetchConfigSuccess(camelCaseObject(config)));
|
||||
dispatch(fetchConfigSuccess(camelCaseObject({
|
||||
...config,
|
||||
enable_in_context: config.provider === DiscussionProvider.OPEN_EDX,
|
||||
})));
|
||||
dispatch(setSortedBy(learnerSort));
|
||||
dispatch(setStatusFilter(postsFilterStatus));
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,51 +14,72 @@ import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
|
||||
} from '../data/hooks';
|
||||
import { selectconfigLoadingStatus } from '../data/selectors';
|
||||
import { selectconfigLoadingStatus, selectEnableInContext } from '../data/selectors';
|
||||
import { TopicPostsView, TopicsView as InContextTopicsView } from '../in-context-topics';
|
||||
import { LearnerPostsView, LearnersView } from '../learners';
|
||||
import { PostsView } from '../posts';
|
||||
import { TopicsView } from '../topics';
|
||||
import { TopicsView as LegacyTopicsView } from '../topics';
|
||||
|
||||
export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) {
|
||||
const location = useLocation();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnXLDesktop = useIsOnXLDesktop();
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const configStatus = useSelector(selectconfigLoadingStatus);
|
||||
const redirectToLearnersTab = useShowLearnersTab();
|
||||
const sidebarRef = useRef(null);
|
||||
const postActionBarHeight = useContainerSize(postActionBarRef);
|
||||
const { height: windowHeight } = useWindowSize();
|
||||
const { inContext } = useContext(DiscussionContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarRef && postActionBarHeight && !inContext) {
|
||||
if (sidebarRef && postActionBarHeight && !enableInContextSidebar) {
|
||||
if (isOnDesktop) {
|
||||
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
}
|
||||
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
sidebarRef.current.style.top = `${postActionBarHeight}px`;
|
||||
}
|
||||
}, [sidebarRef, postActionBarHeight, inContext]);
|
||||
}, [sidebarRef, postActionBarHeight, enableInContextSidebar]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={classNames('flex-column position-sticky', {
|
||||
className={classNames('flex-column position-sticky', {
|
||||
'd-none': !displaySidebar,
|
||||
'd-flex overflow-auto': displaySidebar,
|
||||
'd-flex overflow-auto box-shadow-centered-1': displaySidebar,
|
||||
'w-100': !isOnDesktop,
|
||||
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
|
||||
'w-25 sidebar-XL-width': isOnXLDesktop,
|
||||
'min-content-height': !inContext,
|
||||
'min-content-height': !enableInContextSidebar,
|
||||
})}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
<Switch>
|
||||
{enableInContext && !enableInContextSidebar && (
|
||||
<Route
|
||||
path={Routes.TOPICS.ALL}
|
||||
component={InContextTopicsView}
|
||||
exact
|
||||
/>
|
||||
)}
|
||||
{enableInContext && !enableInContextSidebar && (
|
||||
<Route
|
||||
path={[
|
||||
Routes.TOPICS.TOPIC,
|
||||
Routes.TOPICS.CATEGORY,
|
||||
Routes.TOPICS.TOPIC_POST,
|
||||
Routes.TOPICS.TOPIC_POST_EDIT,
|
||||
]}
|
||||
component={TopicPostsView}
|
||||
exact
|
||||
/>
|
||||
)}
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY, Routes.POSTS.MY_POSTS]}
|
||||
path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||
component={PostsView}
|
||||
/>
|
||||
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
|
||||
<Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} />
|
||||
{redirectToLearnersTab && (
|
||||
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
|
||||
)}
|
||||
@@ -66,13 +87,13 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
|
||||
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
|
||||
)}
|
||||
{configStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Redirect
|
||||
from={Routes.DISCUSSIONS.PATH}
|
||||
to={{
|
||||
...location,
|
||||
pathname: Routes.POSTS.ALL_POSTS,
|
||||
}}
|
||||
/>
|
||||
<Redirect
|
||||
from={Routes.DISCUSSIONS.PATH}
|
||||
to={{
|
||||
...location,
|
||||
pathname: Routes.POSTS.ALL_POSTS,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
@@ -12,60 +12,53 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { PostActionsBar } from '../../components';
|
||||
import { CourseTabsNavigation } from '../../components/NavigationBar';
|
||||
import { selectCourseTabs } from '../../components/NavigationBar/data/selectors';
|
||||
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useShowLearnersTab, useSidebarVisible,
|
||||
} from '../data/hooks';
|
||||
import { selectDiscussionProvider } from '../data/selectors';
|
||||
import { selectDiscussionProvider, selectEnableInContext } from '../data/selectors';
|
||||
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
|
||||
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
|
||||
import messages from '../messages';
|
||||
import { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
|
||||
import { LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
|
||||
import { selectPostEditorVisible } from '../posts/data/selectors';
|
||||
import DiscussionsProductTour from '../tours/DiscussionsProductTour';
|
||||
import { postMessageToParent } from '../utils';
|
||||
import BlackoutInformationBanner from './BlackoutInformationBanner';
|
||||
import DiscussionContent from './DiscussionContent';
|
||||
import DiscussionSidebar from './DiscussionSidebar';
|
||||
import InformationBanner from './InformationsBanner';
|
||||
import InformationBanner from './InformationBanner';
|
||||
|
||||
export default function DiscussionsHome() {
|
||||
const location = useLocation();
|
||||
const postActionBarRef = useRef(null);
|
||||
const postEditorVisible = useSelector(
|
||||
(state) => state.threads.postEditorVisible,
|
||||
);
|
||||
const {
|
||||
params: { page },
|
||||
} = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
|
||||
|
||||
const postEditorVisible = useSelector(selectPostEditorVisible);
|
||||
const provider = useSelector(selectDiscussionProvider);
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
|
||||
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
|
||||
const { params: { path } } = useRouteMatch(`${Routes.DISCUSSIONS.PATH}/:path*`);
|
||||
const { params } = useRouteMatch(ALL_ROUTES);
|
||||
const isRedirectToLearners = useShowLearnersTab();
|
||||
const isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
|
||||
|
||||
const {
|
||||
courseId,
|
||||
postId,
|
||||
topicId,
|
||||
category,
|
||||
learnerUsername,
|
||||
} = params;
|
||||
const inContext = new URLSearchParams(location.search).get('inContext') !== null;
|
||||
// Display the content area if we are currently viewing/editing a post or creating one.
|
||||
const displayContentArea = postId || postEditorVisible || (learnerUsername && postId);
|
||||
let displaySidebar = useSidebarVisible();
|
||||
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
let displaySidebar = useSidebarVisible();
|
||||
const enableInContextSidebar = Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
|
||||
const isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
|
||||
const {
|
||||
courseId, postId, topicId, category, learnerUsername,
|
||||
} = params;
|
||||
|
||||
const { courseNumber, courseTitle, org } = useSelector((state) => state.courseTabs);
|
||||
if (displayContentArea) {
|
||||
// If the window is larger than a particular size, show the sidebar for navigating between posts/topics.
|
||||
// However, for smaller screens or embeds, only show the sidebar if the content area isn't displayed.
|
||||
displaySidebar = isOnDesktop;
|
||||
}
|
||||
|
||||
const provider = useSelector(selectDiscussionProvider);
|
||||
useCourseDiscussionData(courseId);
|
||||
useRedirectToThread(courseId, inContext);
|
||||
useRedirectToThread(courseId, enableInContextSidebar);
|
||||
|
||||
/* Display the content area if we are currently viewing/editing a post or creating one.
|
||||
If the window is larger than a particular size, show the sidebar for navigating between posts/topics.
|
||||
However, for smaller screens or embeds, onlyshow the sidebar if the content area isn't displayed. */
|
||||
const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId));
|
||||
if (displayContentArea) { displaySidebar = isOnDesktop; }
|
||||
|
||||
useEffect(() => {
|
||||
if (path && path !== 'undefined') {
|
||||
postMessageToParent('discussions.navigate', { path });
|
||||
@@ -78,41 +71,47 @@ export default function DiscussionsHome() {
|
||||
courseId,
|
||||
postId,
|
||||
topicId,
|
||||
inContext,
|
||||
enableInContextSidebar,
|
||||
category,
|
||||
learnerUsername,
|
||||
}}
|
||||
>
|
||||
{!inContext && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />}
|
||||
{!enableInContextSidebar && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />}
|
||||
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
|
||||
{!inContext && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
|
||||
{!enableInContextSidebar && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
|
||||
<div
|
||||
className={classNames('header-action-bar', { 'shadow-none border-light-300 border-bottom': inContext })}
|
||||
className={classNames('header-action-bar', {
|
||||
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
|
||||
})}
|
||||
ref={postActionBarRef}
|
||||
>
|
||||
<div
|
||||
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
|
||||
'pl-4 pr-2.5 py-1.5': inContext,
|
||||
'pl-4 pr-2.5 py-1.5': enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
{!inContext && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
|
||||
<PostActionsBar inContext={inContext} />
|
||||
{!enableInContextSidebar && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
|
||||
<PostActionsBar />
|
||||
</div>
|
||||
{isFeedbackBannerVisible && <InformationBanner />}
|
||||
<BlackoutInformationBanner />
|
||||
</div>
|
||||
{!inContext && (
|
||||
{provider === DiscussionProvider.LEGACY && (
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
|
||||
component={LegacyBreadcrumbMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="d-flex flex-row">
|
||||
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
|
||||
{displayContentArea && <DiscussionContent />}
|
||||
{!displayContentArea && (
|
||||
<Switch>
|
||||
<Route path={Routes.TOPICS.PATH} component={EmptyTopics} />
|
||||
<Route
|
||||
path={Routes.TOPICS.PATH}
|
||||
component={(enableInContext || enableInContextSidebar) ? InContextEmptyTopics : EmptyTopics}
|
||||
/>
|
||||
<Route
|
||||
path={Routes.POSTS.MY_POSTS}
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
|
||||
@@ -125,8 +124,9 @@ export default function DiscussionsHome() {
|
||||
</Switch>
|
||||
)}
|
||||
</div>
|
||||
<DiscussionsProductTour />
|
||||
</main>
|
||||
{!inContext && <Footer />}
|
||||
{!enableInContextSidebar && <Footer />}
|
||||
</DiscussionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import {
|
||||
fireEvent, render, screen, waitFor,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Context as ResponsiveContext } from 'react-responsive';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { getCourseConfigApiUrl } from '../data/api';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import navigationBarMessages from '../navigation/navigation-bar/messages';
|
||||
import DiscussionsHome from './DiscussionsHome';
|
||||
|
||||
const courseConfigApiUrl = getCourseConfigApiUrl();
|
||||
let axiosMock;
|
||||
let store;
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
@@ -40,7 +47,7 @@ describe('DiscussionsHome', () => {
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
@@ -63,7 +70,9 @@ describe('DiscussionsHome', () => {
|
||||
});
|
||||
|
||||
test('in-context view should show close button', async () => {
|
||||
renderComponent(`/${courseId}/topics?inContext`);
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { provider: 'openedx' });
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
renderComponent(`/${courseId}/topics?inContextSidebar`);
|
||||
|
||||
expect(screen.queryByText(navigationBarMessages.allTopics.defaultMessage))
|
||||
.not
|
||||
@@ -73,10 +82,12 @@ describe('DiscussionsHome', () => {
|
||||
});
|
||||
|
||||
test('the close button should post a message', async () => {
|
||||
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { provider: 'openedx' });
|
||||
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
|
||||
const { parent } = window;
|
||||
delete window.parent;
|
||||
window.parent = { ...window, postMessage: jest.fn() };
|
||||
renderComponent(`/${courseId}/topics?inContext`);
|
||||
renderComponent(`/${courseId}/topics?inContextSidebar`);
|
||||
|
||||
const closeButton = screen.queryByRole('button', { name: 'Close' });
|
||||
|
||||
@@ -88,7 +99,7 @@ describe('DiscussionsHome', () => {
|
||||
window.parent = parent;
|
||||
});
|
||||
|
||||
test('header, course navigation bar and footer are visible', async () => {
|
||||
test('header, course navigation bar and footer are only visible in Discussions MFE', async () => {
|
||||
renderComponent();
|
||||
expect(screen.queryByRole('banner')).toBeInTheDocument();
|
||||
expect(document.getElementById('courseTabsNavigation')).toBeInTheDocument();
|
||||
|
||||
@@ -8,7 +8,7 @@ import { initializeStore } from '../../store';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { fetchConfigSuccess } from '../data/slices';
|
||||
import messages from '../messages';
|
||||
import InformationBanner from './InformationsBanner';
|
||||
import InformationBanner from './InformationBanner';
|
||||
|
||||
import '../posts/data/__factories__';
|
||||
|
||||
|
||||
@@ -52,7 +52,11 @@ function EmptyPosts({ intl, subTitleMessage }) {
|
||||
}
|
||||
|
||||
EmptyPosts.propTypes = {
|
||||
subTitleMessage: propTypes.string.isRequired,
|
||||
subTitleMessage: propTypes.shape({
|
||||
id: propTypes.string,
|
||||
defaultMessage: propTypes.string,
|
||||
description: propTypes.string,
|
||||
}).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
|
||||
85
src/discussions/in-context-topics/TopicPostsView.jsx
Normal file
85
src/discussions/in-context-topics/TopicPostsView.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Spinner } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { selectTopicThreads } from '../posts/data/selectors';
|
||||
import PostsList from '../posts/PostsList';
|
||||
import { discussionsPath, handleKeyDown } from '../utils';
|
||||
import {
|
||||
selectArchivedTopic, selectLoadingStatus, selectNonCoursewareTopics,
|
||||
selectSubsection, selectSubsectionUnits, selectUnits,
|
||||
} from './data/selectors';
|
||||
import { BackButton, NoResults } from './components';
|
||||
import messages from './messages';
|
||||
import { Topic } from './topic';
|
||||
|
||||
function TopicPostsView({ intl }) {
|
||||
const location = useLocation();
|
||||
const { courseId, topicId, category } = useContext(DiscussionContext);
|
||||
const topicsLoadingStatus = useSelector(selectLoadingStatus);
|
||||
const posts = useSelector(selectTopicThreads([topicId]));
|
||||
const selectedSubsectionUnits = useSelector(selectSubsectionUnits(category));
|
||||
const selectedSubsection = useSelector(selectSubsection(category));
|
||||
const selectedUnit = useSelector(selectUnits)?.find(unit => unit.id === topicId);
|
||||
const selectedNonCoursewareTopic = useSelector(selectNonCoursewareTopics)?.find(topic => topic.id === topicId);
|
||||
const selectedArchivedTopic = useSelector(selectArchivedTopic(topicId));
|
||||
|
||||
const backButtonPath = () => {
|
||||
const path = selectedUnit ? Routes.TOPICS.CATEGORY : Routes.TOPICS.ALL;
|
||||
const params = selectedUnit ? { courseId, category: selectedUnit?.parentId } : { courseId };
|
||||
return discussionsPath(path, params)(location);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="discussion-posts d-flex flex-column h-100">
|
||||
{topicId ? (
|
||||
<BackButton
|
||||
path={backButtonPath()}
|
||||
title={selectedUnit?.name || selectedNonCoursewareTopic?.name || selectedArchivedTopic?.name
|
||||
|| intl.formatMessage(messages.unnamedTopic)}
|
||||
/>
|
||||
) : (
|
||||
<BackButton
|
||||
path={discussionsPath(Routes.TOPICS.ALL, { courseId })(location)}
|
||||
title={selectedSubsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
|
||||
/>
|
||||
)}
|
||||
<div className="border-bottom border-light-400" />
|
||||
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
|
||||
{topicId ? (
|
||||
<PostsList
|
||||
posts={posts}
|
||||
topics={[topicId]}
|
||||
/>
|
||||
) : (
|
||||
selectedSubsectionUnits?.map((unit) => (
|
||||
<Topic
|
||||
key={unit.id}
|
||||
topic={unit}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{(category && selectedSubsectionUnits.length === 0 && topicsLoadingStatus === RequestStatus.SUCCESSFUL) && (
|
||||
<NoResults />
|
||||
)}
|
||||
{(category && topicsLoadingStatus === RequestStatus.IN_PROGRESS) && (
|
||||
<div className="d-flex justify-content-center p-4">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TopicPostsView.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(TopicPostsView);
|
||||
121
src/discussions/in-context-topics/TopicsView.jsx
Normal file
121
src/discussions/in-context-topics/TopicsView.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { Spinner } from '@edx/paragon';
|
||||
|
||||
import SearchInfo from '../../components/SearchInfo';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { selectAreThreadsFiltered, selectDiscussionProvider } from '../data/selectors';
|
||||
import { clearFilter, clearSort } from '../posts/data/slices';
|
||||
import NoResults from '../posts/NoResults';
|
||||
import { handleKeyDown } from '../utils';
|
||||
import {
|
||||
selectArchivedTopics, selectCoursewareTopics, selectFilteredTopics, selectLoadingStatus,
|
||||
selectNonCoursewareTopics, selectTopicFilter, selectTopics,
|
||||
} from './data/selectors';
|
||||
import { setFilter } from './data/slices';
|
||||
import { fetchCourseTopicsV3 } from './data/thunks';
|
||||
import { ArchivedBaseGroup, SectionBaseGroup, Topic } from './topic';
|
||||
|
||||
function TopicsList() {
|
||||
const loadingStatus = useSelector(selectLoadingStatus);
|
||||
const coursewareTopics = useSelector(selectCoursewareTopics);
|
||||
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
|
||||
const archivedTopics = useSelector(selectArchivedTopics);
|
||||
|
||||
return (
|
||||
<>
|
||||
{nonCoursewareTopics?.map((topic, index) => (
|
||||
<Topic
|
||||
key={topic.id}
|
||||
topic={topic}
|
||||
showDivider={(nonCoursewareTopics.length - 1) !== index}
|
||||
/>
|
||||
))}
|
||||
{coursewareTopics?.map((topic, index) => (
|
||||
<SectionBaseGroup
|
||||
key={topic.id}
|
||||
section={topic?.children}
|
||||
sectionId={topic.id}
|
||||
sectionTitle={topic.displayName}
|
||||
showDivider={(coursewareTopics.length - 1) !== index}
|
||||
/>
|
||||
))}
|
||||
{!isEmpty(archivedTopics) && (
|
||||
<ArchivedBaseGroup
|
||||
archivedTopics={archivedTopics}
|
||||
showDivider={(!isEmpty(nonCoursewareTopics) || !isEmpty(coursewareTopics))}
|
||||
/>
|
||||
)}
|
||||
{loadingStatus === RequestStatus.IN_PROGRESS && (
|
||||
<div className="d-flex justify-content-center p-4">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TopicsView() {
|
||||
const dispatch = useDispatch();
|
||||
const { courseId } = useContext(DiscussionContext);
|
||||
const provider = useSelector(selectDiscussionProvider);
|
||||
const topicFilter = useSelector(selectTopicFilter);
|
||||
const filteredTopics = useSelector(selectFilteredTopics);
|
||||
const loadingStatus = useSelector(selectLoadingStatus);
|
||||
const isPostsFiltered = useSelector(selectAreThreadsFiltered);
|
||||
const topics = useSelector(selectTopics);
|
||||
|
||||
useEffect(() => {
|
||||
if (provider) {
|
||||
dispatch(fetchCourseTopicsV3(courseId));
|
||||
}
|
||||
}, [provider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPostsFiltered) {
|
||||
dispatch(clearFilter());
|
||||
dispatch(clearSort());
|
||||
}
|
||||
}, [isPostsFiltered]);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column h-100" data-testid="inContext-topics-view">
|
||||
{topicFilter && (
|
||||
<>
|
||||
<SearchInfo
|
||||
text={topicFilter}
|
||||
count={filteredTopics.length}
|
||||
loadingStatus={loadingStatus}
|
||||
onClear={() => dispatch(setFilter(''))}
|
||||
/>
|
||||
{filteredTopics.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={classNames('list-group list-group-flush flex-fill', {
|
||||
'justify-content-center': loadingStatus === RequestStatus.IN_PROGRESS && isEmpty(topics),
|
||||
})}
|
||||
role="list"
|
||||
onKeyDown={e => handleKeyDown(e)}
|
||||
>
|
||||
{topicFilter ? (
|
||||
filteredTopics?.map((topic) => (
|
||||
<Topic
|
||||
key={topic.id}
|
||||
topic={topic}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<TopicsList />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopicsView;
|
||||
41
src/discussions/in-context-topics/components/BackButton.jsx
Normal file
41
src/discussions/in-context-topics/components/BackButton.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, IconButton } from '@edx/paragon';
|
||||
import { ArrowBack } from '@edx/paragon/icons';
|
||||
|
||||
import messages from '../messages';
|
||||
|
||||
function BackButton({ intl, path, title }) {
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex py-2.5 px-3 font-weight-bold border-light-400 border-bottom">
|
||||
<IconButton
|
||||
src={ArrowBack}
|
||||
iconAs={Icon}
|
||||
style={{ padding: '18px' }}
|
||||
size="inline"
|
||||
onClick={() => history.push(path)}
|
||||
alt={intl.formatMessage(messages.backAlt)}
|
||||
/>
|
||||
<div className="d-flex flex-fill justify-content-center align-items-center mr-4.5">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-bottom border-light-400" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
BackButton.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
path: PropTypes.shape({}).isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(BackButton);
|
||||
83
src/discussions/in-context-topics/components/EmptyTopics.jsx
Normal file
83
src/discussions/in-context-topics/components/EmptyTopics.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useRouteMatch } from 'react-router';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { ALL_ROUTES } from '../../../data/constants';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { useIsOnDesktop } from '../../data/hooks';
|
||||
import { selectPostThreadCount } from '../../data/selectors';
|
||||
import EmptyPage from '../../empty-posts/EmptyPage';
|
||||
import messages from '../../messages';
|
||||
import { messages as postMessages, showPostEditor } from '../../posts';
|
||||
import { selectCourseWareThreadsCount, selectTotalTopicsThreadsCount } from '../data/selectors';
|
||||
|
||||
function EmptyTopics({ intl }) {
|
||||
const match = useRouteMatch(ALL_ROUTES);
|
||||
const dispatch = useDispatch();
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(match.params.category));
|
||||
const topicThreadsCount = useSelector(selectPostThreadCount);
|
||||
// hasGlobalThreads is used to determine if there are any post available in courseware and non-courseware topics
|
||||
const hasGlobalThreads = useSelector(selectTotalTopicsThreadsCount) > 0;
|
||||
|
||||
function addPost() {
|
||||
return dispatch(showPostEditor());
|
||||
}
|
||||
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
|
||||
let title = messages.emptyTitle;
|
||||
let fullWidth = false;
|
||||
let subTitle;
|
||||
let action;
|
||||
let actionText;
|
||||
|
||||
if (!isOnDesktop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (match.params.topicId) {
|
||||
if (topicThreadsCount > 0) {
|
||||
title = messages.noPostSelected;
|
||||
} else {
|
||||
action = addPost;
|
||||
actionText = postMessages.addAPost;
|
||||
subTitle = messages.emptyTopic;
|
||||
fullWidth = true;
|
||||
}
|
||||
} else if (match.params.category) {
|
||||
if (enableInContextSidebar && topicThreadsCount > 0) {
|
||||
title = messages.noPostSelected;
|
||||
} else if (courseWareThreadsCount > 0) {
|
||||
title = messages.noTopicSelected;
|
||||
} else {
|
||||
action = addPost;
|
||||
actionText = postMessages.addAPost;
|
||||
subTitle = messages.emptyTopic;
|
||||
fullWidth = true;
|
||||
}
|
||||
} else if (hasGlobalThreads) {
|
||||
title = messages.noTopicSelected;
|
||||
} else {
|
||||
fullWidth = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<EmptyPage
|
||||
title={intl.formatMessage(title)}
|
||||
subTitle={subTitle && intl.formatMessage(subTitle)}
|
||||
action={action}
|
||||
actionText={actionText && intl.formatMessage(actionText)}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
EmptyTopics.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(EmptyTopics);
|
||||
29
src/discussions/in-context-topics/components/NoResults.jsx
Normal file
29
src/discussions/in-context-topics/components/NoResults.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { selectTopics } from '../data/selectors';
|
||||
import messages from '../messages';
|
||||
|
||||
function NoResults({ intl }) {
|
||||
const topics = useSelector(selectTopics);
|
||||
|
||||
let title = messages.nothingHere;
|
||||
const helpMessage = '';
|
||||
if (topics.length === 0) {
|
||||
title = messages.noTopicExists;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-100 mt-5 align-self-center mx-auto w-50 d-flex flex-column justify-content-center text-center">
|
||||
<h4 className="font-weight-normal text-primary-500">{intl.formatMessage(title)}</h4>
|
||||
{ helpMessage && <small className="font-weight-normal text-gray-700">{intl.formatMessage(helpMessage)}</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
NoResults.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(NoResults);
|
||||
4
src/discussions/in-context-topics/components/index.js
Normal file
4
src/discussions/in-context-topics/components/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as BackButton } from './BackButton';
|
||||
export { default as EmptyTopic } from './EmptyTopics';
|
||||
export { default as NoResults } from './NoResults';
|
||||
11
src/discussions/in-context-topics/data/api.js
Normal file
11
src/discussions/in-context-topics/data/api.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { getApiBaseUrl } from '../../../data/constants';
|
||||
|
||||
export async function getCourseTopicsV3(courseId) {
|
||||
const url = `${getApiBaseUrl()}/api/discussion/v3/course_topics/${courseId}`;
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
1
src/discussions/in-context-topics/data/index.js
Normal file
1
src/discussions/in-context-topics/data/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './slices';
|
||||
67
src/discussions/in-context-topics/data/selectors.js
Normal file
67
src/discussions/in-context-topics/data/selectors.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
export const selectTopicFilter = state => state.inContextTopics.filter.trim().toLowerCase();
|
||||
|
||||
export const selectTopics = state => state.inContextTopics.topics;
|
||||
|
||||
export const selectCoursewareTopics = state => state.inContextTopics.coursewareTopics;
|
||||
|
||||
export const selectNonCoursewareTopics = state => state.inContextTopics.nonCoursewareTopics;
|
||||
|
||||
export const selectNonCoursewareIds = state => state.inContextTopics.nonCoursewareIds;
|
||||
|
||||
export const selectUnits = state => state.inContextTopics.units;
|
||||
|
||||
export const selectSubsectionUnits = subsectionId => state => state.inContextTopics.units?.filter(
|
||||
unit => unit.parentId === subsectionId,
|
||||
);
|
||||
|
||||
export const selectSubsection = category => createSelector(
|
||||
selectCoursewareTopics,
|
||||
(coursewareTopics) => (
|
||||
coursewareTopics?.map((topic) => topic?.children)?.flat()?.find((topic) => topic.id === category)
|
||||
),
|
||||
);
|
||||
|
||||
export const selectArchivedTopics = state => state.inContextTopics.archivedTopics;
|
||||
|
||||
export const selectArchivedTopic = topic => createSelector(
|
||||
selectArchivedTopics,
|
||||
(archivedTopics) => (
|
||||
archivedTopics?.find((archivedTopic) => archivedTopic.id === topic)
|
||||
),
|
||||
);
|
||||
|
||||
export const selectLoadingStatus = state => state.inContextTopics.status;
|
||||
|
||||
export const selectFilteredTopics = createSelector(
|
||||
selectUnits,
|
||||
selectNonCoursewareTopics,
|
||||
selectTopicFilter,
|
||||
(units, nonCoursewareTopics, filter) => (
|
||||
(units && nonCoursewareTopics && filter) && [...units, ...nonCoursewareTopics]?.filter(
|
||||
topic => topic.name.toLowerCase().includes(filter),
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
export const selectTotalTopicsThreadsCount = createSelector(
|
||||
selectUnits,
|
||||
selectNonCoursewareTopics,
|
||||
(units, nonCoursewareTopics) => (
|
||||
(units && nonCoursewareTopics) && [...units, ...nonCoursewareTopics]?.reduce((total, topic) => (
|
||||
total + topic.threadCounts.discussion + topic.threadCounts.question
|
||||
), 0)
|
||||
),
|
||||
);
|
||||
|
||||
export const selectCourseWareThreadsCount = category => createSelector(
|
||||
selectSubsectionUnits(category),
|
||||
(units) => (
|
||||
units?.reduce((total, unit) => (
|
||||
total + unit.threadCounts.discussion + unit.threadCounts.question
|
||||
), 0)
|
||||
),
|
||||
);
|
||||
51
src/discussions/in-context-topics/data/slices.js
Normal file
51
src/discussions/in-context-topics/data/slices.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/* eslint-disable no-param-reassign,import/prefer-default-export */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
|
||||
const topicsSlice = createSlice({
|
||||
name: 'inContextTopics',
|
||||
initialState: {
|
||||
status: RequestStatus.IN_PROGRESS,
|
||||
topics: [],
|
||||
coursewareTopics: [],
|
||||
nonCoursewareTopics: [],
|
||||
nonCoursewareIds: [],
|
||||
units: [],
|
||||
archivedTopics: [],
|
||||
filter: '',
|
||||
},
|
||||
reducers: {
|
||||
fetchCourseTopicsRequest: (state) => {
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchCourseTopicsSuccess: (state, { payload }) => {
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
state.topics = payload.topics;
|
||||
state.coursewareTopics = payload.coursewareTopics;
|
||||
state.nonCoursewareTopics = payload.nonCoursewareTopics;
|
||||
state.nonCoursewareIds = payload.nonCoursewareIds;
|
||||
state.units = payload.units;
|
||||
state.archivedTopics = payload.archivedTopics;
|
||||
},
|
||||
fetchCourseTopicsFailed: (state) => {
|
||||
state.status = RequestStatus.FAILED;
|
||||
},
|
||||
fetchCourseTopicsDenied: (state) => {
|
||||
state.status = RequestStatus.DENIED;
|
||||
},
|
||||
setFilter: (state, { payload }) => {
|
||||
state.filter = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
fetchCourseTopicsRequest,
|
||||
fetchCourseTopicsSuccess,
|
||||
fetchCourseTopicsFailed,
|
||||
setFilter,
|
||||
setSortBy,
|
||||
} = topicsSlice.actions;
|
||||
|
||||
export const inContextTopicsReducer = topicsSlice.reducer;
|
||||
64
src/discussions/in-context-topics/data/thunks.js
Normal file
64
src/discussions/in-context-topics/data/thunks.js
Normal file
@@ -0,0 +1,64 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { reduce } from 'lodash';
|
||||
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { getCourseTopicsV3 } from './api';
|
||||
import { fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess } from './slices';
|
||||
|
||||
function normalizeTopicsV3(topics) {
|
||||
const coursewareUnits = reduce(topics, (arrayOfUnits, chapter) => {
|
||||
if (chapter?.children) {
|
||||
return [
|
||||
...arrayOfUnits,
|
||||
...reduce(chapter.children, (units, sequential) => {
|
||||
if (sequential?.children) {
|
||||
return [
|
||||
...units,
|
||||
...sequential.children.map((unit) => ({
|
||||
...unit,
|
||||
parentId: sequential.id,
|
||||
parentTitle: sequential.displayName,
|
||||
})),
|
||||
];
|
||||
}
|
||||
return units;
|
||||
}, []),
|
||||
];
|
||||
}
|
||||
return arrayOfUnits;
|
||||
}, []);
|
||||
|
||||
const archivedTopics = reduce(topics, (arrayOfArchivedTopics, topic) => {
|
||||
if (topic.id === 'archived') {
|
||||
return topic.children;
|
||||
}
|
||||
return arrayOfArchivedTopics;
|
||||
}, []);
|
||||
|
||||
const coursewareTopics = topics.filter((topic) => topic.courseware);
|
||||
const nonCoursewareTopics = topics.filter((topic) => !topic.courseware && topic.enabledInContext);
|
||||
const nonCoursewareIds = nonCoursewareTopics?.map((topic) => topic.id);
|
||||
|
||||
return {
|
||||
topics,
|
||||
units: coursewareUnits,
|
||||
coursewareTopics,
|
||||
nonCoursewareTopics,
|
||||
nonCoursewareIds,
|
||||
archivedTopics,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseTopicsV3(courseId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchCourseTopicsRequest({ courseId }));
|
||||
const data = await getCourseTopicsV3(courseId);
|
||||
dispatch(fetchCourseTopicsSuccess(normalizeTopicsV3(data)));
|
||||
} catch (error) {
|
||||
dispatch(fetchCourseTopicsFailed());
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
3
src/discussions/in-context-topics/index.js
Normal file
3
src/discussions/in-context-topics/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as TopicPostsView } from './TopicPostsView';
|
||||
export { default as TopicsView } from './TopicsView';
|
||||
79
src/discussions/in-context-topics/messages.js
Normal file
79
src/discussions/in-context-topics/messages.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
backAlt: {
|
||||
id: 'discussions.topics.backAlt',
|
||||
defaultMessage: 'Back to topics list',
|
||||
description: 'Display back button text used to navigate back to topics list',
|
||||
},
|
||||
discussions: {
|
||||
id: 'discussions.topics.discussions',
|
||||
defaultMessage: `{count, plural,
|
||||
=0 {Discussion}
|
||||
one {# Discussion}
|
||||
other {# Discussions}
|
||||
}`,
|
||||
description: 'Display tooltip text used to indicate how many posts type are discussion',
|
||||
},
|
||||
questions: {
|
||||
id: 'discussions.topics.questions',
|
||||
defaultMessage: `{count, plural,
|
||||
=0 {Question}
|
||||
one {# Question}
|
||||
other {# Questions}
|
||||
}`,
|
||||
description: 'Display tooltip text used to indicate how many posts type are questions',
|
||||
},
|
||||
reported: {
|
||||
id: 'discussions.topics.reported',
|
||||
defaultMessage: '{reported} reported',
|
||||
description: 'Display tooltip text used to indicate how many posts are reported',
|
||||
},
|
||||
previouslyReported: {
|
||||
id: 'discussions.topics.previouslyReported',
|
||||
defaultMessage: '{previouslyReported} previously reported',
|
||||
description: 'Display tooltip text used to indicate how many posts are previously reported',
|
||||
},
|
||||
searchTopics: {
|
||||
id: 'discussions.topics.find.label',
|
||||
defaultMessage: 'Search topics',
|
||||
description: 'Placeholder text in search bar',
|
||||
},
|
||||
unnamedSection: {
|
||||
id: 'discussions.topics.unnamed.section.label',
|
||||
defaultMessage: 'Unnamed Section',
|
||||
description: 'Text to display in place of section name if section name is empty',
|
||||
},
|
||||
unnamedSubsection: {
|
||||
id: 'discussions.topics.unnamed.subsection.label',
|
||||
defaultMessage: 'Unnamed Subsection',
|
||||
description: 'Text to display in place of subsection name if subsection name is empty',
|
||||
},
|
||||
unnamedTopic: {
|
||||
id: 'discussions.subtopics.unnamed.topic.label',
|
||||
defaultMessage: 'Unnamed Topic',
|
||||
description: 'Text to display in place of topic name if topic name is empty',
|
||||
},
|
||||
noTopicExists: {
|
||||
id: 'discussions.topics.title',
|
||||
defaultMessage: 'No topic exists',
|
||||
description: 'Text to display in place of topic list if topic does not exist',
|
||||
},
|
||||
createTopic: {
|
||||
id: 'discussions.topics.createTopic',
|
||||
defaultMessage: 'Please contact you admin to create a topic',
|
||||
description: 'Helping Text to display in place of topic list if topic does not exist',
|
||||
},
|
||||
nothingHere: {
|
||||
id: 'discussions.topics.nothing',
|
||||
defaultMessage: 'Nothing here yet',
|
||||
description: 'Helping Text to display if nothing here yet',
|
||||
},
|
||||
archivedTopics: {
|
||||
id: 'discussions.topics.archived.label',
|
||||
defaultMessage: 'Archived',
|
||||
description: 'Heading for displaying topics that are archived.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, SearchField } from '@edx/paragon';
|
||||
import { Search as SearchIcon } from '@edx/paragon/icons';
|
||||
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import postsMessages from '../../posts/post-actions-bar/messages';
|
||||
import { setFilter as setTopicFilter } from '../data/slices';
|
||||
|
||||
function TopicSearchBar({ intl }) {
|
||||
const dispatch = useDispatch();
|
||||
const { page } = useContext(DiscussionContext);
|
||||
const topicSearch = useSelector(({ inContextTopics }) => inContextTopics.filter);
|
||||
let searchValue = '';
|
||||
|
||||
const onClear = () => {
|
||||
dispatch(setTopicFilter(''));
|
||||
};
|
||||
|
||||
const onChange = (query) => {
|
||||
searchValue = query;
|
||||
};
|
||||
|
||||
const onSubmit = (query) => {
|
||||
if (query === '') {
|
||||
return;
|
||||
}
|
||||
dispatch(setTopicFilter(query));
|
||||
};
|
||||
|
||||
useEffect(() => onClear(), [page]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchField.Advanced
|
||||
onClear={onClear}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
value={topicSearch}
|
||||
>
|
||||
<SearchField.Label />
|
||||
<SearchField.Input
|
||||
style={{ paddingRight: '1rem' }}
|
||||
placeholder={intl.formatMessage(postsMessages.search, { page: 'topics' })}
|
||||
/>
|
||||
<span className="mt-auto mb-auto mr-2.5 pointer-cursor-hover">
|
||||
<Icon
|
||||
src={SearchIcon}
|
||||
onClick={() => onSubmit(searchValue)}
|
||||
/>
|
||||
</span>
|
||||
</SearchField.Advanced>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TopicSearchBar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(TopicSearchBar);
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { SearchField } from '@edx/paragon';
|
||||
|
||||
import { setFilter } from '../data';
|
||||
import messages from '../messages';
|
||||
|
||||
function TopicSearchResultBar({ intl }) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-row p-1 align-items-center">
|
||||
<SearchField
|
||||
className="flex-fill m-1 border-0"
|
||||
placeholder={intl.formatMessage(messages.searchTopics)}
|
||||
onSubmit={(query) => dispatch(setFilter(query))}
|
||||
onChange={(query) => dispatch(setFilter(query))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TopicSearchResultBar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(TopicSearchResultBar);
|
||||
3
src/discussions/in-context-topics/topic-search/index.js
Normal file
3
src/discussions/in-context-topics/topic-search/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as TopicSearchBar } from './TopicSearchBar';
|
||||
export { default as TopicSearchResultBar } from './TopicSearchResultBar';
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
import Topic, { topicShape } from './Topic';
|
||||
|
||||
function ArchivedBaseGroup({
|
||||
archivedTopics,
|
||||
showDivider,
|
||||
intl,
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{showDivider && (
|
||||
<>
|
||||
<div className="divider border-top border-light-500" />
|
||||
<div className="divider pt-1 bg-light-300" />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className="discussion-topic-group d-flex flex-column text-primary-500"
|
||||
data-testid="archived-group"
|
||||
>
|
||||
<div className="pt-3 px-4 font-weight-bold">{intl.formatMessage(messages.archivedTopics)}</div>
|
||||
{archivedTopics?.map((topic, index) => (
|
||||
<Topic
|
||||
key={topic.id}
|
||||
topic={topic}
|
||||
showDivider={(archivedTopics.length - 1) !== index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ArchivedBaseGroup.propTypes = {
|
||||
archivedTopics: PropTypes.arrayOf(topicShape).isRequired,
|
||||
showDivider: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
ArchivedBaseGroup.defaultProps = {
|
||||
showDivider: false,
|
||||
};
|
||||
export default injectIntl(ArchivedBaseGroup);
|
||||
90
src/discussions/in-context-topics/topic/SectionBaseGroup.jsx
Normal file
90
src/discussions/in-context-topics/topic/SectionBaseGroup.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { Routes } from '../../../data/constants';
|
||||
import { discussionsPath } from '../../utils';
|
||||
import messages from '../messages';
|
||||
import { topicShape } from './Topic';
|
||||
|
||||
function SectionBaseGroup({
|
||||
section,
|
||||
sectionTitle,
|
||||
sectionId,
|
||||
showDivider,
|
||||
intl,
|
||||
}) {
|
||||
const { courseId } = useParams();
|
||||
const isSelected = (id) => window.location.pathname.includes(id);
|
||||
const sectionUrl = (id) => discussionsPath(Routes.TOPICS.CATEGORY, {
|
||||
courseId,
|
||||
category: id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="discussion-topic-group d-flex flex-column text-primary-500"
|
||||
data-section-id={sectionId}
|
||||
data-testid="section-group"
|
||||
>
|
||||
<div className="pt-3 px-4 font-weight-bold">
|
||||
{sectionTitle || intl.formatMessage(messages.unnamedSection)}
|
||||
</div>
|
||||
{section.map((subsection, index) => (
|
||||
<Link
|
||||
className={classNames('subsection p-0 text-decoration-none text-primary-500', {
|
||||
'border-bottom border-light-400': (section.length - 1 !== index),
|
||||
})}
|
||||
key={subsection.id}
|
||||
role="option"
|
||||
data-subsection-id={subsection.id}
|
||||
data-testid="subsection-group"
|
||||
to={sectionUrl(subsection.id)}
|
||||
onClick={() => isSelected(subsection.id)}
|
||||
aria-current={isSelected(section.id) ? 'page' : undefined}
|
||||
tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1}
|
||||
>
|
||||
<div className="d-flex flex-row py-3.5 px-4">
|
||||
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
|
||||
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
|
||||
<div className="topic-name text-truncate">
|
||||
{subsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{showDivider && (
|
||||
<>
|
||||
<div className="divider border-top border-light-500" />
|
||||
<div className="divider pt-1 bg-light-300" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SectionBaseGroup.propTypes = {
|
||||
section: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
blockId: PropTypes.string,
|
||||
lmsWebUrl: PropTypes.string,
|
||||
legacyWebUrl: PropTypes.string,
|
||||
studentViewUrl: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
displayName: PropTypes.string,
|
||||
children: PropTypes.arrayOf(topicShape),
|
||||
})).isRequired,
|
||||
sectionTitle: PropTypes.string.isRequired,
|
||||
sectionId: PropTypes.string.isRequired,
|
||||
showDivider: PropTypes.bool.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SectionBaseGroup);
|
||||
155
src/discussions/in-context-topics/topic/Topic.jsx
Normal file
155
src/discussions/in-context-topics/topic/Topic.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/* eslint-disable no-unused-vars, react/forbid-prop-types */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
|
||||
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
|
||||
|
||||
import { Routes } from '../../../data/constants';
|
||||
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
|
||||
import { discussionsPath } from '../../utils';
|
||||
import messages from '../messages';
|
||||
|
||||
function Topic({
|
||||
topic,
|
||||
showDivider,
|
||||
index,
|
||||
intl,
|
||||
}) {
|
||||
const { courseId } = useParams();
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const { inactiveFlags, activeFlags } = topic;
|
||||
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
|
||||
const isSelected = (id) => window.location.pathname.includes(id);
|
||||
const topicUrl = discussionsPath(Routes.TOPICS.TOPIC, {
|
||||
courseId,
|
||||
topicId: topic.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
className={classNames('discussion-topic p-0 text-decoration-none text-primary-500', {
|
||||
'border-light-400 border-bottom': showDivider,
|
||||
})}
|
||||
data-topic-id={topic.id}
|
||||
to={topicUrl}
|
||||
onClick={() => isSelected(topic.id)}
|
||||
aria-current={isSelected(topic.id) ? 'page' : undefined}
|
||||
role="option"
|
||||
tabIndex={(isSelected(topic.id) || index === 0) ? 0 : -1}
|
||||
>
|
||||
<div className="d-flex flex-row pt-2.5 pb-2 px-4">
|
||||
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
|
||||
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
|
||||
<div className="topic-name text-truncate">
|
||||
{topic?.name || topic?.displayName || intl.formatMessage(messages.unnamedTopicSubCategories)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{intl.formatMessage(messages.discussions, {
|
||||
count: topic.threadCounts?.discussion || 0,
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center mr-3.5">
|
||||
<Icon src={PostOutline} className="icon-size mr-2" />
|
||||
{topic.threadCounts?.discussion || 0}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{intl.formatMessage(messages.questions, {
|
||||
count: topic.threadCounts?.question || 0,
|
||||
})}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center mr-3.5">
|
||||
<Icon src={HelpOutline} className="icon-size mr-2" />
|
||||
{topic.threadCounts?.question || 0}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
{Boolean(canSeeReportedStats) && (
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip>
|
||||
<div className="d-flex flex-column align-items-start">
|
||||
{Boolean(activeFlags) && (
|
||||
<span>
|
||||
{intl.formatMessage(messages.reported, { reported: activeFlags })}
|
||||
</span>
|
||||
)}
|
||||
{Boolean(inactiveFlags) && (
|
||||
<span>
|
||||
{intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<Icon src={Report} className="icon-size mr-2 text-danger" />
|
||||
{activeFlags}{Boolean(inactiveFlags) && `/${inactiveFlags}`}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{!showDivider && (
|
||||
<>
|
||||
<div className="divider border-top border-light-500" />
|
||||
<div className="divider pt-1 bg-light-300" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const topicShape = PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
usage_key: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
thread_counts: PropTypes.shape({
|
||||
discussions: PropTypes.number,
|
||||
questions: PropTypes.number,
|
||||
}),
|
||||
enabled_in_context: PropTypes.bool,
|
||||
flags: PropTypes.number,
|
||||
});
|
||||
|
||||
Topic.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
topic: topicShape,
|
||||
showDivider: PropTypes.bool,
|
||||
index: PropTypes.number,
|
||||
};
|
||||
|
||||
Topic.defaultProps = {
|
||||
showDivider: true,
|
||||
index: -1,
|
||||
topic: {
|
||||
usage_key: '',
|
||||
},
|
||||
};
|
||||
|
||||
export default injectIntl(Topic);
|
||||
4
src/discussions/in-context-topics/topic/index.js
Normal file
4
src/discussions/in-context-topics/topic/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as ArchivedBaseGroup } from './ArchivedBaseGroup';
|
||||
export { default as SectionBaseGroup } from './SectionBaseGroup';
|
||||
export { default as Topic } from './Topic';
|
||||
@@ -4,6 +4,8 @@ import { isEmpty } from 'lodash';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
import FilterBar from '../../../components/FilterBar';
|
||||
import { selectCourseCohorts } from '../../cohorts/data/selectors';
|
||||
import { fetchCourseCohorts } from '../../cohorts/data/thunks';
|
||||
@@ -39,12 +41,20 @@ function LearnerPostFilterBar() {
|
||||
|
||||
const handleFilterChange = (event) => {
|
||||
const { name, value } = event.currentTarget;
|
||||
const filterContentEventProperties = {
|
||||
statusFilter: postFilter.status,
|
||||
threadTypeFilter: postFilter.postType,
|
||||
sortFilter: postFilter.orderBy,
|
||||
cohortFilter: postFilter.cohort,
|
||||
triggeredBy: name,
|
||||
};
|
||||
if (name === 'postType') {
|
||||
if (postFilter.postType !== value) {
|
||||
dispatch(setPostFilter({
|
||||
...postFilter,
|
||||
postType: value,
|
||||
}));
|
||||
filterContentEventProperties.threadTypeFilter = value;
|
||||
}
|
||||
} else if (name === 'status') {
|
||||
if (postFilter.status !== value) {
|
||||
@@ -52,6 +62,7 @@ function LearnerPostFilterBar() {
|
||||
...postFilter,
|
||||
status: value,
|
||||
}));
|
||||
filterContentEventProperties.statusFilter = value;
|
||||
}
|
||||
} else if (name === 'orderBy') {
|
||||
if (postFilter.orderBy !== value) {
|
||||
@@ -59,6 +70,7 @@ function LearnerPostFilterBar() {
|
||||
...postFilter,
|
||||
orderBy: value,
|
||||
}));
|
||||
filterContentEventProperties.sortFilter = value;
|
||||
}
|
||||
} else if (name === 'cohort') {
|
||||
if (postFilter.cohort !== value) {
|
||||
@@ -66,8 +78,10 @@ function LearnerPostFilterBar() {
|
||||
...postFilter,
|
||||
cohort: value,
|
||||
}));
|
||||
filterContentEventProperties.cohortFilter = value;
|
||||
}
|
||||
}
|
||||
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -16,9 +16,9 @@ function LearnerCard({
|
||||
learner,
|
||||
courseId,
|
||||
}) {
|
||||
const { inContext, learnerUsername } = useContext(DiscussionContext);
|
||||
const { enableInContextSidebar, learnerUsername } = useContext(DiscussionContext);
|
||||
const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, {
|
||||
0: inContext ? 'in-context' : undefined,
|
||||
0: enableInContextSidebar ? 'in-context' : undefined,
|
||||
learnerUsername: learner.username,
|
||||
courseId,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Collapsible, Form, Icon } from '@edx/paragon';
|
||||
import { Check, Tune } from '@edx/paragon/icons';
|
||||
@@ -58,6 +59,12 @@ function LearnerFilterBar({
|
||||
|
||||
if (name === 'sort') {
|
||||
dispatch(setSortedBy(value));
|
||||
sendTrackEvent(
|
||||
'edx.forum.sort.user',
|
||||
{
|
||||
sort: value,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Action to delete a post or comment',
|
||||
},
|
||||
confirmationConfirm: {
|
||||
id: 'discussions.confirmation.button.confirm',
|
||||
defaultMessage: 'Confirm',
|
||||
description: 'Confirm button shown on confirmation dialog',
|
||||
},
|
||||
closeAction: {
|
||||
id: 'discussions.actions.close',
|
||||
defaultMessage: 'Close',
|
||||
@@ -71,16 +76,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Unmark as answered',
|
||||
description: 'Action to unmark a comment as answering a post',
|
||||
},
|
||||
deleteConfirmationCancel: {
|
||||
id: 'discussions.delete.confirmation.button.cancel',
|
||||
confirmationCancel: {
|
||||
id: 'discussions.modal.confirmation.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Cancel button shown on delete confirmation dialog',
|
||||
},
|
||||
deleteConfirmationDelete: {
|
||||
id: 'discussions.delete.confirmation.button.delete',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Delete button shown on delete confirmation dialog',
|
||||
},
|
||||
emptyAllTopics: {
|
||||
id: 'discussions.empty.allTopics',
|
||||
defaultMessage:
|
||||
@@ -185,8 +185,8 @@ const messages = defineMessages({
|
||||
},
|
||||
blackoutDiscussionInformation: {
|
||||
id: 'discussion.blackoutBanner.information',
|
||||
defaultMessage: 'Blackout dates are currently active. Posting in discussions is unavailable at this time.',
|
||||
description: 'Informative text when discussions blackout is active',
|
||||
defaultMessage: 'Posting in discussions is temporarily disabled by the course team',
|
||||
description: 'Informative text when discussion posting is disabled',
|
||||
},
|
||||
imageWarningMessage: {
|
||||
id: 'discussions.editor.image.warning.message',
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { Routes } from '../../../data/constants';
|
||||
import { selectBlocks, selectChapters } from '../../../data/selectors';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { selectTopic } from '../../topics/data/selectors';
|
||||
import { discussionsPath } from '../../utils';
|
||||
import BreadcrumbDropdown from './BreadcrumbDropdown';
|
||||
|
||||
function BreadcrumbMenu() {
|
||||
const {
|
||||
courseId,
|
||||
topicId,
|
||||
category,
|
||||
} = useContext(DiscussionContext);
|
||||
const blocks = useSelector(selectBlocks);
|
||||
const chapters = useSelector(selectChapters);
|
||||
const blockKey = useSelector(selectTopic(topicId))?.usageKey || category;
|
||||
|
||||
let currentChapter = null;
|
||||
let currentVertical = null;
|
||||
let currentSequential = null;
|
||||
if (!blocks[blockKey]) {
|
||||
// Data is still loading
|
||||
return null;
|
||||
}
|
||||
if (blocks[blockKey].type === 'chapter') {
|
||||
currentChapter = blockKey;
|
||||
} else if (blocks[blockKey].type === 'sequential') {
|
||||
currentSequential = blockKey;
|
||||
currentChapter = blocks[currentSequential].parent;
|
||||
} else if (blocks[blockKey].type === 'vertical') {
|
||||
currentVertical = blockKey;
|
||||
currentSequential = blocks[currentVertical].parent;
|
||||
currentChapter = blocks[currentSequential].parent;
|
||||
}
|
||||
|
||||
const getItemDisplayName = itemId => blocks[itemId]?.displayName;
|
||||
const getItemPath = itemId => discussionsPath(Routes.TOPICS.CATEGORY, {
|
||||
courseId,
|
||||
category: itemId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="breadcrumb-menu d-flex flex-row bg-light-200 box-shadow-down-1 px-2.5 py-1">
|
||||
<BreadcrumbDropdown
|
||||
currentItem={currentChapter}
|
||||
showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })}
|
||||
items={chapters}
|
||||
itemPathFunc={getItemPath}
|
||||
itemActiveFunc={item => item === currentChapter}
|
||||
itemLabelFunc={getItemDisplayName}
|
||||
/>
|
||||
{currentChapter
|
||||
&& (
|
||||
<>
|
||||
<div className="d-flex py-2">/</div>
|
||||
<BreadcrumbDropdown
|
||||
currentItem={currentSequential}
|
||||
showAllPath={getItemPath(currentChapter)}
|
||||
items={blocks[currentChapter].children}
|
||||
itemPathFunc={getItemPath}
|
||||
itemActiveFunc={seqId => seqId === currentChapter}
|
||||
itemLabelFunc={getItemDisplayName}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{currentSequential
|
||||
&& (
|
||||
<>
|
||||
<div className="d-flex py-2">/</div>
|
||||
<BreadcrumbDropdown
|
||||
currentItem={currentVertical}
|
||||
showAllPath={getItemPath(currentSequential)}
|
||||
items={blocks[currentSequential].children}
|
||||
itemPathFunc={getItemPath}
|
||||
itemActiveFunc={vertId => vertId === currentChapter}
|
||||
itemLabelFunc={getItemDisplayName}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BreadcrumbMenu.propTypes = {};
|
||||
|
||||
export default BreadcrumbMenu;
|
||||
@@ -1,151 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
act, fireEvent, render, screen, waitFor,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { getBlocksAPIResponse } from '../../../data/__factories__';
|
||||
import { getBlocksAPIURL } from '../../../data/api';
|
||||
import { getApiBaseUrl, Routes } from '../../../data/constants';
|
||||
import { fetchCourseBlocks } from '../../../data/thunks';
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { fetchCourseTopics } from '../../topics/data/thunks';
|
||||
import { BreadcrumbMenu } from '../index';
|
||||
|
||||
import '../../topics/data/__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v2/course_topics/${courseId}`;
|
||||
let store;
|
||||
let axiosMock;
|
||||
|
||||
function renderComponent(path, topicId = null, category = null) {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<DiscussionContext.Provider
|
||||
value={{
|
||||
courseId,
|
||||
topicId,
|
||||
category,
|
||||
}}
|
||||
>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Route
|
||||
path={[
|
||||
Routes.POSTS.PATH,
|
||||
Routes.TOPICS.CATEGORY,
|
||||
]}
|
||||
component={BreadcrumbMenu}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('BreadcrumbMenu', () => {
|
||||
let blocksAPIResponse;
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore({
|
||||
config: {
|
||||
provider: 'openedx',
|
||||
},
|
||||
blocks: {
|
||||
topics: {},
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
blocksAPIResponse = getBlocksAPIResponse();
|
||||
axiosMock.onGet(getBlocksAPIURL())
|
||||
.reply(200, blocksAPIResponse);
|
||||
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
|
||||
const data = [
|
||||
...Factory.buildList('topic.v2', 3, { usage_key: null }, { topicPrefix: 'ncw' }),
|
||||
Factory.build('topic.v2', { id: 'vertical_0270f6de40fc' }),
|
||||
Factory.build('topic.v2', { id: '867dddb6f55d410caaa9c1eb9c6743ec' }),
|
||||
Factory.build('topic.v2', { id: '4f6c1b4e316a419ab5b6bf30e6c708e9' }),
|
||||
];
|
||||
axiosMock
|
||||
.onGet(topicsApiUrl)
|
||||
.reply(200, data);
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
});
|
||||
|
||||
it('shows the category dropdown with a category selected', async () => {
|
||||
const chapterKey = 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b';
|
||||
const sectionKey = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
|
||||
|
||||
renderComponent(`/${courseId}/category/${chapterKey}`, null, chapterKey);
|
||||
|
||||
await waitFor(() => screen.findByText(blocksAPIResponse.blocks[chapterKey].display_name));
|
||||
|
||||
const chapterDropdown = screen.queryByText(blocksAPIResponse.blocks[chapterKey].display_name);
|
||||
// Since a category is selected a subcategory dropdown should also be visible with "show all" selected by default
|
||||
const sectionDropdown = screen.queryByRole('button', { name: 'Show all' });
|
||||
// A show all button should show up that lists topics in the current category
|
||||
expect(sectionDropdown)
|
||||
.toBeInTheDocument();
|
||||
// Other categories should not be visible.
|
||||
expect(screen.queryByText(blocksAPIResponse.blocks[sectionKey].display_name))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
|
||||
// Click on the category dropdown.
|
||||
act(() => {
|
||||
fireEvent.click(chapterDropdown);
|
||||
});
|
||||
// Now other categories should be visible in the dropdown.
|
||||
expect(screen.queryByText(blocksAPIResponse.blocks[chapterKey].display_name))
|
||||
.toBeInTheDocument();
|
||||
// There are 4 categories but this has a length of 5 since there is also a link to show all.
|
||||
expect(screen.queryAllByRole('link', { exact: false }))
|
||||
.toHaveLength(5);
|
||||
|
||||
// Now click on the topics dropdown
|
||||
act(() => {
|
||||
fireEvent.click(sectionDropdown);
|
||||
});
|
||||
|
||||
// Topics in the category should be visible.
|
||||
expect(screen.queryByRole('link', { name: 'Demo Course Overview' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the category correct dropdown labels with a topic selected', async () => {
|
||||
const topicId = 'vertical_0270f6de40fc';
|
||||
renderComponent(`/${courseId}/topics/${topicId}`, topicId);
|
||||
// Since a topic is selected, we have both a category and topic, so "show all shouldn't be visible"
|
||||
expect(screen.queryByText('Show all'))
|
||||
.not
|
||||
.toBeInTheDocument();
|
||||
// The name of the category and topic should be visible.
|
||||
expect(await screen.findByRole('button', { name: 'Introduction' }))
|
||||
.toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Demo Course Overview' }))
|
||||
.toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Introduction: Video and Sequences' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as BreadcrumbMenu } from './breadcrumb-menu/BreadcrumbMenu';
|
||||
export { default as LegacyBreadcrumbMenu } from './breadcrumb-menu/LegacyBreadcrumbMenu';
|
||||
export { default as NavigationBar } from './navigation-bar/NavigationBar';
|
||||
|
||||
@@ -4,21 +4,24 @@ import { useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { selectAreThreadsFiltered } from '../data/selectors';
|
||||
import { selectTopicFilter } from '../in-context-topics/data/selectors';
|
||||
import messages from '../messages';
|
||||
|
||||
function NoResults({ intl }) {
|
||||
const postsFiltered = useSelector(selectAreThreadsFiltered);
|
||||
const inContextTopicsFilter = useSelector(selectTopicFilter);
|
||||
const topicsFilter = useSelector(({ topics }) => topics.filter);
|
||||
const filters = useSelector((state) => state.threads.filters);
|
||||
const learnersFilter = useSelector(({ learners }) => learners.usernameSearch);
|
||||
const isFiltered = postsFiltered || (topicsFilter !== '') || (learnersFilter !== null);
|
||||
const isFiltered = postsFiltered || (topicsFilter !== '')
|
||||
|| (learnersFilter !== null) || (inContextTopicsFilter !== '');
|
||||
|
||||
let helpMessage = messages.removeFilters;
|
||||
if (!isFiltered) {
|
||||
return null;
|
||||
} if (filters.search || learnersFilter) {
|
||||
helpMessage = messages.removeKeywords;
|
||||
} if (topicsFilter) {
|
||||
} if (topicsFilter || inContextTopicsFilter) {
|
||||
helpMessage = messages.removeKeywordsOnly;
|
||||
}
|
||||
const titleCssClasses = classNames(
|
||||
|
||||
@@ -22,7 +22,9 @@ import { fetchThreads } from './data/thunks';
|
||||
import NoResults from './NoResults';
|
||||
import { PostLink } from './post';
|
||||
|
||||
function PostsList({ posts, topics, intl }) {
|
||||
function PostsList({
|
||||
posts, topics, intl, isTopicTab,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
courseId,
|
||||
@@ -38,7 +40,7 @@ function PostsList({ posts, topics, intl }) {
|
||||
const userIsStaff = useSelector(selectUserIsStaff);
|
||||
const configStatus = useSelector(selectconfigLoadingStatus);
|
||||
|
||||
const loadThreads = (topicIds, pageNum = undefined) => {
|
||||
const loadThreads = (topicIds, pageNum = undefined, isFilterChanged = false) => {
|
||||
const params = {
|
||||
orderBy,
|
||||
filters,
|
||||
@@ -46,6 +48,7 @@ function PostsList({ posts, topics, intl }) {
|
||||
author: showOwnPosts ? authenticatedUser.username : null,
|
||||
countFlagged: (userHasModerationPrivileges || userIsStaff) || undefined,
|
||||
topicIds,
|
||||
isFilterChanged,
|
||||
};
|
||||
|
||||
if (showOwnPosts) {
|
||||
@@ -59,7 +62,11 @@ function PostsList({ posts, topics, intl }) {
|
||||
if (topics !== undefined && configStatus === RequestStatus.SUCCESSFUL) {
|
||||
loadThreads(topics);
|
||||
}
|
||||
}, [courseId, orderBy, filters, page, JSON.stringify(topics), configStatus]);
|
||||
}, [courseId, filters, orderBy, page, JSON.stringify(topics), configStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTopicTab) { loadThreads(topics, 1, true); }
|
||||
}, [filters]);
|
||||
|
||||
const checkIsSelected = (id) => window.location.pathname.includes(id);
|
||||
const pinnedPosts = useMemo(() => filterPosts(posts, 'pinned'), [posts]);
|
||||
@@ -83,7 +90,7 @@ function PostsList({ posts, topics, intl }) {
|
||||
{postInstances(unpinnedPosts)}
|
||||
{posts?.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
|
||||
{loadingStatus === RequestStatus.IN_PROGRESS ? (
|
||||
<div className="d-flex justify-content-center p-4">
|
||||
<div className="d-flex justify-content-center p-4 mx-auto my-auto">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
@@ -103,12 +110,14 @@ PostsList.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
})),
|
||||
topics: PropTypes.arrayOf(PropTypes.string),
|
||||
isTopicTab: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
PostsList.defaultProps = {
|
||||
posts: [],
|
||||
topics: undefined,
|
||||
isTopicTab: false,
|
||||
};
|
||||
|
||||
export default injectIntl(PostsList);
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import SearchInfo from '../../components/SearchInfo';
|
||||
import { selectCurrentCategoryGrouping, selectTopicsUnderCategory } from '../../data/selectors';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { selectEnableInContext } from '../data/selectors';
|
||||
import { selectTopics as selectInContextTopics } from '../in-context-topics/data/selectors';
|
||||
import { fetchCourseTopicsV3 } from '../in-context-topics/data/thunks';
|
||||
import { selectTopics } from '../topics/data/selectors';
|
||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||
import { handleKeyDown } from '../utils';
|
||||
import {
|
||||
selectAllThreads,
|
||||
selectTopicThreads,
|
||||
@@ -21,7 +28,7 @@ function AllPostsList() {
|
||||
|
||||
function TopicPostsList({ topicId }) {
|
||||
const posts = useSelector(selectTopicThreads([topicId]));
|
||||
return <PostsList posts={posts} topics={[topicId]} />;
|
||||
return <PostsList posts={posts} topics={[topicId]} isTopicTab />;
|
||||
}
|
||||
|
||||
TopicPostsList.propTypes = {
|
||||
@@ -29,10 +36,10 @@ TopicPostsList.propTypes = {
|
||||
};
|
||||
|
||||
function CategoryPostsList({ category }) {
|
||||
const { inContext } = useContext(DiscussionContext);
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
|
||||
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE.
|
||||
const topicIds = useSelector(selectTopicsUnderCategory)(inContext ? groupedCategory : category);
|
||||
const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
|
||||
const posts = useSelector(selectTopicThreads(topicIds));
|
||||
return <PostsList posts={posts} topics={topicIds} />;
|
||||
}
|
||||
@@ -45,12 +52,24 @@ function PostsView() {
|
||||
const {
|
||||
topicId,
|
||||
category,
|
||||
courseId,
|
||||
enableInContextSidebar,
|
||||
} = useContext(DiscussionContext);
|
||||
const dispatch = useDispatch();
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const searchString = useSelector(({ threads }) => threads.filters.search);
|
||||
const resultsFound = useSelector(({ threads }) => threads.totalThreads);
|
||||
const textSearchRewrite = useSelector(({ threads }) => threads.textSearchRewrite);
|
||||
const loadingStatus = useSelector(({ threads }) => threads.status);
|
||||
const topics = useSelector(enableInContext ? selectInContextTopics : selectTopics);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty(topics)) {
|
||||
dispatch((enableInContext || enableInContextSidebar)
|
||||
? fetchCourseTopicsV3(courseId)
|
||||
: fetchCourseTopics(courseId));
|
||||
}
|
||||
}, [topics]);
|
||||
|
||||
let postsListComponent;
|
||||
|
||||
@@ -62,20 +81,6 @@ function PostsView() {
|
||||
postsListComponent = <AllPostsList />;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
const { key } = event;
|
||||
if (key !== 'ArrowDown' && key !== 'ArrowUp') { return; }
|
||||
const option = event.target;
|
||||
|
||||
let selectedOption;
|
||||
if (key === 'ArrowDown') { selectedOption = option.nextElementSibling; }
|
||||
if (key === 'ArrowUp') { selectedOption = option.previousElementSibling; }
|
||||
|
||||
if (selectedOption) {
|
||||
selectedOption.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="discussion-posts d-flex flex-column h-100">
|
||||
{searchString && (
|
||||
|
||||
@@ -13,27 +13,31 @@ import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { Routes, ThreadType } from '../../data/constants';
|
||||
import { getApiBaseUrl, Routes, ThreadType } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { getCohortsApiUrl } from '../cohorts/data/api';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { fetchConfigSuccess } from '../data/slices';
|
||||
import { getCoursesApiUrl } from '../learners/data/api';
|
||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||
import { getThreadsApiUrl } from './data/api';
|
||||
import { PostsView } from './index';
|
||||
|
||||
import './data/__factories__';
|
||||
import '../cohorts/data/__factories__';
|
||||
import '../topics/data/__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const coursesApiUrl = getCoursesApiUrl();
|
||||
const threadsApiUrl = getThreadsApiUrl();
|
||||
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
|
||||
let store;
|
||||
let axiosMock;
|
||||
const username = 'abc123';
|
||||
|
||||
async function renderComponent({
|
||||
postId, topicId, category, myPosts, inContext = false,
|
||||
postId, topicId, category, myPosts, enableInContextSidebar = false,
|
||||
} = { myPosts: false }) {
|
||||
let path = generatePath(Routes.POSTS.ALL_POSTS, { courseId });
|
||||
let page;
|
||||
@@ -60,7 +64,7 @@ async function renderComponent({
|
||||
topicId,
|
||||
category,
|
||||
page,
|
||||
inContext,
|
||||
enableInContextSidebar,
|
||||
}}
|
||||
>
|
||||
<Switch>
|
||||
@@ -106,6 +110,12 @@ describe('PostsView', () => {
|
||||
pageSize: 6,
|
||||
})];
|
||||
});
|
||||
axiosMock
|
||||
.onGet(topicsApiUrl)
|
||||
.reply(200, {
|
||||
courseware_topics: Factory.buildList('category', 2),
|
||||
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
|
||||
});
|
||||
});
|
||||
|
||||
function setupStore(data = {}) {
|
||||
@@ -114,7 +124,6 @@ describe('PostsView', () => {
|
||||
config: { hasModerationPrivileges: true },
|
||||
...data,
|
||||
};
|
||||
// console.log(storeData);
|
||||
store = initializeStore(storeData);
|
||||
store.dispatch(fetchConfigSuccess({}));
|
||||
}
|
||||
@@ -173,7 +182,7 @@ describe('PostsView', () => {
|
||||
config: { groupAtSubsection: grouping, hasModerationPrivileges: true, provider: 'openedx' },
|
||||
});
|
||||
await act(async () => {
|
||||
await renderComponent({ category: 'test-usage-key', inContext: true, p: true });
|
||||
await renderComponent({ category: 'test-usage-key', enableInContextSidebar: true, p: true });
|
||||
});
|
||||
const topicThreadCount = Math.ceil(threadCount / 3);
|
||||
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-2/i))
|
||||
@@ -197,6 +206,8 @@ describe('PostsView', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
setupStore();
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
|
||||
await act(async () => {
|
||||
await renderComponent();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ const selectThreads = state => state.threads.threadsById;
|
||||
|
||||
const mapIdsToThreads = (ids, threads) => ids.map(id => threads?.[id]);
|
||||
|
||||
export const selectPostEditorVisible = state => state.threads.postEditorVisible;
|
||||
|
||||
export const selectTopicThreads = topicIds => createSelector(
|
||||
[
|
||||
state => (topicIds || []).flatMap(topicId => state.threads.threadsInTopic[topicId] || []),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable no-param-reassign,import/prefer-default-export */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
|
||||
import {
|
||||
PostsStatusFilter, RequestStatus, ThreadOrdering, ThreadType,
|
||||
@@ -57,7 +58,6 @@ const threadsSlice = createSlice({
|
||||
if (state.author !== payload.author) {
|
||||
state.pages = [];
|
||||
state.author = payload.author;
|
||||
state.totalThreads = null;
|
||||
}
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
@@ -79,7 +79,13 @@ const threadsSlice = createSlice({
|
||||
}
|
||||
state.status = RequestStatus.SUCCESSFUL;
|
||||
state.threadsById = { ...state.threadsById, ...payload.threadsById };
|
||||
state.threadsInTopic = mergeThreadsInTopics(state.threadsInTopic, payload.threadsInTopic);
|
||||
// filter
|
||||
if (payload.isFilterChanged) {
|
||||
state.threadsInTopic = { ...payload.threadsInTopic };
|
||||
} else {
|
||||
state.threadsInTopic = mergeThreadsInTopics(state.threadsInTopic, payload.threadsInTopic);
|
||||
}
|
||||
|
||||
state.avatars = { ...state.avatars, ...payload.avatars };
|
||||
state.nextPage = (payload.page < payload.pagination.numPages) ? payload.page + 1 : null;
|
||||
state.totalPages = payload.pagination.numPages;
|
||||
@@ -155,9 +161,11 @@ const threadsSlice = createSlice({
|
||||
},
|
||||
updateThreadFailed: (state) => {
|
||||
state.postStatus = RequestStatus.FAILED;
|
||||
state.totalThreads = 0;
|
||||
},
|
||||
updateThreadDenied: (state) => {
|
||||
state.postStatus = RequestStatus.DENIED;
|
||||
state.totalThreads = 0;
|
||||
},
|
||||
deleteThreadRequest: (state) => {
|
||||
state.postStatus = RequestStatus.IN_PROGRESS;
|
||||
@@ -168,7 +176,7 @@ const threadsSlice = createSlice({
|
||||
state.postStatus = RequestStatus.SUCCESSFUL;
|
||||
state.threadsInTopic[topicId] = state.threadsInTopic[topicId].filter(item => item !== threadId);
|
||||
state.pages = state.pages.map(page => page?.filter(item => item !== threadId));
|
||||
delete state.threadsById[threadId];
|
||||
state.threadsById = omitBy(state.threadsById, (thread) => thread.id === threadId);
|
||||
},
|
||||
deleteThreadFailed: (state) => {
|
||||
state.postStatus = RequestStatus.FAILED;
|
||||
@@ -210,6 +218,19 @@ const threadsSlice = createSlice({
|
||||
clearRedirect: (state) => {
|
||||
state.redirectToThread = null;
|
||||
},
|
||||
clearFilter: (state) => {
|
||||
state.filters = {
|
||||
status: PostsStatusFilter.ALL,
|
||||
postType: ThreadType.ALL,
|
||||
cohort: '',
|
||||
search: '',
|
||||
};
|
||||
state.pages = [];
|
||||
},
|
||||
clearSort: (state) => {
|
||||
state.sortedBy = ThreadOrdering.BY_LAST_ACTIVITY;
|
||||
state.pages = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -246,6 +267,8 @@ export const {
|
||||
hidePostEditor,
|
||||
clearRedirect,
|
||||
clearPostsPages,
|
||||
clearFilter,
|
||||
clearSort,
|
||||
} = threadsSlice.actions;
|
||||
|
||||
export const threadsReducer = threadsSlice.reducer;
|
||||
|
||||
@@ -102,6 +102,7 @@ export function fetchThreads(courseId, {
|
||||
author = null,
|
||||
filters = {},
|
||||
page = 1,
|
||||
isFilterChanged,
|
||||
countFlagged,
|
||||
} = {}) {
|
||||
const options = {
|
||||
@@ -141,7 +142,7 @@ export function fetchThreads(courseId, {
|
||||
const data = await getThreads(courseId, options);
|
||||
const normalisedData = normaliseThreads(camelCaseObject(data), topicIds);
|
||||
dispatch(fetchThreadsSuccess({
|
||||
...normalisedData, page, author, textSearchRewrite: data.text_search_rewrite,
|
||||
...normalisedData, page, author, textSearchRewrite: data.text_search_rewrite, isFilterChanged,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (getHttpErrorStatus(error) === 403) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -12,8 +11,11 @@ import { Close } from '@edx/paragon/icons';
|
||||
|
||||
import Search from '../../../components/Search';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import { selectBlackoutDate, selectconfigLoadingStatus } from '../../data/selectors';
|
||||
import { inBlackoutDateRange, postMessageToParent } from '../../utils';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
|
||||
import { selectconfigLoadingStatus, selectEnableInContext } from '../../data/selectors';
|
||||
import { TopicSearchBar as IncontextSearch } from '../../in-context-topics/topic-search';
|
||||
import { postMessageToParent } from '../../utils';
|
||||
import { showPostEditor } from '../data';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -21,38 +23,44 @@ import './actionBar.scss';
|
||||
|
||||
function PostActionsBar({
|
||||
intl,
|
||||
inContext,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const loadingStatus = useSelector(selectconfigLoadingStatus);
|
||||
const blackoutDateRange = useSelector(selectBlackoutDate);
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
|
||||
const { enableInContextSidebar, page } = useContext(DiscussionContext);
|
||||
|
||||
const handleCloseInContext = () => {
|
||||
postMessageToParent('learning.events.sidebar.close');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !inContext })}>
|
||||
{!inContext && <Search />}
|
||||
{inContext && (
|
||||
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !enableInContextSidebar })}>
|
||||
{!enableInContextSidebar && (
|
||||
(enableInContext && ['topics', 'category'].includes(page))
|
||||
? <IncontextSearch />
|
||||
: <Search />
|
||||
)}
|
||||
{enableInContextSidebar && (
|
||||
<h4 className="d-flex flex-grow-1 font-weight-bold my-0 py-0 align-self-center">
|
||||
{intl.formatMessage(messages.title)}
|
||||
</h4>
|
||||
)}
|
||||
{(!inBlackoutDateRange(blackoutDateRange) && loadingStatus === RequestStatus.SUCCESSFUL) && (
|
||||
{loadingStatus === RequestStatus.SUCCESSFUL && userCanAddThreadInBlackoutDate
|
||||
&& (
|
||||
<>
|
||||
{!inContext && <div className="border-right border-light-400 mx-3" />}
|
||||
{!enableInContextSidebar && <div className="border-right border-light-400 mx-3" />}
|
||||
<Button
|
||||
variant={inContext ? 'plain' : 'brand'}
|
||||
className={classNames('my-0', { 'p-0': inContext })}
|
||||
variant={enableInContextSidebar ? 'plain' : 'brand'}
|
||||
className={classNames('my-0', { 'p-0': enableInContextSidebar })}
|
||||
onClick={() => dispatch(showPostEditor())}
|
||||
size={inContext ? 'md' : 'sm'}
|
||||
size={enableInContextSidebar ? 'md' : 'sm'}
|
||||
>
|
||||
{intl.formatMessage(messages.addAPost)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{inContext && (
|
||||
{enableInContextSidebar && (
|
||||
<>
|
||||
<div className="border-right border-light-300 mr-2 ml-3.5 my-2" />
|
||||
<IconButton
|
||||
@@ -69,7 +77,6 @@ function PostActionsBar({
|
||||
|
||||
PostActionsBar.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
inContext: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(PostActionsBar);
|
||||
|
||||
@@ -28,12 +28,19 @@ import { useCurrentDiscussionTopic } from '../../data/hooks';
|
||||
import {
|
||||
selectAnonymousPostingConfig,
|
||||
selectDivisionSettings,
|
||||
selectEnableInContext,
|
||||
selectModerationSettings,
|
||||
selectUserHasModerationPrivileges,
|
||||
selectUserIsGroupTa,
|
||||
selectUserIsStaff,
|
||||
} from '../../data/selectors';
|
||||
import { EmptyPage } from '../../empty-posts';
|
||||
import {
|
||||
selectArchivedTopics,
|
||||
selectCoursewareTopics as inContextCourseware,
|
||||
selectNonCoursewareIds as inContextCoursewareIds,
|
||||
selectNonCoursewareTopics as inContextNonCourseware,
|
||||
} from '../../in-context-topics/data/selectors';
|
||||
import { selectCoursewareTopics, selectNonCoursewareIds, selectNonCoursewareTopics } from '../../topics/data/selectors';
|
||||
import {
|
||||
discussionsPath, formikCompatibleHandler, isFormikFieldInvalid, useCommentsPagePath,
|
||||
@@ -52,7 +59,7 @@ function DiscussionPostType({
|
||||
}) {
|
||||
// Need to use regular label since Form.Label doesn't support overriding htmlFor
|
||||
return (
|
||||
<label htmlFor={`post-type-${value}`} className="d-flex p-0 my-2 mr-3">
|
||||
<label htmlFor={`post-type-${value}`} className="d-flex p-0 my-0 mr-3">
|
||||
<Form.Radio value={value} id={`post-type-${value}`} className="sr-only">{type}</Form.Radio>
|
||||
<Card
|
||||
className={classNames('border-2 shadow-none', {
|
||||
@@ -94,10 +101,12 @@ function PostEditor({
|
||||
courseId,
|
||||
postId,
|
||||
} = useParams();
|
||||
const { category, enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const topicId = useCurrentDiscussionTopic();
|
||||
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
|
||||
const nonCoursewareIds = useSelector(selectNonCoursewareIds);
|
||||
const coursewareTopics = useSelector(selectCoursewareTopics);
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const nonCoursewareTopics = useSelector(enableInContext ? inContextNonCourseware : selectNonCoursewareTopics);
|
||||
const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds);
|
||||
const coursewareTopics = useSelector(enableInContext ? inContextCourseware : selectCoursewareTopics);
|
||||
const cohorts = useSelector(selectCourseCohorts);
|
||||
const post = useSelector(selectThread(postId));
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
@@ -106,7 +115,7 @@ function PostEditor({
|
||||
const { allowAnonymous, allowAnonymousToPeers } = useSelector(selectAnonymousPostingConfig);
|
||||
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
|
||||
const userIsStaff = useSelector(selectUserIsStaff);
|
||||
const { category, inContext } = useContext(DiscussionContext);
|
||||
const archivedTopics = useSelector(selectArchivedTopics);
|
||||
|
||||
const canDisplayEditReason = (reasonCodesEnabled && editExisting
|
||||
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
|
||||
@@ -138,7 +147,7 @@ function PostEditor({
|
||||
follow: isEmpty(post?.following) ? true : post?.following,
|
||||
anonymous: allowAnonymous ? false : undefined,
|
||||
anonymousToPeers: allowAnonymousToPeers ? false : undefined,
|
||||
editReasonCode: post?.lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''),
|
||||
editReasonCode: post?.lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
|
||||
cohort: post?.cohort || 'default',
|
||||
};
|
||||
|
||||
@@ -240,6 +249,10 @@ function PostEditor({
|
||||
|
||||
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
|
||||
|
||||
const handleInContextSelectLabel = (section, subsection) => (
|
||||
`${section.displayName} / ${subsection.displayName}` || intl.formatMessage(messages.unnamedTopics)
|
||||
);
|
||||
|
||||
return (
|
||||
<Formik
|
||||
enableReinitialize
|
||||
@@ -257,11 +270,11 @@ function PostEditor({
|
||||
resetForm,
|
||||
}) => (
|
||||
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
|
||||
<h3 className="mb-3">
|
||||
<h4 className="mb-4" style={{ lineHeight: '16px' }}>
|
||||
{editExisting
|
||||
? intl.formatMessage(messages.editPostHeading)
|
||||
: intl.formatMessage(messages.addPostHeading)}
|
||||
</h3>
|
||||
</h4>
|
||||
<Form.RadioSet
|
||||
name="postType"
|
||||
className="d-flex flex-row flex-wrap"
|
||||
@@ -275,14 +288,12 @@ function PostEditor({
|
||||
selected={values.postType === 'discussion'}
|
||||
type={intl.formatMessage(messages.discussionType)}
|
||||
icon={<Post />}
|
||||
description={intl.formatMessage(messages.discussionDescription)}
|
||||
/>
|
||||
<DiscussionPostType
|
||||
value="question"
|
||||
selected={values.postType === 'question'}
|
||||
type={intl.formatMessage(messages.questionType)}
|
||||
icon={<Help />}
|
||||
description={intl.formatMessage(messages.questionDescription)}
|
||||
/>
|
||||
</Form.RadioSet>
|
||||
<div className="d-flex flex-row my-4.5 justify-content-between">
|
||||
@@ -296,7 +307,7 @@ function PostEditor({
|
||||
onBlur={handleBlur}
|
||||
aria-describedby="topicAreaInput"
|
||||
floatingLabel={intl.formatMessage(messages.topicArea)}
|
||||
disabled={inContext}
|
||||
disabled={enableInContextSidebar}
|
||||
>
|
||||
{nonCoursewareTopics.map(topic => (
|
||||
<option
|
||||
@@ -305,15 +316,46 @@ function PostEditor({
|
||||
>{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
|
||||
</option>
|
||||
))}
|
||||
{coursewareTopics.map(categoryObj => (
|
||||
<optgroup label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)} key={categoryObj.id}>
|
||||
{categoryObj.topics.map(subtopic => (
|
||||
<option key={subtopic.id} value={subtopic.id}>
|
||||
{subtopic.name || intl.formatMessage(messages.unnamedSubTopics)}
|
||||
</option>
|
||||
{enableInContext ? (
|
||||
<>
|
||||
{coursewareTopics?.map(section => (
|
||||
section?.children?.map(subsection => (
|
||||
<optgroup
|
||||
label={handleInContextSelectLabel(section, subsection)}
|
||||
key={subsection.id}
|
||||
>
|
||||
{subsection?.children?.map(unit => (
|
||||
<option key={unit.id} value={unit.id}>
|
||||
{unit.name || intl.formatMessage(messages.unnamedSubTopics)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
{(userIsStaff || userIsGroupTa || userHasModerationPrivileges) && (
|
||||
<optgroup label={intl.formatMessage(messages.archivedTopics)}>
|
||||
{archivedTopics.map(topic => (
|
||||
<option key={topic.id} value={topic.id}>
|
||||
{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
coursewareTopics.map(categoryObj => (
|
||||
<optgroup
|
||||
label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)}
|
||||
key={categoryObj.id}
|
||||
>
|
||||
{categoryObj.topics.map(subtopic => (
|
||||
<option key={subtopic.id} value={subtopic.id}>
|
||||
{subtopic.name || intl.formatMessage(messages.unnamedSubTopics)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))
|
||||
)}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
{canSelectCohort(values.topic) && (
|
||||
@@ -336,8 +378,8 @@ function PostEditor({
|
||||
</Form.Group>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-bottom border-light-400" />
|
||||
<div className="d-flex flex-row my-4.5 justify-content-between">
|
||||
|
||||
<div className="d-flex flex-row mb-4.5 justify-content-between">
|
||||
<Form.Group
|
||||
className="w-100 m-0"
|
||||
isInvalid={isFormikFieldInvalid('title', {
|
||||
@@ -359,7 +401,7 @@ function PostEditor({
|
||||
</Form.Group>
|
||||
{canDisplayEditReason && (
|
||||
<Form.Group
|
||||
className="w-100 ml-3 mb-0"
|
||||
className="w-100 ml-4 mb-0"
|
||||
isInvalid={isFormikFieldInvalid('editReasonCode', {
|
||||
errors,
|
||||
touched,
|
||||
@@ -384,7 +426,7 @@ function PostEditor({
|
||||
</Form.Group>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<div className="mb-3">
|
||||
<TinyMCEEditor
|
||||
onInit={
|
||||
/* istanbul ignore next: TinyMCE is mocked so this cannot be easily tested */
|
||||
@@ -402,7 +444,7 @@ function PostEditor({
|
||||
|
||||
<PostPreviewPane htmlNode={values.comment} isPost editExisting={editExisting} />
|
||||
|
||||
<div className="d-flex flex-row mt-n4.5 w-75 text-primary">
|
||||
<div className="d-flex flex-row mt-n4 w-75 text-primary">
|
||||
{!editExisting && (
|
||||
<>
|
||||
<Form.Group>
|
||||
@@ -413,7 +455,9 @@ function PostEditor({
|
||||
onBlur={handleBlur}
|
||||
className="mr-4.5"
|
||||
>
|
||||
{intl.formatMessage(messages.followPost)}
|
||||
<span className="font-size-14">
|
||||
{intl.formatMessage(messages.followPost)}
|
||||
</span>
|
||||
</Form.Checkbox>
|
||||
</Form.Group>
|
||||
{allowAnonymousToPeers && (
|
||||
@@ -424,7 +468,9 @@ function PostEditor({
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
{intl.formatMessage(messages.anonymousToPeersPost)}
|
||||
<span className="font-size-14">
|
||||
{intl.formatMessage(messages.anonymousToPeersPost)}
|
||||
</span>
|
||||
</Form.Checkbox>
|
||||
</Form.Group>
|
||||
)}
|
||||
@@ -432,7 +478,7 @@ function PostEditor({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="d-flex justify-content-end mt-2.5">
|
||||
<div className="d-flex justify-content-end">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={() => hideEditor(resetForm)}
|
||||
|
||||
@@ -136,6 +136,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Thread not found',
|
||||
description: 'message to show on screen if the request thread is not found in course',
|
||||
},
|
||||
archivedTopics: {
|
||||
id: 'discussions.topics.archived.label',
|
||||
defaultMessage: 'Archived',
|
||||
description: 'Heading for displaying topics that are archived.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { capitalize, isEmpty, toString } from 'lodash';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Collapsible, Form, Icon, Spinner,
|
||||
@@ -84,6 +85,13 @@ function PostFilterBar({
|
||||
name,
|
||||
value,
|
||||
} = event.currentTarget;
|
||||
const filterContentEventProperties = {
|
||||
statusFilter: currentStatus,
|
||||
threadTypeFilter: currentType,
|
||||
sortFilter: currentSorting,
|
||||
cohortFilter: selectedCohort,
|
||||
triggeredBy: name,
|
||||
};
|
||||
if (name === 'type') {
|
||||
dispatch(setPostsTypeFilter(value));
|
||||
if (
|
||||
@@ -92,6 +100,7 @@ function PostFilterBar({
|
||||
// You can't filter discussions by unanswered
|
||||
dispatch(setStatusFilter(PostsStatusFilter.ALL));
|
||||
}
|
||||
filterContentEventProperties.threadTypeFilter = value;
|
||||
}
|
||||
if (name === 'status') {
|
||||
dispatch(setStatusFilter(value));
|
||||
@@ -103,13 +112,17 @@ function PostFilterBar({
|
||||
// You can't filter questions by not responded so switch type to discussion
|
||||
dispatch(setPostsTypeFilter(ThreadType.DISCUSSION));
|
||||
}
|
||||
filterContentEventProperties.statusFilter = value;
|
||||
}
|
||||
if (name === 'sort') {
|
||||
dispatch(setSortedBy(value));
|
||||
filterContentEventProperties.sortFilter = value;
|
||||
}
|
||||
if (name === 'cohort') {
|
||||
dispatch(setCohortFilter(value));
|
||||
filterContentEventProperties.cohortFilter = value;
|
||||
}
|
||||
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -135,12 +148,15 @@ function PostFilterBar({
|
||||
cohort: capitalize(selectedCohort?.name),
|
||||
})}
|
||||
</span>
|
||||
<Collapsible.Visible whenClosed>
|
||||
<Icon src={Tune} />
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<Icon src={Tune} />
|
||||
</Collapsible.Visible>
|
||||
<span id="icon-tune">
|
||||
<Collapsible.Visible whenClosed>
|
||||
<Icon src={Tune} />
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<Icon src={Tune} />
|
||||
</Collapsible.Visible>
|
||||
</span>
|
||||
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
|
||||
<Form>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
@@ -10,7 +11,8 @@ import { Hyperlink, useToggle } from '@edx/paragon';
|
||||
import HTMLLoader from '../../../components/HTMLLoader';
|
||||
import { ContentActions } from '../../../data/constants';
|
||||
import { selectorForUnitSubsection, selectTopicContext } from '../../../data/selectors';
|
||||
import { AlertBanner, DeleteConfirmation } from '../../common';
|
||||
import { AlertBanner, Confirmation } from '../../common';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { selectModerationSettings } from '../../data/selectors';
|
||||
import { selectTopic } from '../../topics/data/selectors';
|
||||
import { removeThread, updateExistingThread } from '../data/thunks';
|
||||
@@ -28,13 +30,38 @@ function Post({
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const { courseId } = useSelector((state) => state.courseTabs);
|
||||
const topic = useSelector(selectTopic(post.topicId));
|
||||
const getTopicSubsection = useSelector(selectorForUnitSubsection);
|
||||
const topicContext = useSelector(selectTopicContext(post.topicId));
|
||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
|
||||
|
||||
const handleAbusedFlag = () => {
|
||||
if (post.abuseFlagged) {
|
||||
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
|
||||
} else {
|
||||
showReportConfirmation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirmation = async () => {
|
||||
await dispatch(removeThread(post.id));
|
||||
history.push({
|
||||
pathname: '.',
|
||||
search: enableInContextSidebar && '?inContextSidebar',
|
||||
});
|
||||
hideDeleteConfirmation();
|
||||
};
|
||||
|
||||
const handleReportConfirmation = () => {
|
||||
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
|
||||
hideReportConfirmation();
|
||||
};
|
||||
|
||||
const actionHandlers = {
|
||||
[ContentActions.EDIT_CONTENT]: () => history.push({
|
||||
...location,
|
||||
@@ -52,7 +79,7 @@ function Post({
|
||||
},
|
||||
[ContentActions.COPY_LINK]: () => { navigator.clipboard.writeText(`${window.location.origin}/${courseId}/posts/${post.id}`); },
|
||||
[ContentActions.PIN]: () => dispatch(updateExistingThread(post.id, { pinned: !post.pinned })),
|
||||
[ContentActions.REPORT]: () => dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged })),
|
||||
[ContentActions.REPORT]: () => handleAbusedFlag(),
|
||||
};
|
||||
|
||||
const getTopicCategoryName = topicData => (
|
||||
@@ -61,30 +88,50 @@ function Post({
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column w-100 mw-100" data-testid={`post-${post.id}`}>
|
||||
<DeleteConfirmation
|
||||
<Confirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deletePostTitle)}
|
||||
description={intl.formatMessage(messages.deletePostDescription)}
|
||||
onClose={hideDeleteConfirmation}
|
||||
onDelete={() => {
|
||||
dispatch(removeThread(post.id));
|
||||
history.push('.');
|
||||
hideDeleteConfirmation();
|
||||
}}
|
||||
comfirmAction={handleDeleteConfirmation}
|
||||
closeButtonVaraint="tertiary"
|
||||
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
|
||||
/>
|
||||
{!post.abuseFlagged && (
|
||||
<Confirmation
|
||||
isOpen={isReporting}
|
||||
title={intl.formatMessage(messages.reportPostTitle)}
|
||||
description={intl.formatMessage(messages.reportPostDescription)}
|
||||
onClose={hideReportConfirmation}
|
||||
comfirmAction={handleReportConfirmation}
|
||||
confirmButtonVariant="danger"
|
||||
/>
|
||||
)}
|
||||
<AlertBanner content={post} />
|
||||
<PostHeader post={post} actionHandlers={actionHandlers} />
|
||||
<div className="d-flex mt-4 mb-2 text-break font-style-normal text-primary-500">
|
||||
<HTMLLoader htmlNode={post.renderedBody} id="post" />
|
||||
</div>
|
||||
{topicContext && topic && (
|
||||
<div className="border px-3 rounded mb-4 border-light-400 align-self-start py-2.5">
|
||||
<div className={classNames('border px-3 rounded mb-4 border-light-400 align-self-start py-2.5',
|
||||
{ 'w-100': enableInContextSidebar })}
|
||||
>
|
||||
<span className="text-gray-500">{intl.formatMessage(messages.relatedTo)}{' '}</span>
|
||||
<Hyperlink
|
||||
destination={topicContext.unitLink}
|
||||
target="_top"
|
||||
>
|
||||
{`${getTopicCategoryName(topic)} / ${topic.name}`}
|
||||
{enableInContextSidebar
|
||||
? (
|
||||
<>
|
||||
<span className="w-auto">{topicContext.chapterName}</span>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="w-auto">{topicContext.verticalName}</span>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="w-auto">{topicContext.unitName}</span>
|
||||
</>
|
||||
)
|
||||
: `${getTopicCategoryName(topic)} / ${topic.name}`}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,11 +22,11 @@ export function PostAvatar({
|
||||
const outlineColor = AvatarOutlineAndLabelColors[authorLabel];
|
||||
|
||||
const avatarSize = useMemo(() => {
|
||||
let size = '1.75rem';
|
||||
let size = '2rem';
|
||||
if (post.type === ThreadType.DISCUSSION && !fromPostLink) {
|
||||
size = '2.375rem';
|
||||
} else if (post.type === ThreadType.QUESTION) {
|
||||
size = '1.375rem';
|
||||
size = '1.5rem';
|
||||
}
|
||||
return size;
|
||||
}, [post.type]);
|
||||
|
||||
@@ -15,8 +15,8 @@ import AuthorLabel from '../../common/AuthorLabel';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { discussionsPath, isPostPreviewAvailable } from '../../utils';
|
||||
import messages from './messages';
|
||||
import PostFooter from './PostFooter';
|
||||
import { PostAvatar } from './PostHeader';
|
||||
import PostSummaryFooter from './PostSummaryFooter';
|
||||
import { postShape } from './proptypes';
|
||||
|
||||
function PostLink({
|
||||
@@ -29,12 +29,12 @@ function PostLink({
|
||||
const {
|
||||
page,
|
||||
postId,
|
||||
inContext,
|
||||
enableInContextSidebar,
|
||||
category,
|
||||
learnerUsername,
|
||||
} = useContext(DiscussionContext);
|
||||
const linkUrl = discussionsPath(Routes.COMMENTS.PAGES[page], {
|
||||
0: inContext ? 'in-context' : undefined,
|
||||
0: enableInContextSidebar ? 'in-context' : undefined,
|
||||
courseId: post.courseId,
|
||||
topicId: post.topicId,
|
||||
postId: post.id,
|
||||
@@ -56,24 +56,20 @@ function PostLink({
|
||||
}
|
||||
to={linkUrl}
|
||||
onClick={() => isSelected(post.id)}
|
||||
style={{ lineHeight: '22px' }}
|
||||
aria-current={isSelected(post.id) ? 'page' : undefined}
|
||||
role="option"
|
||||
tabIndex={(isSelected(post.id) || idx === 0) ? 0 : -1}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
classNames('d-flex flex-row pt-2.5 pb-2 px-4 border-primary-500 position-relative',
|
||||
{ 'bg-light-300': read })
|
||||
classNames('d-flex flex-row pt-2 pb-2 px-4 border-primary-500 position-relative',
|
||||
{ 'bg-light-300': read },
|
||||
{ 'post-summary-card-selected': post.id === postId })
|
||||
}
|
||||
style={post.id === postId ? {
|
||||
borderRightWidth: '4px',
|
||||
borderRightStyle: 'solid',
|
||||
} : null}
|
||||
>
|
||||
<PostAvatar post={post} authorLabel={post.authorLabel} fromPostLink read={read} />
|
||||
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
|
||||
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
|
||||
<div className="d-flex flex-column justify-content-start mw-100 flex-fill" style={{ marginBottom: '-3px' }}>
|
||||
<div className="d-flex align-items-center pb-0 mb-0 flex-fill font-weight-500">
|
||||
<Truncate lines={1} className="mr-1.5" whiteSpace>
|
||||
<span
|
||||
@@ -100,20 +96,20 @@ function PostLink({
|
||||
)}
|
||||
|
||||
{canSeeReportedBadge && (
|
||||
<Badge
|
||||
variant="danger"
|
||||
data-testid="reported-post"
|
||||
className={`font-weight-500 badge-padding ${showAnsweredBadge ? 'ml-2' : 'ml-auto'}`}
|
||||
>
|
||||
{intl.formatMessage(messages.contentReported)}
|
||||
<span className="sr-only">{' '}reported</span>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="danger"
|
||||
data-testid="reported-post"
|
||||
className={`font-weight-500 badge-padding ${showAnsweredBadge ? 'ml-2' : 'ml-auto'}`}
|
||||
>
|
||||
{intl.formatMessage(messages.contentReported)}
|
||||
<span className="sr-only">{' '}reported</span>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{post.pinned && (
|
||||
<Icon
|
||||
src={PushPin}
|
||||
className={`icon-size ${canSeeReportedBadge || showAnsweredBadge ? 'ml-2' : 'ml-auto'}`}
|
||||
className={`post-summary-icons-dimensions text-gray-700 ${canSeeReportedBadge || showAnsweredBadge ? 'ml-2' : 'ml-auto'}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -123,7 +119,7 @@ function PostLink({
|
||||
authorLabel={post.authorLabel}
|
||||
labelColor={authorLabelColor && `text-${authorLabelColor}`}
|
||||
/>
|
||||
<PostFooter post={post} preview intl={intl} showNewCountLabel={read} />
|
||||
<PostSummaryFooter post={post} preview showNewCountLabel={read} />
|
||||
</div>
|
||||
</div>
|
||||
{!showDivider && post.pinned && <div className="pt-1 bg-light-500 border-top border-light-700" />}
|
||||
|
||||
147
src/discussions/posts/post/PostSummaryFooter.jsx
Normal file
147
src/discussions/posts/post/PostSummaryFooter.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Badge, Icon, OverlayTrigger, Tooltip,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
Locked,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
import {
|
||||
People,
|
||||
QuestionAnswer,
|
||||
QuestionAnswerOutline,
|
||||
StarFilled,
|
||||
StarOutline, ThumbUpFilled, ThumbUpOutline,
|
||||
} from '../../../components/icons';
|
||||
import timeLocale from '../../common/time-locale';
|
||||
import { selectUserHasModerationPrivileges } from '../../data/selectors';
|
||||
import messages from './messages';
|
||||
import { postShape } from './proptypes';
|
||||
|
||||
function PostSummaryFooter({
|
||||
post,
|
||||
intl,
|
||||
preview,
|
||||
showNewCountLabel,
|
||||
}) {
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
timeago.register('time-locale', timeLocale);
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-center text-gray-700">
|
||||
<div className="d-flex align-items-center mr-4.5">
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`liked-${post.id}-tooltip`}>
|
||||
{intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Icon src={post.voted ? ThumbUpFilled : ThumbUpOutline} className="post-summary-icons-dimensions mr-0.5">
|
||||
<span className="sr-only">{' '}{intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)}</span>
|
||||
</Icon>
|
||||
</OverlayTrigger>
|
||||
<div className="font-family-inter font-style-normal">
|
||||
{(post.voteCount && post.voteCount > 0) ? post.voteCount : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`follow-${post.id}-tooltip`}>
|
||||
{intl.formatMessage(post.following ? messages.followed : messages.notFollowed)}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Icon src={post.following ? StarFilled : StarOutline} className="post-summary-icons-dimensions mr-0.5">
|
||||
<span className="sr-only">{' '}{ intl.formatMessage(post.following ? messages.srOnlyFollowDescription : messages.srOnlyUnFollowDescription)}</span>
|
||||
</Icon>
|
||||
</OverlayTrigger>
|
||||
|
||||
{preview && post.commentCount > 1 && (
|
||||
<div className="d-flex align-items-center ml-4.5">
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`follow-${post.id}-tooltip`}>
|
||||
{intl.formatMessage(messages.activity)}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Icon src={post.unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline} className="post-summary-icons-dimensions mr-0.5">
|
||||
<span className="sr-only">{' '} {intl.formatMessage(messages.activity)}</span>
|
||||
</Icon>
|
||||
</OverlayTrigger>
|
||||
{post.commentCount}
|
||||
</div>
|
||||
)}
|
||||
{showNewCountLabel && preview && post?.unreadCommentCount > 0 && post.commentCount > 1 && (
|
||||
<Badge variant="light" className="ml-2">
|
||||
{intl.formatMessage(messages.newLabel, { count: post.unreadCommentCount })}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="d-flex flex-fill justify-content-end align-items-center">
|
||||
{post.groupId && userHasModerationPrivileges && (
|
||||
<>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`visibility-${post.id}-tooltip`}>{post.groupName}</Tooltip>
|
||||
)}
|
||||
>
|
||||
<span data-testid="cohort-icon" className="post-summary-icons-dimensions">
|
||||
<People />
|
||||
</span>
|
||||
</OverlayTrigger>
|
||||
<span
|
||||
className="text-gray-700 mx-1.5 font-weight-500"
|
||||
style={{ fontSize: '16px' }}
|
||||
>
|
||||
·
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span title={post.createdAt} className="text-gray-700 post-summary-timestamp">
|
||||
{timeago.format(post.createdAt, 'time-locale')}
|
||||
</span>
|
||||
{!preview && post.closed
|
||||
&& (
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`closed-${post.id}-tooltip`}>
|
||||
{intl.formatMessage(messages.postClosed)}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
src={Locked}
|
||||
style={{
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
}}
|
||||
className="ml-3 post-summary-icons-dimensions"
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PostSummaryFooter.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
post: postShape.isRequired,
|
||||
preview: PropTypes.bool,
|
||||
showNewCountLabel: PropTypes.bool,
|
||||
};
|
||||
|
||||
PostSummaryFooter.defaultProps = {
|
||||
preview: false,
|
||||
showNewCountLabel: false,
|
||||
};
|
||||
|
||||
export default injectIntl(PostSummaryFooter);
|
||||
@@ -28,6 +28,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Follow',
|
||||
description: 'Tooltip/alttext for button to follow a discussion post',
|
||||
},
|
||||
followed: {
|
||||
id: 'discussions.post.followed',
|
||||
defaultMessage: 'Followed',
|
||||
description: 'Tooltip/alttext for follow icon showing user followed a post',
|
||||
},
|
||||
notFollowed: {
|
||||
id: 'discussions.post.notFollowed',
|
||||
defaultMessage: 'Not Followed',
|
||||
description: 'Tooltip/alttext for follow icon showing user not following a post',
|
||||
},
|
||||
answered: {
|
||||
id: 'discussions.post.answered',
|
||||
defaultMessage: 'Answered',
|
||||
@@ -48,11 +58,26 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Unlike',
|
||||
description: 'Tooltip/alttext for button to remove the like applied to a discussion post',
|
||||
},
|
||||
likedPost: {
|
||||
id: 'discussions.post.liked',
|
||||
defaultMessage: 'liked',
|
||||
description: 'Tooltip/alttext for like icon to tell user this post is liked by user',
|
||||
},
|
||||
postLikes: {
|
||||
id: 'discussions.post.likes',
|
||||
defaultMessage: 'likes',
|
||||
description: 'Tooltip/alttext for like icon to tell user about like on the post',
|
||||
},
|
||||
viewActivity: {
|
||||
id: 'discussions.post.viewActivity',
|
||||
defaultMessage: 'View activity',
|
||||
description: 'Tooltip/alttext for button to view the activity of a discussion post',
|
||||
},
|
||||
activity: {
|
||||
id: 'discussions.post.activity',
|
||||
defaultMessage: 'Activity',
|
||||
description: 'Tooltip/alttext for icon for showing icon represents activity on a post',
|
||||
},
|
||||
postClosed: {
|
||||
id: 'discussions.post.closed',
|
||||
defaultMessage: 'Post closed for responses and comments',
|
||||
@@ -71,6 +96,21 @@ const messages = defineMessages({
|
||||
id: 'discussions.editor.delete.post.description',
|
||||
defaultMessage: 'Are you sure you want to permanently delete this post?',
|
||||
},
|
||||
deleteConfirmationDelete: {
|
||||
id: 'discussions.post.delete.confirmation.button.delete',
|
||||
defaultMessage: 'Delete',
|
||||
description: 'Delete button shown on delete confirmation dialog',
|
||||
},
|
||||
reportPostTitle: {
|
||||
id: 'discussions.editor.report.post.title',
|
||||
defaultMessage: 'Report inappropriate content?',
|
||||
description: 'Title of confirmation dialog shown when reporting a post',
|
||||
},
|
||||
reportPostDescription: {
|
||||
id: 'discussions.editor.report.post.description',
|
||||
defaultMessage: 'The discussion moderation team will review this content and take appropriate action.',
|
||||
description: 'Text displayed in confirmation dialog when deleting a post',
|
||||
},
|
||||
closePostModalTitle: {
|
||||
id: 'discussions.post.closePostModal.title',
|
||||
defaultMessage: 'Close post',
|
||||
@@ -116,6 +156,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'No preview available',
|
||||
description: 'No preview available',
|
||||
},
|
||||
srOnlyFollowDescription: {
|
||||
id: 'discussions.post.follow.description',
|
||||
defaultMessage: 'you are following this post',
|
||||
description: 'tell screen readers if user is following a post',
|
||||
},
|
||||
srOnlyUnFollowDescription: {
|
||||
id: 'discussions.post.unfollow.description',
|
||||
defaultMessage: 'you are not following this post',
|
||||
description: 'tell screen readers if user is not following a post',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -4,17 +4,15 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import SearchInfo from '../../components/SearchInfo';
|
||||
import { DiscussionProvider, RequestStatus } from '../../data/constants';
|
||||
import { selectSequences } from '../../data/selectors';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { selectDiscussionProvider } from '../data/selectors';
|
||||
import NoResults from '../posts/NoResults';
|
||||
import { handleKeyDown } from '../utils';
|
||||
import { selectCategories, selectNonCoursewareTopics, selectTopicFilter } from './data/selectors';
|
||||
import { setFilter, setTopicsCount } from './data/slices';
|
||||
import { fetchCourseTopics } from './data/thunks';
|
||||
import ArchivedTopicGroup from './topic-group/ArchivedTopicGroup';
|
||||
import LegacyTopicGroup from './topic-group/LegacyTopicGroup';
|
||||
import SequenceTopicGroup from './topic-group/SequenceTopicGroup';
|
||||
import Topic from './topic-group/topic/Topic';
|
||||
import countFilteredTopics from './utils';
|
||||
|
||||
@@ -38,24 +36,6 @@ function CourseWideTopics() {
|
||||
));
|
||||
}
|
||||
|
||||
function CoursewareTopics() {
|
||||
const sequences = useSelector(selectSequences);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ sequences?.map(
|
||||
sequence => (
|
||||
<SequenceTopicGroup
|
||||
sequence={sequence}
|
||||
key={sequence.id}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<ArchivedTopicGroup />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyCoursewareTopics() {
|
||||
const { category } = useParams();
|
||||
const categories = useSelector(selectCategories)
|
||||
@@ -80,20 +60,6 @@ function TopicsView() {
|
||||
const { courseId } = useContext(DiscussionContext);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
const { key } = event;
|
||||
if (key !== 'ArrowDown' && key !== 'ArrowUp') { return; }
|
||||
const option = event.target;
|
||||
|
||||
let selectedOption;
|
||||
if (key === 'ArrowDown') { selectedOption = option.nextElementSibling; }
|
||||
if (key === 'ArrowUp') { selectedOption = option.previousElementSibling; }
|
||||
|
||||
if (selectedOption) {
|
||||
selectedOption.focus();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Don't load till the provider information is available
|
||||
if (provider) {
|
||||
@@ -118,8 +84,7 @@ function TopicsView() {
|
||||
)}
|
||||
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
|
||||
<CourseWideTopics />
|
||||
{provider === DiscussionProvider.OPEN_EDX && <CoursewareTopics />}
|
||||
{provider === DiscussionProvider.LEGACY && <LegacyCoursewareTopics />}
|
||||
<LegacyCoursewareTopics />
|
||||
</div>
|
||||
{
|
||||
filteredTopicsCount === 0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
fireEvent, render, screen, within,
|
||||
fireEvent, render, screen,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
@@ -10,11 +10,7 @@ import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { getBlocksAPIResponse } from '../../data/__factories__';
|
||||
import { getBlocksAPIURL } from '../../data/api';
|
||||
import { DiscussionProvider, getApiBaseUrl } from '../../data/constants';
|
||||
import { selectSequences } from '../../data/selectors';
|
||||
import { fetchCourseBlocks } from '../../data/thunks';
|
||||
import { getApiBaseUrl } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import { executeThunk } from '../../test-utils';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
@@ -27,7 +23,6 @@ import './data/__factories__';
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
|
||||
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
|
||||
const topicsv2ApiUrl = `${getApiBaseUrl()}/api/discussion/v2/course_topics/${courseId}`;
|
||||
let store;
|
||||
let axiosMock;
|
||||
let lastLocation;
|
||||
@@ -57,131 +52,86 @@ function renderComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
describe('TopicsView', () => {
|
||||
describe.each(['legacy', 'openedx'])('%s provider', (provider) => {
|
||||
let inContextTopics;
|
||||
let globalTopics;
|
||||
let categories;
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore({
|
||||
config: { provider },
|
||||
blocks: {
|
||||
topics: {},
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
lastLocation = undefined;
|
||||
describe('Legacy Topics View', () => {
|
||||
let inContextTopics;
|
||||
let globalTopics;
|
||||
let categories;
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
async function setupMockResponse() {
|
||||
if (provider === 'legacy') {
|
||||
axiosMock
|
||||
.onGet(topicsApiUrl)
|
||||
.reply(200, {
|
||||
courseware_topics: Factory.buildList('category', 2),
|
||||
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
|
||||
});
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
const state = store.getState();
|
||||
categories = state.topics.categoryIds;
|
||||
globalTopics = selectNonCoursewareTopics(state);
|
||||
inContextTopics = selectCoursewareTopics(state);
|
||||
} else {
|
||||
const blocksAPIResponse = getBlocksAPIResponse(true);
|
||||
const ids = Object.values(blocksAPIResponse.blocks).filter(block => block.type === 'vertical')
|
||||
.map(block => block.block_id);
|
||||
const deletedIds = [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@deleted-vertical-1',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@deleted-vertical-2',
|
||||
];
|
||||
const data = [
|
||||
...Factory.buildList('topic.v2', 2, { usage_key: null }, { topicPrefix: 'ncw' }),
|
||||
...ids.map(id => Factory.build('topic.v2', { id })),
|
||||
...deletedIds.map(id => Factory.build('topic.v2', { id, enabled_in_context: false }, { topicPrefix: 'archived ' })),
|
||||
];
|
||||
|
||||
axiosMock
|
||||
.onGet(topicsv2ApiUrl)
|
||||
.reply(200, data);
|
||||
axiosMock.onGet(getBlocksAPIURL())
|
||||
.reply(200, getBlocksAPIResponse(true));
|
||||
axiosMock.onAny().networkError();
|
||||
await executeThunk(fetchCourseBlocks(courseId, 'abc123'), store.dispatch, store.getState);
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
const state = store.getState();
|
||||
categories = selectSequences(state);
|
||||
globalTopics = selectNonCoursewareTopics(state);
|
||||
inContextTopics = selectCoursewareTopics(state);
|
||||
}
|
||||
}
|
||||
|
||||
it('displays non-courseware topics', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
|
||||
globalTopics.forEach(topic => {
|
||||
expect(screen.queryByText(topic.name)).toBeInTheDocument();
|
||||
});
|
||||
store = initializeStore({
|
||||
config: { provider: 'legacy' },
|
||||
blocks: {
|
||||
topics: {},
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
lastLocation = undefined;
|
||||
});
|
||||
|
||||
it('displays non-courseware outside of a topic group', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
|
||||
categories.forEach(category => {
|
||||
// For the new provider categories are blocks so use the display name
|
||||
// otherwise use the category itself which is a string
|
||||
expect(screen.queryByText(category.displayName || category)).toBeInTheDocument();
|
||||
async function setupMockResponse() {
|
||||
axiosMock
|
||||
.onGet(topicsApiUrl)
|
||||
.reply(200, {
|
||||
courseware_topics: Factory.buildList('category', 2),
|
||||
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
|
||||
});
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
const state = store.getState();
|
||||
categories = state.topics.categoryIds;
|
||||
globalTopics = selectNonCoursewareTopics(state);
|
||||
inContextTopics = selectCoursewareTopics(state);
|
||||
}
|
||||
|
||||
const topicGroups = screen.queryAllByTestId('topic-group');
|
||||
// For the new provider there should be a section for archived topics
|
||||
expect(topicGroups).toHaveLength(
|
||||
provider === DiscussionProvider.LEGACY
|
||||
? categories.length
|
||||
: categories.length + 1,
|
||||
);
|
||||
});
|
||||
it('displays non-courseware topics', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
|
||||
if (provider === DiscussionProvider.OPEN_EDX) {
|
||||
it('displays archived topics', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
const archivedTopicGroup = screen.queryAllByTestId('topic-group').pop();
|
||||
expect(archivedTopicGroup).toHaveTextContent(/archived/i);
|
||||
const archivedTopicLinks = within(archivedTopicGroup).queryAllByRole('option');
|
||||
expect(archivedTopicLinks).toHaveLength(2);
|
||||
});
|
||||
}
|
||||
|
||||
it('displays courseware topics', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
|
||||
inContextTopics.forEach(topic => {
|
||||
expect(screen.queryByText(topic.name)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking on courseware topic (category) takes to category page', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
|
||||
const categoryName = categories[0].displayName || categories[0];
|
||||
const categoryPath = provider === 'legacy' ? categoryName : categories[0].id;
|
||||
const topic = await screen.findByText(categoryName);
|
||||
fireEvent.click(topic);
|
||||
expect(lastLocation.pathname.endsWith(`/category/${categoryPath}`)).toBeTruthy();
|
||||
globalTopics.forEach(topic => {
|
||||
expect(screen.queryByText(topic.name)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays non-courseware outside of a topic group', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
|
||||
categories.forEach(category => {
|
||||
// For the new provider categories are blocks so use the display name
|
||||
// otherwise use the category itself which is a string
|
||||
expect(screen.queryByText(category.displayName || category)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const topicGroups = screen.queryAllByTestId('topic-group');
|
||||
// For the new provider there should be a section for archived topics
|
||||
expect(topicGroups).toHaveLength(categories.length);
|
||||
});
|
||||
|
||||
it('displays courseware topics', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
|
||||
inContextTopics.forEach(topic => {
|
||||
expect(screen.queryByText(topic.name)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('clicking on courseware topic (category) takes to category page', async () => {
|
||||
await setupMockResponse();
|
||||
renderComponent();
|
||||
|
||||
const categoryName = categories[0].displayName || categories[0];
|
||||
const categoryPath = categoryName;
|
||||
const topic = await screen.findByText(categoryName);
|
||||
fireEvent.click(topic);
|
||||
expect(lastLocation.pathname.endsWith(`/category/${categoryPath}`)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,14 +13,3 @@ export async function getCourseTopics(courseId, topicIds) {
|
||||
.get(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getCourseTopicsV2(courseId, topicIds) {
|
||||
const url = `${getApiBaseUrl()}/api/discussion/v2/course_topics/${courseId}`;
|
||||
const params = {};
|
||||
if (topicIds) {
|
||||
params.topic_id = topicIds.join(',');
|
||||
}
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { DiscussionProvider } from '../../../data/constants';
|
||||
import { selectSequences } from '../../../data/selectors';
|
||||
import { selectDiscussionProvider } from '../../data/selectors';
|
||||
|
||||
export const selectTopicFilter = state => state.topics.filter.trim()
|
||||
.toLowerCase();
|
||||
|
||||
@@ -19,29 +15,22 @@ export const selectTopicsInCategory = (categoryId) => state => (
|
||||
|
||||
export const selectTopics = state => state.topics.topics;
|
||||
export const selectCoursewareTopics = createSelector(
|
||||
selectDiscussionProvider,
|
||||
selectCategories,
|
||||
selectTopicCategoryMap,
|
||||
selectTopics,
|
||||
selectSequences,
|
||||
(provider, categoryIds, topicsInCategory, topics, sequences) => (
|
||||
provider === DiscussionProvider.LEGACY
|
||||
? categoryIds.map(category => ({
|
||||
id: category,
|
||||
name: category,
|
||||
topics: topicsInCategory[category].map(id => topics[id]),
|
||||
}))
|
||||
: sequences.map(sequence => ({
|
||||
id: sequence.id,
|
||||
name: sequence.displayName,
|
||||
topics: sequence.topics.map(topicId => ({ id: topicId, name: topics[topicId]?.name || 'unnamed' })),
|
||||
}))
|
||||
(categoryIds, topicsInCategory, topics) => (
|
||||
categoryIds.map(category => ({
|
||||
id: category,
|
||||
name: category,
|
||||
topics: topicsInCategory[category].map(id => topics[id]),
|
||||
}))
|
||||
),
|
||||
);
|
||||
|
||||
export const selectNonCoursewareIds = state => state.topics.nonCoursewareIds;
|
||||
|
||||
export const selectNonCoursewareTopics = state => state.topics.nonCoursewareIds.map(id => state.topics.topics[id]);
|
||||
export const selectNonCoursewareTopics = state => state.topics.nonCoursewareIds?.map(id => state.topics.topics[id])
|
||||
|| [];
|
||||
|
||||
export const selectTopic = topicId => state => state.topics.topics[topicId];
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ const topicsSlice = createSlice({
|
||||
categoryIds: [],
|
||||
// List of all non-courseware topics
|
||||
nonCoursewareIds: [],
|
||||
// Topics that have been archived
|
||||
archivedIds: [],
|
||||
// Mapping of all topics in each category
|
||||
topicsInCategory: {},
|
||||
// Map of topics ids to topic data
|
||||
@@ -32,7 +30,6 @@ const topicsSlice = createSlice({
|
||||
state.topics = payload.topics;
|
||||
state.nonCoursewareIds = payload.nonCoursewareIds;
|
||||
state.categoryIds = payload.categoryIds;
|
||||
state.archivedIds = payload.archivedIds;
|
||||
state.topicsInCategory = payload.topicsInCategory;
|
||||
},
|
||||
fetchCourseTopicsFailed: (state) => {
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { DiscussionProvider } from '../../../data/constants';
|
||||
import { getCourseTopics, getCourseTopicsV2 } from './api';
|
||||
import { getCourseTopics } from './api';
|
||||
import { fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess } from './slices';
|
||||
|
||||
function normaliseTopics(data) {
|
||||
@@ -26,34 +25,12 @@ function normaliseTopics(data) {
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseTopicsV2(data) {
|
||||
const nonCoursewareIds = [];
|
||||
const topics = {};
|
||||
const archivedIds = [];
|
||||
data.forEach(topic => {
|
||||
if (!topic.enabledInContext) {
|
||||
archivedIds.push(topic.id);
|
||||
} else if (topic.usageKey === null) {
|
||||
nonCoursewareIds.push(topic.id);
|
||||
}
|
||||
topics[topic.id] = topic;
|
||||
});
|
||||
return {
|
||||
topics, nonCoursewareIds, archivedIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseTopics(courseId) {
|
||||
return async (dispatch, getState) => {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const { config } = getState();
|
||||
dispatch(fetchCourseTopicsRequest({ courseId }));
|
||||
let data = {};
|
||||
if (config.provider === DiscussionProvider.LEGACY) {
|
||||
data = normaliseTopics(camelCaseObject(await getCourseTopics(courseId)));
|
||||
} else if (config.provider === DiscussionProvider.OPEN_EDX) {
|
||||
data = normaliseTopicsV2(camelCaseObject(await getCourseTopicsV2(courseId)));
|
||||
}
|
||||
|
||||
const data = normaliseTopics(camelCaseObject(await getCourseTopics(courseId)));
|
||||
dispatch(fetchCourseTopicsSuccess(data));
|
||||
} catch (error) {
|
||||
dispatch(fetchCourseTopicsFailed());
|
||||
@@ -61,16 +38,3 @@ export function fetchCourseTopics(courseId) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseTopicsV2(courseId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchCourseTopicsRequest({ courseId }));
|
||||
const data = await getCourseTopicsV2(courseId);
|
||||
dispatch(fetchCourseTopicsSuccess(normaliseTopicsV2(camelCaseObject(data))));
|
||||
} catch (error) {
|
||||
dispatch(fetchCourseTopicsFailed());
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function countFilteredTopics(topicsSelector, provider) {
|
||||
? item.name.toLowerCase().includes(query)
|
||||
: true
|
||||
));
|
||||
count += nonCoursewareTopicsList.length;
|
||||
count += nonCoursewareTopicsList?.length;
|
||||
// Counting legacy topics
|
||||
if (provider === DiscussionProvider.LEGACY) {
|
||||
const categories = topicsSelector?.categoryIds;
|
||||
|
||||
34
src/discussions/tours/DiscussionsProductTour.jsx
Normal file
34
src/discussions/tours/DiscussionsProductTour.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { ProductTour } from '@edx/paragon';
|
||||
|
||||
import { useTourConfiguration } from '../data/hooks';
|
||||
import { fetchDiscussionTours } from './data/thunks';
|
||||
|
||||
function DiscussionsProductTour({ intl }) {
|
||||
const dispatch = useDispatch();
|
||||
const config = useTourConfiguration(intl);
|
||||
useEffect(() => {
|
||||
dispatch(fetchDiscussionTours());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isEmpty(config) && (
|
||||
<ProductTour
|
||||
tours={config}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
DiscussionsProductTour.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(DiscussionsProductTour);
|
||||
14
src/discussions/tours/constants.js
Normal file
14
src/discussions/tours/constants.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import messages from './messages';
|
||||
|
||||
export default function tourCheckpoints(intl) {
|
||||
return {
|
||||
NOT_RESPONDED_FILTER: [
|
||||
{
|
||||
body: intl.formatMessage(messages.notRespondedFilterTourBody),
|
||||
placement: 'right',
|
||||
target: '#icon-tune',
|
||||
title: intl.formatMessage(messages.notRespondedFilterTourTitle),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
30
src/discussions/tours/data/api.js
Normal file
30
src/discussions/tours/data/api.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
// create constant for the API URL
|
||||
export const getDiscussionTourUrl = () => `${getConfig().LMS_BASE_URL}/api/user_tours/discussion_tours/`;
|
||||
|
||||
/**
|
||||
* getDiscussionTours
|
||||
* This function makes an HTTP GET request to the API to retrieve a list of tours for the authenticated user.
|
||||
* @returns {Promise} - A promise that resolves to the API response data.
|
||||
*/
|
||||
export async function getDiscssionTours() {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getDiscussionTourUrl());
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* updateDiscussionTour
|
||||
* This function makes an HTTP PUT request to the API to update a specific tour for the authenticated user.
|
||||
* @param {number} tourId - The ID of the tour to be updated.
|
||||
* @returns {Promise} - A promise that resolves to the API response data.
|
||||
*/
|
||||
export async function updateDiscussionTour(tourId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.put(`${getDiscussionTourUrl()}${tourId}`, {
|
||||
show_tour: false,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
1
src/discussions/tours/data/index.js
Normal file
1
src/discussions/tours/data/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './slices';
|
||||
218
src/discussions/tours/data/redux.test.js
Normal file
218
src/discussions/tours/data/redux.test.js
Normal file
@@ -0,0 +1,218 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import { initializeStore } from '../../../store';
|
||||
import { getDiscussionTourUrl } from './api';
|
||||
import { selectTours } from './selectors';
|
||||
import {
|
||||
discussionsTourRequest,
|
||||
discussionsToursRequestError,
|
||||
fetchUserDiscussionsToursSuccess,
|
||||
toursReducer,
|
||||
updateUserDiscussionsTourSuccess,
|
||||
} from './slices';
|
||||
import { fetchDiscussionTours, updateTourShowStatus } from './thunks';
|
||||
import discussionTourFactory from './tours.factory';
|
||||
|
||||
let mockAxios;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let store;
|
||||
const url = getDiscussionTourUrl();
|
||||
describe('DiscussionToursThunk', () => {
|
||||
let actualActions;
|
||||
|
||||
const dispatch = (action) => {
|
||||
actualActions.push(action);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
mockAxios = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = initializeStore();
|
||||
actualActions = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockAxios.reset();
|
||||
});
|
||||
|
||||
it('dispatches get request, success actions', async () => {
|
||||
const mockData = discussionTourFactory.buildList(2);
|
||||
mockAxios.onGet(url)
|
||||
.reply(200, mockData);
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
payload: undefined,
|
||||
type: 'userDiscussionsTours/discussionsTourRequest',
|
||||
},
|
||||
{
|
||||
type: 'userDiscussionsTours/fetchUserDiscussionsToursSuccess',
|
||||
payload: mockData,
|
||||
},
|
||||
];
|
||||
await fetchDiscussionTours()(dispatch);
|
||||
expect(actualActions)
|
||||
.toEqual(expectedActions);
|
||||
});
|
||||
|
||||
it('dispatches request, and error actions', async () => {
|
||||
mockAxios.onGet('/api/discussion-tours/')
|
||||
.reply(500);
|
||||
const errorAction = [{
|
||||
payload: undefined,
|
||||
type: 'userDiscussionsTours/discussionsTourRequest',
|
||||
}, {
|
||||
payload: undefined,
|
||||
type: 'userDiscussionsTours/discussionsToursRequestError',
|
||||
}];
|
||||
|
||||
await fetchDiscussionTours()(dispatch);
|
||||
expect(actualActions)
|
||||
.toEqual(errorAction);
|
||||
});
|
||||
|
||||
it('dispatches put request, success actions', async () => {
|
||||
const mockData = discussionTourFactory.build();
|
||||
mockAxios.onPut(`${url}${1}`)
|
||||
.reply(200, mockData);
|
||||
|
||||
const expectedActions = [
|
||||
{
|
||||
payload: undefined,
|
||||
type: 'userDiscussionsTours/discussionsTourRequest',
|
||||
},
|
||||
{
|
||||
type: 'userDiscussionsTours/updateUserDiscussionsTourSuccess',
|
||||
payload: mockData,
|
||||
},
|
||||
];
|
||||
await updateTourShowStatus(1)(dispatch);
|
||||
expect(actualActions)
|
||||
.toEqual(expectedActions);
|
||||
});
|
||||
|
||||
it('dispatches update request, and error actions', async () => {
|
||||
mockAxios.onPut(`${url}${1}`)
|
||||
.reply(500);
|
||||
const errorAction = [{
|
||||
payload: undefined,
|
||||
type: 'userDiscussionsTours/discussionsTourRequest',
|
||||
}, {
|
||||
payload: undefined,
|
||||
type: 'userDiscussionsTours/discussionsToursRequestError',
|
||||
}];
|
||||
|
||||
await updateTourShowStatus(1)(dispatch);
|
||||
expect(actualActions)
|
||||
.toEqual(errorAction);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toursReducer', () => {
|
||||
it('handles the discussionsToursRequest action', () => {
|
||||
const initialState = {
|
||||
tours: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
const state = toursReducer(initialState, discussionsTourRequest());
|
||||
expect(state)
|
||||
.toEqual({
|
||||
tours: [],
|
||||
loading: RequestStatus.IN_PROGRESS,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles the fetchUserDiscussionsToursSuccess action', () => {
|
||||
const initialState = {
|
||||
tours: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
const mockData = [{ id: 1 }, { id: 2 }];
|
||||
const state = toursReducer(initialState, fetchUserDiscussionsToursSuccess(mockData));
|
||||
expect(state)
|
||||
.toEqual({
|
||||
tours: mockData,
|
||||
loading: RequestStatus.SUCCESSFUL,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles the updateUserDiscussionsTourSuccess action', () => {
|
||||
const initialState = {
|
||||
tours: {
|
||||
tours: [{ id: 1 }, { id: 2 }],
|
||||
loading: true,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
const updatedTour = {
|
||||
id: 2,
|
||||
name: 'Updated Tour',
|
||||
};
|
||||
const state = toursReducer(initialState, updateUserDiscussionsTourSuccess(updatedTour));
|
||||
expect(state.tours)
|
||||
.toEqual({
|
||||
tours: [{ id: 1 }, updatedTour],
|
||||
loading: RequestStatus.SUCCESSFUL,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles the discussionsToursRequestError action', () => {
|
||||
const initialState = {
|
||||
tours: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
const mockError = new Error('Something went wrong');
|
||||
const state = toursReducer(initialState, discussionsToursRequestError(mockError));
|
||||
expect(state)
|
||||
.toEqual({
|
||||
tours: [],
|
||||
loading: RequestStatus.FAILED,
|
||||
error: mockError,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tourSelector', () => {
|
||||
it('returns the tours list from state', () => {
|
||||
const state = {
|
||||
tours: {
|
||||
tours: [
|
||||
{ id: 1, tourName: 'not_responded_filter' },
|
||||
{ id: 2, tourName: 'other_filter' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const expectedResult = [
|
||||
{ id: 1, tourName: 'not_responded_filter' },
|
||||
{ id: 2, tourName: 'other_filter' },
|
||||
];
|
||||
expect(selectTours(state)).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('returns an empty list if the tours state is not defined', () => {
|
||||
const state = {
|
||||
tours: {
|
||||
tours: [],
|
||||
},
|
||||
};
|
||||
expect(selectTours(state))
|
||||
.toEqual([]);
|
||||
});
|
||||
});
|
||||
2
src/discussions/tours/data/selectors.js
Normal file
2
src/discussions/tours/data/selectors.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const selectTours = (state) => state.tours.tours;
|
||||
44
src/discussions/tours/data/slices.js
Normal file
44
src/discussions/tours/data/slices.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable no-param-reassign,import/prefer-default-export */
|
||||
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
|
||||
const userDiscussionsToursSlice = createSlice({
|
||||
name: 'userDiscussionsTours',
|
||||
initialState: {
|
||||
tours: [],
|
||||
loading: RequestStatus.SUCCESSFUL,
|
||||
error: null,
|
||||
},
|
||||
reducers: {
|
||||
discussionsTourRequest: (state) => {
|
||||
state.loading = RequestStatus.IN_PROGRESS;
|
||||
state.error = null;
|
||||
},
|
||||
fetchUserDiscussionsToursSuccess: (state, action) => {
|
||||
state.tours = action.payload;
|
||||
state.loading = RequestStatus.SUCCESSFUL;
|
||||
state.error = null;
|
||||
},
|
||||
discussionsToursRequestError: (state, action) => {
|
||||
state.loading = RequestStatus.FAILED;
|
||||
state.error = action.payload;
|
||||
},
|
||||
updateUserDiscussionsTourSuccess: (state, action) => {
|
||||
const tourIndex = state.tours.tours.findIndex(tour => tour.id === action.payload.id);
|
||||
state.tours.tours[tourIndex] = action.payload;
|
||||
state.tours.loading = RequestStatus.SUCCESSFUL;
|
||||
state.tours.error = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
discussionsTourRequest,
|
||||
fetchUserDiscussionsToursSuccess,
|
||||
discussionsToursRequestError,
|
||||
updateUserDiscussionsTourSuccess,
|
||||
} = userDiscussionsToursSlice.actions;
|
||||
|
||||
export const toursReducer = userDiscussionsToursSlice.reducer;
|
||||
46
src/discussions/tours/data/thunks.js
Normal file
46
src/discussions/tours/data/thunks.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
import { getDiscssionTours, updateDiscussionTour } from './api';
|
||||
import {
|
||||
discussionsTourRequest,
|
||||
discussionsToursRequestError,
|
||||
fetchUserDiscussionsToursSuccess,
|
||||
updateUserDiscussionsTourSuccess,
|
||||
} from './slices';
|
||||
|
||||
/**
|
||||
* Action thunk to fetch the list of discussion tours for the current user.
|
||||
* @returns {function} - Thunk that dispatches the request, success, and error actions.
|
||||
*/
|
||||
export function fetchDiscussionTours() {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(discussionsTourRequest());
|
||||
const data = await getDiscssionTours();
|
||||
dispatch(fetchUserDiscussionsToursSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
dispatch(discussionsToursRequestError());
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action thunk to update the show_tour field for a specific discussion tour for the current user.
|
||||
* @param {number} tourId - The ID of the tour to update.
|
||||
* @returns {function} - Thunk that dispatches the request, success, and error actions.
|
||||
*/
|
||||
|
||||
export function updateTourShowStatus(tourId) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(discussionsTourRequest());
|
||||
const data = await updateDiscussionTour(tourId);
|
||||
dispatch(updateUserDiscussionsTourSuccess(camelCaseObject(data)));
|
||||
} catch (error) {
|
||||
dispatch(discussionsToursRequestError());
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
8
src/discussions/tours/data/tours.factory.js
Normal file
8
src/discussions/tours/data/tours.factory.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
const discussionTourFactory = new Factory()
|
||||
.sequence('id')
|
||||
.attr('name', ['id'], (id) => `Discussion Tour ${id}`)
|
||||
.attr('description', ['id'], (id) => `This is the description for Discussion Tour ${id}.`);
|
||||
|
||||
export default discussionTourFactory;
|
||||
31
src/discussions/tours/messages.js
Normal file
31
src/discussions/tours/messages.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
advanceButtonText: {
|
||||
id: 'tour.action.advance',
|
||||
defaultMessage: 'Next',
|
||||
description: 'Action to go to next step of tour',
|
||||
},
|
||||
dismissButtonText: {
|
||||
id: 'tour.action.dismiss',
|
||||
defaultMessage: 'Dismiss',
|
||||
description: 'Action to dismiss current tour',
|
||||
},
|
||||
endButtonText: {
|
||||
id: 'tour.action.end',
|
||||
defaultMessage: 'Okay',
|
||||
description: 'Action to end current tour',
|
||||
},
|
||||
notRespondedFilterTourBody: {
|
||||
id: 'tour.body.notRespondedFilter',
|
||||
defaultMessage: 'Now you can filter discussions to find posts with no response.',
|
||||
description: 'Body of the tour for the not responded filter',
|
||||
},
|
||||
notRespondedFilterTourTitle: {
|
||||
id: 'tour.title.notRespondedFilter',
|
||||
defaultMessage: 'New filtering option!',
|
||||
description: 'Title of the tour for the not responded filter',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -281,3 +281,17 @@ export function inBlackoutDateRange(blackoutDateRanges) {
|
||||
(blackoutDateRange) => dateInDateRange(now, new Date(blackoutDateRange.start), new Date(blackoutDateRange.end)),
|
||||
);
|
||||
}
|
||||
|
||||
export function handleKeyDown(event) {
|
||||
const { key } = event;
|
||||
if (key !== 'ArrowDown' && key !== 'ArrowUp') { return; }
|
||||
const option = event.target;
|
||||
|
||||
let selectedOption;
|
||||
if (key === 'ArrowDown') { selectedOption = option.nextElementSibling; }
|
||||
if (key === 'ArrowUp') { selectedOption = option.previousElementSibling; }
|
||||
|
||||
if (selectedOption) {
|
||||
selectedOption.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,182 +1,205 @@
|
||||
{
|
||||
"navigation.course.tabs.label": "Course Material",
|
||||
"learn.course.tabs.navigation.overflow.menu": "More...",
|
||||
"discussions.comments.comment.addResponse": "Add a response",
|
||||
"discussions.comments.comment.addComment": "Add a comment",
|
||||
"discussions.comments.comment.abuseFlaggedMessage": "Content reported for staff to review",
|
||||
"discussions.actions.back.alt": "Back to list",
|
||||
"discussions.comments.comment.responseCount": "{num, plural,\n =0 {No responses}\n one {Showing # response}\n other {Showing # responses}\n }",
|
||||
"discussions.comments.comment.endorsedResponseCount": "{num, plural,\n =0 {No endorsed responses}\n one {Showing # endorsed response}\n other {Showing # endorsed responses}\n }",
|
||||
"discussions.comments.comment.loadMoreComments": "Load more comments",
|
||||
"discussions.comments.comment.loadMoreResponses": "Load more responses",
|
||||
"discussions.comments.comment.visibility": "This post is visible to {group, select,\n null {Everyone}\n other {{group}}\n }.",
|
||||
"discussions.comments.comment.postedTime": "{postType, select,\n discussion {Discussion}\n question {Question}\n other {{postType}}\n } posted {relativeTime} by",
|
||||
"discussions.comments.comment.commentTime": "Posted {relativeTime}",
|
||||
"discussions.comments.comment.answer": "Answer",
|
||||
"discussions.comments.comment.answeredlabel": "Marked as answered by",
|
||||
"discussions.comments.comment.endorsed": "Endorsed",
|
||||
"discussions.comments.comment.endorsedlabel": "Endorsed by",
|
||||
"discussions.actions.label": "Actions menu",
|
||||
"discussions.actions.edit": "Edit",
|
||||
"discussions.actions.pin": "Pin",
|
||||
"discussions.actions.delete": "Delete",
|
||||
"discussions.editor.submit": "Submit",
|
||||
"discussions.editor.submitting": "Submitting",
|
||||
"discussions.editor.cancel": "Cancel",
|
||||
"discussions.editor.error.empty": "Post content cannot be empty.",
|
||||
"discussions.editor.delete.response.title": "Delete response",
|
||||
"discussions.editor.delete.response.description": "Are you sure you want to permanently delete this response?",
|
||||
"discussions.editor.delete.comment.title": "Delete comment",
|
||||
"discussions.editor.delete.comment.description": "Are you sure you want to permanently delete this comment?",
|
||||
"discussions.editor.comments.editReasonCode": "Reason for editing",
|
||||
"discussions.editor.posts.editReasonCode.error": "Select reason for editing",
|
||||
"discussions.comment.comments.editedBy": "Edited by",
|
||||
"discussions.comment.comments.reason": "Reason",
|
||||
"discussions.post.closedBy": "Post closed by",
|
||||
"discussion.comment.repliesHeading": "{count} replies for the response added",
|
||||
"discussion.comment.time": "{time} ago",
|
||||
"discussions.learner.reported": "{reported} reported",
|
||||
"discussions.learner.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.learner.lastLogin": "Last active {lastActiveTime}",
|
||||
"discussions.learner.loadMostLearners": "Load more",
|
||||
"discussions.learner.back": "Back",
|
||||
"discussions.learner.activityForLearner": "Activity for {username}",
|
||||
"discussions.learner.mostActivity": "Most activity",
|
||||
"discussions.learner.reportedActivity": "Reported activity",
|
||||
"discussions.learner.recentActivity": "Recent activity",
|
||||
"discussions.learner.sortFilterStatus": "All learners sorted by {sort, select,\n flagged {reported activity}\n activity {most activity}\n other {{sort}}\n }",
|
||||
"discussion.learner.allActivity": "All activity",
|
||||
"discussion.learner.posts": "Posts",
|
||||
"discussions.actions.button.alt": "Actions menu",
|
||||
"discussions.actions.copylink": "Copy link",
|
||||
"discussions.actions.unpin": "Unpin",
|
||||
"discussions.actions.close": "Close",
|
||||
"discussions.actions.reopen": "Reopen",
|
||||
"discussions.actions.report": "Report",
|
||||
"discussions.actions.unreport": "Unreport",
|
||||
"discussions.actions.endorse": "Endorse",
|
||||
"discussions.actions.unendorse": "Unendorse",
|
||||
"discussions.actions.markAnswered": "Mark as answered",
|
||||
"discussions.actions.unMarkAnswered": "Unmark as answered",
|
||||
"discussions.delete.confirmation.button.cancel": "Cancel",
|
||||
"discussions.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.empty.allTopics": "All discussion activity for these topics will show up here.",
|
||||
"discussions.empty.allPosts": "All discussion activity for your course will show up here.",
|
||||
"discussions.empty.myPosts": "Posts you've interacted with will show up here.",
|
||||
"discussions.empty.topic": "All discussion activity for this topic will show up here.",
|
||||
"discussions.empty.title": "Nothing here yet",
|
||||
"discussions.empty.noPostSelected": "No post selected",
|
||||
"discussions.empty.noTopicSelected": "No topic selected",
|
||||
"discussions.sidebar.noResultsFound": "No results found",
|
||||
"discussions.sidebar.differentKeywords": "Try searching different keywords",
|
||||
"discussions.sidebar.removeKeywords": "Try searching different keywords or removing some filters",
|
||||
"discussions.sidebar.removeKeywordsOnly": "Try searching different keywords",
|
||||
"discussions.sidebar.removeFilters": "Try removing some filters",
|
||||
"discussions.empty.iconAlt": "Empty",
|
||||
"discussions.authors.label.staff": "Staff",
|
||||
"discussions.authors.label.ta": "TA",
|
||||
"discussions.learner.loadMostPosts": "Load more posts",
|
||||
"discussions.post.anonymous.author": "anonymous",
|
||||
"discussion.banner.welcomeMessage": "🎉 Welcome to the new and improved discussions experience!",
|
||||
"discussion.banner.learnMore": "Learn more",
|
||||
"discussion.banner.shareFeedback": "Share feedback",
|
||||
"discussion.blackoutBanner.information": "Blackout dates are currently active. Posting in discussions is unavailable at this time.",
|
||||
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
|
||||
"discussions.editor.image.warning.title": "Warning!",
|
||||
"discussions.editor.image.warning.dismiss": "Ok",
|
||||
"discussions.navigation.breadcrumbMenu.allTopics": "Topics",
|
||||
"discussions.navigation.breadcrumbMenu.showAll": "Show all",
|
||||
"discussions.navigation.navigationBar.allPosts": "All posts",
|
||||
"discussions.navigation.navigationBar.allTopics": "Topics",
|
||||
"discussions.navigation.navigationBar.myPosts": "My posts",
|
||||
"discussions.navigation.navigationBar.learners": "Learners",
|
||||
"discussions.app.title": "Discussions",
|
||||
"discussions.posts.actionBar.searchAllPosts": "Search all posts",
|
||||
"discussions.posts.actionBar.search": "{page, select,\n topics {Search topics}\n posts {Search all posts}\n learners {Search learners}\n myPosts {Search all posts}\n other {{page}}\n }",
|
||||
"discussions.actionBar.searchInfo": "Showing {count} results for \"{text}\"",
|
||||
"discussions.actionBar.searchRewriteInfo": "No results found for \"{searchString}\". Showing {count} results for \"{textSearchRewrite}\".",
|
||||
"discussions.actionBar.searchInfoSearching": "Searching...",
|
||||
"discussions.actionBar.clearSearch": "Clear results",
|
||||
"discussion.posts.actionBar.add": "Add a post",
|
||||
"discussion.posts.actionBar.close": "Close",
|
||||
"discussions.post.editor.type": "Post type",
|
||||
"discussions.post.editor.addPostHeading": "Add a post",
|
||||
"discussions.post.editor.editPostHeading": "Edit post",
|
||||
"discussions.post.editor.typeDescription": "Questions raise issues that need answers. Discussions share ideas and start conversations.",
|
||||
"discussions.post.editor.required": "Required",
|
||||
"discussions.post.editor.questionType": "Question",
|
||||
"discussions.post.editor.questionDescription": "Raise issues that need answers",
|
||||
"discussions.post.editor.discussionType": "Discussion",
|
||||
"discussions.post.editor.discussionDescription": "Share ideas and start conversations",
|
||||
"discussions.post.editor.topicArea": "Topic area",
|
||||
"discussions.post.editor.topicAreaDescription": "Add your post to a relevant topic to help others find it.",
|
||||
"discussions.post.editor.cohortVisibility": "Cohort visibility",
|
||||
"discussions.post.editor.cohortVisibilityAllLearners": "All learners",
|
||||
"discussions.post.editor.title": "Post title",
|
||||
"discussions.post.editor.titleDescription": "Add a clear and descriptive title to encourage participation.",
|
||||
"discussions.post.editor.title.error": "Post title cannot be empty.",
|
||||
"discussions.post.editor.content.error": "Post content cannot be empty.",
|
||||
"discussions.post.editor.questionText": "Your question or idea (required)",
|
||||
"discussions.post.editor.preview": "Preview",
|
||||
"discussions.post.editor.followPost": "Follow this post",
|
||||
"discussions.post.editor.anonymousPost": "Post anonymously",
|
||||
"discussions.post.editor.anonymousToPeersPost": "Post anonymously to peers",
|
||||
"discussions.editor.posts.editReasonCode": "Reason for editing",
|
||||
"discussions.editor.posts.showPreview.button": "Show Preview",
|
||||
"discussions.topic.noName.label": "Unnamed category",
|
||||
"discussions.subtopic.noName.label": "Unnamed subcategory",
|
||||
"discussions.posts.filter.showALl": "Show all",
|
||||
"discussions.posts.filter.discussions": "Discussions",
|
||||
"discussions.posts.filter.questions": "Questions",
|
||||
"discussions.posts.filter.message": "Status: {filterBy}",
|
||||
"discussions.posts.status.filter.anyStatus": "Any status",
|
||||
"discussions.posts.status.filter.unread": "Unread",
|
||||
"discussions.posts.status.filter.following": "Following",
|
||||
"discussions.posts.status.filter.reported": "Reported",
|
||||
"discussions.posts.status.filter.unanswered": "Unanswered",
|
||||
"discussions.posts.status.filter.unresponded": "Not responded",
|
||||
"discussions.posts.filter.myPosts": "My posts",
|
||||
"discussions.posts.filter.myDiscussions": "My discussions",
|
||||
"discussions.posts.filter.myQuestions": "My questions",
|
||||
"discussions.posts.sort.message": "Sorted by {sortBy}",
|
||||
"discussions.posts.sort.lastActivity": "Recent activity",
|
||||
"discussions.posts.sort.commentCount": "Most activity",
|
||||
"discussions.posts.sort.voteCount": "Most likes",
|
||||
"discussions.posts.sort-filter.sortFilterStatus": "{own, select,\n false {All}\n true {Own}\n other {{own}}\n } {status, select,\n statusAll {}\n statusUnread {unread}\n statusFollowing {followed}\n statusReported {reported}\n statusUnanswered {unanswered}\n statusUnresponded {unresponded}\n other {{status}}\n } {type, select,\n discussion {discussions}\n question {questions}\n all {posts}\n other {{type}}\n } {cohortType, select,\n all {}\n group {in {cohort}}\n other {{cohortType}}\n } sorted by {sort, select,\n lastActivityAt {recent activity}\n commentCount {most activity}\n voteCount {most likes}\n other {{sort}}\n }",
|
||||
"discussions.post.author.anonymous": "anonymous",
|
||||
"discussions.post.lastResponse": "Last response {time}",
|
||||
"discussions.post.postedOn": "Posted {time} by {author} {authorLabel}",
|
||||
"discussions.post.contentReported": "Reported",
|
||||
"discussions.post.following": "Following",
|
||||
"discussions.post.follow": "Follow",
|
||||
"discussions.post.answered": "Answered",
|
||||
"discussions.post.unFollow": "Unfollow",
|
||||
"discussions.post.like": "Like",
|
||||
"discussions.post.removeLike": "Unlike",
|
||||
"discussions.post.viewActivity": "View activity",
|
||||
"discussions.post.closed": "Post closed for responses and comments",
|
||||
"discussions.post.relatedTo": "Related to",
|
||||
"discussions.editor.delete.post.title": "Delete post",
|
||||
"discussions.editor.delete.post.description": "Are you sure you want to permanently delete this post?",
|
||||
"discussions.post.closePostModal.title": "Close post",
|
||||
"discussions.post.closePostModal.text": "Enter a reason for closing this post. This will only be displayed to other moderators.",
|
||||
"discussions.post.closePostModal.reasonCodeInput": "Reason",
|
||||
"discussions.post.closePostModal.cancel": "Cancel",
|
||||
"discussions.post.closePostModal.confirm": "Close post",
|
||||
"discussions.post.label.new": "{count} New",
|
||||
"discussions.post.editedBy": "Edited by",
|
||||
"discussions.post.editReason": "Reason",
|
||||
"discussions.post.postWithoutPreview": "No preview available",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} reported",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.topics.sort.message": "Sorted by {sortBy}",
|
||||
"discussions.topics.sort.lastActivity": "Recent activity",
|
||||
"discussions.topics.sort.commentCount": "Most activity",
|
||||
"discussions.topics.sort.courseStructure": "Course Structure",
|
||||
"discussions.topics.find.label": "Search topics",
|
||||
"discussions.topics.archived.label": "Archived",
|
||||
"discussions.topics.unnamed.label": "Unnamed category",
|
||||
"discussions.subtopics.unnamed.label": "Unnamed subcategory"
|
||||
"navigation.course.tabs.label": "مواد المساق",
|
||||
"learn.course.tabs.navigation.overflow.menu": "المزيد...",
|
||||
"discussions.comments.comment.addResponse": "إضافة رد",
|
||||
"discussions.comments.comment.addComment": "إضافة تعليق ",
|
||||
"discussions.comments.comment.abuseFlaggedMessage": "تم إبلاغ الطاقم عن هذا المحتوى لمراجعته.",
|
||||
"discussions.actions.back.alt": "العودة إلى القائمة",
|
||||
"discussions.comments.comment.responseCount": "{num، plural, =0 {دون رد} one {تم إظهار ردّ واحد} two {تم إظهار ردّين} few {تم إظهار # ردود} many {تم إظهار # ردًا} other {تم إظهار # ردود}}",
|
||||
"discussions.comments.comment.endorsedResponseCount": "{num، plural, =0 {لا ردود معتمدة} one {تم إظهار ردّ واحد معتمد} two {تم إظهار ردّين معتمدين} few {تم إظهار # ردود معتمدة} many {تم إظهار # ردًا معتمدًا} other {تم إظهار # ردود معتمدة}}",
|
||||
"discussions.comments.comment.loadMoreComments": "تحميل المزيد من التعليقات",
|
||||
"discussions.comments.comment.loadMoreResponses": "تحميل المزيد من الردود",
|
||||
"discussions.comments.comment.visibility": "هذه المشاركة تظهر {group، select، null {للجميع} other {لـ {group}}.",
|
||||
"discussions.comments.comment.postedTime": "تم نشر {postType، select، discussion {المناقشة} question {المنشور} other {{postType}} {relativeTime} من طرف",
|
||||
"discussions.comments.comment.commentTime": "تم النشر {relativeTime}",
|
||||
"discussions.comments.comment.answer": "الإجابة",
|
||||
"discussions.comments.comment.answeredlabel": "تم تعليمها كمُجابة من طرف",
|
||||
"discussions.comments.comment.endorsed": "معتمد",
|
||||
"discussions.comments.comment.endorsedlabel": "اعتمده",
|
||||
"discussions.actions.label": "قائمة الإجراءات",
|
||||
"discussions.actions.edit": "تعديل",
|
||||
"discussions.actions.pin": "تثبيت",
|
||||
"discussions.actions.delete": "حذف",
|
||||
"discussions.editor.submit": "إرسال",
|
||||
"discussions.editor.submitting": "الإرسال جارٍ",
|
||||
"discussions.editor.cancel": "إلغاء",
|
||||
"discussions.editor.error.empty": "لا يمكن أن يكون محتوى المنشور فارغًا.",
|
||||
"discussions.editor.delete.response.title": "حذف الرد",
|
||||
"discussions.editor.delete.response.description": "هل أنت متأكد من رغبتك في حذف هذا الردّ نهائيًا؟",
|
||||
"discussions.editor.delete.comment.title": "حذف التعليق",
|
||||
"discussions.editor.delete.comment.description": "هل أنت متأكد من رغبتك في حذف هذا التعليق نهائيا؟",
|
||||
"discussions.delete.confirmation.button.delete": "حذف",
|
||||
"discussions.editor.response.response.title": "أتريد الإبلاغ عن محتوى غير لائق؟",
|
||||
"discussions.editor.response.description": "سيراجع فريق الإشراف على المناقشة هذا المحتوى و يتخذ الإجراء المناسب.",
|
||||
"discussions.editor.report.comment.title": "أتريد الإبلاغ عن محتوى غير لائق؟",
|
||||
"discussions.editor.report.comment.description": "سيراجع فريق الإشراف على المناقشة هذا المحتوى ويتخذ الإجراء المناسب.",
|
||||
"discussions.editor.comments.editReasonCode": "سبب التعديل",
|
||||
"discussions.editor.posts.editReasonCode.error": "حدد سبب التعديل",
|
||||
"discussions.comment.comments.editedBy": "عدّله",
|
||||
"discussions.comment.comments.reason": "السبب",
|
||||
"discussions.post.closedBy": "تم إقفال المنشور من طرف",
|
||||
"discussion.comment.repliesHeading": "{count, plural, =0 {لم يضف أي رد} one {أضيف رد واحد} two {أضيف ردان} few {أضيفت # ردود} many {أضيف # ردًا} other {أضيفت # ردود} على الرد",
|
||||
"discussion.comment.time": "منذ {time}",
|
||||
"discussion.thread.notFound": "المناقشة غير موجودة",
|
||||
"discussions.topics.backAlt": "Back to topics list",
|
||||
"discussions.topics.discussions": "{count، plural, =0 {لا مناقشات} one {مناقشة واحدة} two {مناقشتان} few {# مناقشات} many {# مناقشة} other {# مناقشات}",
|
||||
"discussions.topics.questions": "{count، plural, =0 {لا مناقشات} one {سؤال واحد} two {سؤالان} few {# اسئلة} many {# سؤالًا} other {# أسئلة}",
|
||||
"discussions.topics.reported": "تم الإبلاغ عن {reported}",
|
||||
"discussions.topics.previouslyReported": "تم الإبلاغ عن {previouslyReported} من قبل",
|
||||
"discussions.topics.find.label": "البحث في المواضيع",
|
||||
"discussions.topics.unnamed.section.label": "Unnamed Section",
|
||||
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
|
||||
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
|
||||
"discussions.topics.title": "No topic exists",
|
||||
"discussions.topics.createTopic": "Please contact you admin to create a topic",
|
||||
"discussions.topics.nothing": "Nothing here yet",
|
||||
"discussions.topics.archived.label": "مؤرشف",
|
||||
"discussions.learner.reported": "تم الإبلاغ عن {reported}",
|
||||
"discussions.learner.previouslyReported": "تم الإبلاغ عن {previouslyReported} من قبل",
|
||||
"discussions.learner.lastLogin": "آخر نشاط {lastActiveTime}",
|
||||
"discussions.learner.loadMostLearners": "تحميل المزيد",
|
||||
"discussions.learner.back": "عودة",
|
||||
"discussions.learner.activityForLearner": "نشاط {username}",
|
||||
"discussions.learner.mostActivity": "الأكثر نشاطًا",
|
||||
"discussions.learner.reportedActivity": "النشاطات المبلغ عنها",
|
||||
"discussions.learner.recentActivity": "الأحدث نشاطًا",
|
||||
"discussions.learner.sortFilterStatus": "جميع المتعلمين مرتبين حسب {sort, select,\n flagged {النشاطات المبلّغ غنها}\n activity {الأكثر نشاطًا}\n other {{sort}}\n }",
|
||||
"discussion.learner.allActivity": "كل النشاط",
|
||||
"discussion.learner.posts": "المنشورات",
|
||||
"discussions.actions.button.alt": "قائمة الإجراءات",
|
||||
"discussions.actions.copylink": "نسخ الرابط",
|
||||
"discussions.actions.unpin": "إلغاء التثبيت",
|
||||
"discussions.confirmation.button.confirm": "تأكيد",
|
||||
"discussions.actions.close": "إقفال ",
|
||||
"discussions.actions.reopen": "إعادة الفتح",
|
||||
"discussions.actions.report": "إبلاغ",
|
||||
"discussions.actions.unreport": "سحب البلاغ",
|
||||
"discussions.actions.endorse": "اعتماد",
|
||||
"discussions.actions.unendorse": "إلغاء الاعتماد",
|
||||
"discussions.actions.markAnswered": "وضع علامة \"تمت الإجابة\"",
|
||||
"discussions.actions.unMarkAnswered": "إزالة علامة \"تمت الإجابة\"",
|
||||
"discussions.modal.confirmation.button.cancel": "إلغاء",
|
||||
"discussions.empty.allTopics": "ستظهر هنا جميع مناقشات هذه المواضيع.",
|
||||
"discussions.empty.allPosts": "ستظهر هنا جميع مناقشات مساقك.",
|
||||
"discussions.empty.myPosts": "ستظهر المنشورات التي تفاعلت معها هنا.",
|
||||
"discussions.empty.topic": "ستظهر هنا جميع مناقشات هذا الموضوع.",
|
||||
"discussions.empty.title": "لا شيء هنا بعد",
|
||||
"discussions.empty.noPostSelected": "لم يتم تحديد أي منشور",
|
||||
"discussions.empty.noTopicSelected": "لا موضوع محددًا",
|
||||
"discussions.sidebar.noResultsFound": "لم يعثر على نتائج",
|
||||
"discussions.sidebar.differentKeywords": "جرب البحث بكلمات مفتاحية مختلفة",
|
||||
"discussions.sidebar.removeKeywords": "جرب البحث بكلمات مفتاحية مختلفة أو أزل بعض المرشحات",
|
||||
"discussions.sidebar.removeKeywordsOnly": "جرب البحث بكلمات مفتاحية مختلفة",
|
||||
"discussions.sidebar.removeFilters": "جرب إزالة بعض المرشحات",
|
||||
"discussions.empty.iconAlt": "خالٍ",
|
||||
"discussions.authors.label.staff": "عضو طاقم",
|
||||
"discussions.authors.label.ta": "أستاذ مساعد",
|
||||
"discussions.learner.loadMostPosts": "تواريخ تعطيل نشطة حاليا. لا يمكن النشر في المناقشات خلال هذه الفترة.",
|
||||
"discussions.post.anonymous.author": "مجهول",
|
||||
"discussion.banner.welcomeMessage": "🎉 مرحبًا بك في تجربة المناقشات الجديدة والمحسّنة!",
|
||||
"discussion.banner.learnMore": "معرفة المزيد",
|
||||
"discussion.banner.shareFeedback": "شاركنا رأيك",
|
||||
"discussion.blackoutBanner.information": "Posting in discussions is temporarily disabled by the course team",
|
||||
"discussions.editor.image.warning.message": "لن تظهر الصور التي يزيد عرضها أو ارتفاعها عن 999 بكسل عند عرض المنشور أو الرد او التعليق باستخدام مناقشات المساق المضمّنة",
|
||||
"discussions.editor.image.warning.title": "تحذير!",
|
||||
"discussions.editor.image.warning.dismiss": "حسنًا",
|
||||
"discussions.navigation.breadcrumbMenu.allTopics": "المواضيع",
|
||||
"discussions.navigation.breadcrumbMenu.showAll": "عرض الكل",
|
||||
"discussions.navigation.navigationBar.allPosts": "جميع المنشورات",
|
||||
"discussions.navigation.navigationBar.allTopics": "المواضيع",
|
||||
"discussions.navigation.navigationBar.myPosts": "منشوراتي",
|
||||
"discussions.navigation.navigationBar.learners": "المتعلمون",
|
||||
"discussions.app.title": "المناقشات",
|
||||
"discussions.posts.actionBar.searchAllPosts": "البحث في كافّة المنشورات",
|
||||
"discussions.posts.actionBar.search": "{page، select، topics {مواضيع البحث} posts {بحث في كل المشاركات} learners {بحث عن المتعلمين} myPosts} {بحث في كل المشاركات",
|
||||
"discussions.actionBar.searchInfo": "{num، plural, =0 {لا توجد نتائج} one {تم إظهار نتيجة واحدة} two {تم إظهار نتيجتين} few {تم إظهار # نتائج} many {تم إظهار # نتيجة} other {تم إظهار # نتائج}} لـ\"{text}\"",
|
||||
"discussions.actionBar.searchRewriteInfo": "لم يعثر على نتائج لـ \"{searchString}\". {num، plural, =0 {لا توجد نتائج} one {تم إظهار نتيجة واحدة} two {تم إظهار نتيجتين} few {تم إظهار # نتائج} many {تم إظهار # نتيجة} other {تم إظهار # نتائج}} لـ\"{textSearchRewrite}\" ",
|
||||
"discussions.actionBar.searchInfoSearching": "البحث جارٍ...",
|
||||
"discussions.actionBar.clearSearch": "مسح النتائج",
|
||||
"discussion.posts.actionBar.add": "أضف منشورًا",
|
||||
"discussion.posts.actionBar.close": "إغلاق ",
|
||||
"discussions.post.editor.type": "نوع المنشور",
|
||||
"discussions.post.editor.addPostHeading": "أضف منشورًا",
|
||||
"discussions.post.editor.editPostHeading": "تعديل المنشور",
|
||||
"discussions.post.editor.typeDescription": "تطرح الأسئلة القضايا تحتاج إلى إجابات. بينما تفتح المناقشات باب مشاركة الأفكار و بدء المحادثات.",
|
||||
"discussions.post.editor.required": "مطلوب",
|
||||
"discussions.post.editor.questionType": "سؤال",
|
||||
"discussions.post.editor.questionDescription": "اطرح القضايا التي تحتاج إجابات",
|
||||
"discussions.post.editor.discussionType": "مناقشة",
|
||||
"discussions.post.editor.discussionDescription": "شارك الأفكار و ابدء المحادثات",
|
||||
"discussions.post.editor.topicArea": "مجال الموضوع:",
|
||||
"discussions.post.editor.topicAreaDescription": "أضف منشورك إلى موضوع مناسب لمساعدة الآخرين على إيجاده.",
|
||||
"discussions.post.editor.cohortVisibility": "الظهور للأفواج",
|
||||
"discussions.post.editor.cohortVisibilityAllLearners": "جميع المتعلّمين",
|
||||
"discussions.post.editor.title": "عنوان المنشور",
|
||||
"discussions.post.editor.titleDescription": "أضِف عنوانًا واضحًا ومعبّرًا للتشجيع على المشاركة.",
|
||||
"discussions.post.editor.title.error": "لا يمكن لعنوان المنشور أن يكون فارغًا.",
|
||||
"discussions.post.editor.content.error": "لا يمكن لمحتوى المنشور أن يكون فارغًا.",
|
||||
"discussions.post.editor.questionText": "سؤالك أو فكرتك (مطلوب)",
|
||||
"discussions.post.editor.preview": "معاينة",
|
||||
"discussions.post.editor.followPost": "متابعة هذا المنشور",
|
||||
"discussions.post.editor.anonymousPost": "النشر كمجهول",
|
||||
"discussions.post.editor.anonymousToPeersPost": "انشر ﻷقرانك كمجهول",
|
||||
"discussions.editor.posts.editReasonCode": "سبب التعديل",
|
||||
"discussions.editor.posts.showPreview.button": "عرض المعاينة",
|
||||
"discussions.topic.noName.label": "تصنيف دون اسم",
|
||||
"discussions.subtopic.noName.label": "تصنيف فرعي دون اسم",
|
||||
"discussions.posts.filter.showALl": "عرض الكل",
|
||||
"discussions.posts.filter.discussions": "المناقشات",
|
||||
"discussions.posts.filter.questions": "الأسئلة",
|
||||
"discussions.posts.filter.message": "الحالة: {filterBy}",
|
||||
"discussions.posts.status.filter.anyStatus": "أي حالة",
|
||||
"discussions.posts.status.filter.unread": "غير المقروءة",
|
||||
"discussions.posts.status.filter.following": "التي تتابعها",
|
||||
"discussions.posts.status.filter.reported": "مبلّغ عنها",
|
||||
"discussions.posts.status.filter.unanswered": "دون إجابة",
|
||||
"discussions.posts.status.filter.unresponded": "دون رد",
|
||||
"discussions.posts.filter.myPosts": "منشوراتي",
|
||||
"discussions.posts.filter.myDiscussions": "مناقشاتي",
|
||||
"discussions.posts.filter.myQuestions": "أسئلتي",
|
||||
"discussions.posts.sort.message": "مرتبة حسب {sortBy}",
|
||||
"discussions.posts.sort.lastActivity": "الأحدث نشاطًا",
|
||||
"discussions.posts.sort.commentCount": "الأكثر نشاطًا",
|
||||
"discussions.posts.sort.voteCount": "الأكثر إعجابًا",
|
||||
"discussions.posts.sort-filter.sortFilterStatus": "{own, select,\n false {جميع}\n true {ما كتبته من}\n other {{own}}\n } \n {type, select,\ndiscussion {المناقشات}\nquestion {الأسئلة}\nall {المنشورات}\nother {{type}}\n}\n {status, select,\n statusAll {}\n statusUnread {غير المقروءة}\n statusFollowing {التي تتابعها}\n statusReported {المُبلّغ عنها}\n statusUnanswered {دون إجابة}\n statusUnresponded {دون رد}\n other {{status}}\n } و المنشورة {cohortType, select,\n all {}\n group {ضمن {cohort}}\n other {{cohortType}}\n }، مرتبة حسب {sort, select,\n lastActivityAt {أحدث نشاط}\n commentCount {أكثر نشاط}\n voteCount {أكثر إعجاب}\n other {{sort}}\n }",
|
||||
"discussions.post.author.anonymous": "مجهول",
|
||||
"discussions.post.lastResponse": "آخر رد {time}",
|
||||
"discussions.post.postedOn": "منشور في {time} من طرف {author} {authorLabel}",
|
||||
"discussions.post.contentReported": "تم الإبلاغ",
|
||||
"discussions.post.following": "جاري المتابعة",
|
||||
"discussions.post.follow": "متابعة",
|
||||
"discussions.post.followed": "Followed",
|
||||
"discussions.post.notFollowed": "Not Followed",
|
||||
"discussions.post.answered": "تمّت الإجابة",
|
||||
"discussions.post.unFollow": "إلغاء المتابعة",
|
||||
"discussions.post.like": "أعجبني",
|
||||
"discussions.post.removeLike": "إلغاء الإغجاب",
|
||||
"discussions.post.liked": "liked",
|
||||
"discussions.post.likes": "likes",
|
||||
"discussions.post.viewActivity": "عرض النشاط",
|
||||
"discussions.post.activity": "Activity",
|
||||
"discussions.post.closed": "منشور مقفل أمام الردود والتعليقات",
|
||||
"discussions.post.relatedTo": "متعلق بـ",
|
||||
"discussions.editor.delete.post.title": "حذف المنشور",
|
||||
"discussions.editor.delete.post.description": "هل أنت متأكد من رغبتك في حذف هذا المنشور نهائيًا؟",
|
||||
"discussions.post.delete.confirmation.button.delete": "حذف",
|
||||
"discussions.editor.report.post.title": "أتريد الإبلاغ عن محتوى غير لائق؟",
|
||||
"discussions.editor.report.post.description": "سيراجع فريق الإشراف على المناقشة هذا المحتوى و يتخذ الإجراء المناسب.",
|
||||
"discussions.post.closePostModal.title": "إقفال المنشور",
|
||||
"discussions.post.closePostModal.text": "أدخل سبب إقفال هذه المنشور. سيظهر هذا فقط لبقية المشرفين.",
|
||||
"discussions.post.closePostModal.reasonCodeInput": "السبب",
|
||||
"discussions.post.closePostModal.cancel": "إلغاء",
|
||||
"discussions.post.closePostModal.confirm": "إقفال المنشور",
|
||||
"discussions.post.label.new": "{count} جديدة",
|
||||
"discussions.post.editedBy": "عدّله",
|
||||
"discussions.post.editReason": "السبب",
|
||||
"discussions.post.postWithoutPreview": "المعاينة غير متاحة",
|
||||
"discussions.post.follow.description": "you are following this post",
|
||||
"discussions.post.unfollow.description": "you are not following this post",
|
||||
"discussions.topics.sort.message": "مرتبة حسب {sortBy}",
|
||||
"discussions.topics.sort.lastActivity": "الأحدث نشاطًا",
|
||||
"discussions.topics.sort.commentCount": "الاكثر نشاطًا",
|
||||
"discussions.topics.sort.courseStructure": "هيكل المساق",
|
||||
"discussions.topics.unnamed.label": "فئة بدون اسم",
|
||||
"discussions.subtopics.unnamed.label": "فئة فرعية بدون اسم"
|
||||
}
|
||||
@@ -28,6 +28,11 @@
|
||||
"discussions.editor.delete.response.description": "Are you sure you want to permanently delete this response?",
|
||||
"discussions.editor.delete.comment.title": "Delete comment",
|
||||
"discussions.editor.delete.comment.description": "Are you sure you want to permanently delete this comment?",
|
||||
"discussions.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.editor.response.response.title": "Report inappropriate content?",
|
||||
"discussions.editor.response.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.editor.report.comment.title": "Report inappropriate content?",
|
||||
"discussions.editor.report.comment.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.editor.comments.editReasonCode": "Reason for editing",
|
||||
"discussions.editor.posts.editReasonCode.error": "Select reason for editing",
|
||||
"discussions.comment.comments.editedBy": "Edited by",
|
||||
@@ -35,6 +40,20 @@
|
||||
"discussions.post.closedBy": "Post closed by",
|
||||
"discussion.comment.repliesHeading": "{count} replies for the response added",
|
||||
"discussion.comment.time": "{time} ago",
|
||||
"discussion.thread.notFound": "Thread not found",
|
||||
"discussions.topics.backAlt": "Back to topics list",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} reported",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.topics.find.label": "Search topics",
|
||||
"discussions.topics.unnamed.section.label": "Unnamed Section",
|
||||
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
|
||||
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
|
||||
"discussions.topics.title": "No topic exists",
|
||||
"discussions.topics.createTopic": "Please contact you admin to create a topic",
|
||||
"discussions.topics.nothing": "Nothing here yet",
|
||||
"discussions.topics.archived.label": "Archived",
|
||||
"discussions.learner.reported": "{reported} reported",
|
||||
"discussions.learner.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.learner.lastLogin": "Last active {lastActiveTime}",
|
||||
@@ -50,6 +69,7 @@
|
||||
"discussions.actions.button.alt": "Actions menu",
|
||||
"discussions.actions.copylink": "Copy link",
|
||||
"discussions.actions.unpin": "Unpin",
|
||||
"discussions.confirmation.button.confirm": "Confirm",
|
||||
"discussions.actions.close": "Close",
|
||||
"discussions.actions.reopen": "Reopen",
|
||||
"discussions.actions.report": "Report",
|
||||
@@ -58,8 +78,7 @@
|
||||
"discussions.actions.unendorse": "Unendorse",
|
||||
"discussions.actions.markAnswered": "Mark as answered",
|
||||
"discussions.actions.unMarkAnswered": "Unmark as answered",
|
||||
"discussions.delete.confirmation.button.cancel": "Cancel",
|
||||
"discussions.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.modal.confirmation.button.cancel": "Cancel",
|
||||
"discussions.empty.allTopics": "All discussion activity for these topics will show up here.",
|
||||
"discussions.empty.allPosts": "All discussion activity for your course will show up here.",
|
||||
"discussions.empty.myPosts": "Posts you've interacted with will show up here.",
|
||||
@@ -80,7 +99,7 @@
|
||||
"discussion.banner.welcomeMessage": "🎉 Welcome to the new and improved discussions experience!",
|
||||
"discussion.banner.learnMore": "Learn more",
|
||||
"discussion.banner.shareFeedback": "Share feedback",
|
||||
"discussion.blackoutBanner.information": "Blackout dates are currently active. Posting in discussions is unavailable at this time.",
|
||||
"discussion.blackoutBanner.information": "Posting in discussions is temporarily disabled by the course team",
|
||||
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
|
||||
"discussions.editor.image.warning.title": "Warning!",
|
||||
"discussions.editor.image.warning.dismiss": "Ok",
|
||||
@@ -149,15 +168,23 @@
|
||||
"discussions.post.contentReported": "Reported",
|
||||
"discussions.post.following": "Following",
|
||||
"discussions.post.follow": "Follow",
|
||||
"discussions.post.followed": "Followed",
|
||||
"discussions.post.notFollowed": "Not Followed",
|
||||
"discussions.post.answered": "Answered",
|
||||
"discussions.post.unFollow": "Unfollow",
|
||||
"discussions.post.like": "Like",
|
||||
"discussions.post.removeLike": "Unlike",
|
||||
"discussions.post.liked": "liked",
|
||||
"discussions.post.likes": "likes",
|
||||
"discussions.post.viewActivity": "View activity",
|
||||
"discussions.post.activity": "Activity",
|
||||
"discussions.post.closed": "Post closed for responses and comments",
|
||||
"discussions.post.relatedTo": "Related to",
|
||||
"discussions.editor.delete.post.title": "Delete post",
|
||||
"discussions.editor.delete.post.description": "Are you sure you want to permanently delete this post?",
|
||||
"discussions.post.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.editor.report.post.title": "Report inappropriate content?",
|
||||
"discussions.editor.report.post.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.post.closePostModal.title": "Close post",
|
||||
"discussions.post.closePostModal.text": "Enter a reason for closing this post. This will only be displayed to other moderators.",
|
||||
"discussions.post.closePostModal.reasonCodeInput": "Reason",
|
||||
@@ -167,16 +194,12 @@
|
||||
"discussions.post.editedBy": "Edited by",
|
||||
"discussions.post.editReason": "Reason",
|
||||
"discussions.post.postWithoutPreview": "No preview available",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} reported",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.post.follow.description": "you are following this post",
|
||||
"discussions.post.unfollow.description": "you are not following this post",
|
||||
"discussions.topics.sort.message": "Sorted by {sortBy}",
|
||||
"discussions.topics.sort.lastActivity": "Recent activity",
|
||||
"discussions.topics.sort.commentCount": "Most activity",
|
||||
"discussions.topics.sort.courseStructure": "Course Structure",
|
||||
"discussions.topics.find.label": "Search topics",
|
||||
"discussions.topics.archived.label": "Archived",
|
||||
"discussions.topics.unnamed.label": "Unnamed category",
|
||||
"discussions.subtopics.unnamed.label": "Unnamed subcategory"
|
||||
}
|
||||
@@ -28,6 +28,11 @@
|
||||
"discussions.editor.delete.response.description": "¿Está seguro de que desea eliminar esta respuesta de forma permanente?",
|
||||
"discussions.editor.delete.comment.title": "Eliminar comentario",
|
||||
"discussions.editor.delete.comment.description": "¿Estás seguro de que quieres eliminar este comentario de forma permanente?",
|
||||
"discussions.delete.confirmation.button.delete": "Borrar",
|
||||
"discussions.editor.response.response.title": "Report inappropriate content?",
|
||||
"discussions.editor.response.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.editor.report.comment.title": "Report inappropriate content?",
|
||||
"discussions.editor.report.comment.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.editor.comments.editReasonCode": "Razón de la edición",
|
||||
"discussions.editor.posts.editReasonCode.error": "Seleccione el motivo de la edición",
|
||||
"discussions.comment.comments.editedBy": "Editado por",
|
||||
@@ -35,6 +40,20 @@
|
||||
"discussions.post.closedBy": "Publicación cerrada por",
|
||||
"discussion.comment.repliesHeading": "{count} respuestas para la respuesta añadida",
|
||||
"discussion.comment.time": "hace {time}",
|
||||
"discussion.thread.notFound": "Thread not found",
|
||||
"discussions.topics.backAlt": "Back to topics list",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} informado",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} informado anteriormente",
|
||||
"discussions.topics.find.label": "Buscar temas",
|
||||
"discussions.topics.unnamed.section.label": "Unnamed Section",
|
||||
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
|
||||
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
|
||||
"discussions.topics.title": "No topic exists",
|
||||
"discussions.topics.createTopic": "Please contact you admin to create a topic",
|
||||
"discussions.topics.nothing": "Nothing here yet",
|
||||
"discussions.topics.archived.label": "Archivado",
|
||||
"discussions.learner.reported": "{reported} informado",
|
||||
"discussions.learner.previouslyReported": "{previouslyReported} informado anteriormente",
|
||||
"discussions.learner.lastLogin": "Último activo {lastActiveTime}",
|
||||
@@ -50,6 +69,7 @@
|
||||
"discussions.actions.button.alt": "Menú de acciones",
|
||||
"discussions.actions.copylink": "Copiar link",
|
||||
"discussions.actions.unpin": "Desmarcar",
|
||||
"discussions.confirmation.button.confirm": "Confirm",
|
||||
"discussions.actions.close": "Cerrar",
|
||||
"discussions.actions.reopen": "Reabrir",
|
||||
"discussions.actions.report": "Informar",
|
||||
@@ -58,8 +78,7 @@
|
||||
"discussions.actions.unendorse": "Invalidar",
|
||||
"discussions.actions.markAnswered": "Mark as answered",
|
||||
"discussions.actions.unMarkAnswered": "Desmarcar como respondida",
|
||||
"discussions.delete.confirmation.button.cancel": "Cancelar",
|
||||
"discussions.delete.confirmation.button.delete": "Borrar",
|
||||
"discussions.modal.confirmation.button.cancel": "Cancel",
|
||||
"discussions.empty.allTopics": "Toda la actividad de debate de estos temas se mostrará aquí.",
|
||||
"discussions.empty.allPosts": "Toda la actividad de debate de su curso se mostrará aquí.",
|
||||
"discussions.empty.myPosts": "Las publicaciones con las que has interactuado se mostrarán aquí.",
|
||||
@@ -80,7 +99,7 @@
|
||||
"discussion.banner.welcomeMessage": "🎉 ¡Bienvenido a la nueva y mejorada experiencia de debates!",
|
||||
"discussion.banner.learnMore": "Aprender más",
|
||||
"discussion.banner.shareFeedback": "Compartir comentarios",
|
||||
"discussion.blackoutBanner.information": "Blackout dates are currently active. Posting in discussions is unavailable at this time.",
|
||||
"discussion.blackoutBanner.information": "Posting in discussions is temporarily disabled by the course team",
|
||||
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
|
||||
"discussions.editor.image.warning.title": "Warning!",
|
||||
"discussions.editor.image.warning.dismiss": "Ok",
|
||||
@@ -149,15 +168,23 @@
|
||||
"discussions.post.contentReported": "Informado",
|
||||
"discussions.post.following": "Siguiendo",
|
||||
"discussions.post.follow": "Seguir",
|
||||
"discussions.post.followed": "Followed",
|
||||
"discussions.post.notFollowed": "Not Followed",
|
||||
"discussions.post.answered": "Respondido",
|
||||
"discussions.post.unFollow": "Dejar de seguir",
|
||||
"discussions.post.like": "Me gusta",
|
||||
"discussions.post.removeLike": "Dejar de gustar",
|
||||
"discussions.post.liked": "liked",
|
||||
"discussions.post.likes": "likes",
|
||||
"discussions.post.viewActivity": "Ver actividad",
|
||||
"discussions.post.activity": "Activity",
|
||||
"discussions.post.closed": "Publicación cerrada por respuestas y comentarios.",
|
||||
"discussions.post.relatedTo": "Related to",
|
||||
"discussions.editor.delete.post.title": "Eliminar mensaje",
|
||||
"discussions.editor.delete.post.description": "¿Seguro que quieres eliminar esta publicación de forma permanente?",
|
||||
"discussions.post.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.editor.report.post.title": "Report inappropriate content?",
|
||||
"discussions.editor.report.post.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.post.closePostModal.title": "Cerrar publicación",
|
||||
"discussions.post.closePostModal.text": "Escribe un motivo para cerrar esta publicación. Esto solo se mostrará a otros moderadores.",
|
||||
"discussions.post.closePostModal.reasonCodeInput": "Motivo",
|
||||
@@ -167,16 +194,12 @@
|
||||
"discussions.post.editedBy": "Editado por",
|
||||
"discussions.post.editReason": "Motivo",
|
||||
"discussions.post.postWithoutPreview": "No hay vista previa disponible",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} informado",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} informado anteriormente",
|
||||
"discussions.post.follow.description": "you are following this post",
|
||||
"discussions.post.unfollow.description": "you are not following this post",
|
||||
"discussions.topics.sort.message": "Ordenado por {sortBy}",
|
||||
"discussions.topics.sort.lastActivity": "Actividad reciente",
|
||||
"discussions.topics.sort.commentCount": "La mayoría de la actividad",
|
||||
"discussions.topics.sort.courseStructure": "Estructura del curso",
|
||||
"discussions.topics.find.label": "Buscar temas",
|
||||
"discussions.topics.archived.label": "Archivado",
|
||||
"discussions.topics.unnamed.label": "Unnamed category",
|
||||
"discussions.subtopics.unnamed.label": "Unnamed subcategory"
|
||||
}
|
||||
@@ -28,6 +28,11 @@
|
||||
"discussions.editor.delete.response.description": "Are you sure you want to permanently delete this response?",
|
||||
"discussions.editor.delete.comment.title": "Delete comment",
|
||||
"discussions.editor.delete.comment.description": "Are you sure you want to permanently delete this comment?",
|
||||
"discussions.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.editor.response.response.title": "Report inappropriate content?",
|
||||
"discussions.editor.response.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.editor.report.comment.title": "Report inappropriate content?",
|
||||
"discussions.editor.report.comment.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.editor.comments.editReasonCode": "Reason for editing",
|
||||
"discussions.editor.posts.editReasonCode.error": "Select reason for editing",
|
||||
"discussions.comment.comments.editedBy": "Édité par",
|
||||
@@ -35,6 +40,20 @@
|
||||
"discussions.post.closedBy": "Message fermé par",
|
||||
"discussion.comment.repliesHeading": "{count} réponses pour la réponse ajoutée",
|
||||
"discussion.comment.time": "il y a {time}",
|
||||
"discussion.thread.notFound": "Thread not found",
|
||||
"discussions.topics.backAlt": "Back to topics list",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} reported",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.topics.find.label": "Search topics",
|
||||
"discussions.topics.unnamed.section.label": "Unnamed Section",
|
||||
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
|
||||
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
|
||||
"discussions.topics.title": "No topic exists",
|
||||
"discussions.topics.createTopic": "Please contact you admin to create a topic",
|
||||
"discussions.topics.nothing": "Nothing here yet",
|
||||
"discussions.topics.archived.label": "Archived",
|
||||
"discussions.learner.reported": "{reported} signalé",
|
||||
"discussions.learner.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.learner.lastLogin": "Last active {lastActiveTime}",
|
||||
@@ -50,6 +69,7 @@
|
||||
"discussions.actions.button.alt": "Actions menu",
|
||||
"discussions.actions.copylink": "Copy link",
|
||||
"discussions.actions.unpin": "Unpin",
|
||||
"discussions.confirmation.button.confirm": "Confirm",
|
||||
"discussions.actions.close": "Close",
|
||||
"discussions.actions.reopen": "Reopen",
|
||||
"discussions.actions.report": "Report",
|
||||
@@ -58,8 +78,7 @@
|
||||
"discussions.actions.unendorse": "Unendorse",
|
||||
"discussions.actions.markAnswered": "Mark as answered",
|
||||
"discussions.actions.unMarkAnswered": "Unmark as answered",
|
||||
"discussions.delete.confirmation.button.cancel": "Cancel",
|
||||
"discussions.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.modal.confirmation.button.cancel": "Cancel",
|
||||
"discussions.empty.allTopics": "All discussion activity for these topics will show up here.",
|
||||
"discussions.empty.allPosts": "All discussion activity for your course will show up here.",
|
||||
"discussions.empty.myPosts": "Posts you've interacted with will show up here.",
|
||||
@@ -80,7 +99,7 @@
|
||||
"discussion.banner.welcomeMessage": "🎉 Welcome to the new and improved discussions experience!",
|
||||
"discussion.banner.learnMore": "Learn more",
|
||||
"discussion.banner.shareFeedback": "Share feedback",
|
||||
"discussion.blackoutBanner.information": "Blackout dates are currently active. Posting in discussions is unavailable at this time.",
|
||||
"discussion.blackoutBanner.information": "Posting in discussions is temporarily disabled by the course team",
|
||||
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
|
||||
"discussions.editor.image.warning.title": "Warning!",
|
||||
"discussions.editor.image.warning.dismiss": "Ok",
|
||||
@@ -149,15 +168,23 @@
|
||||
"discussions.post.contentReported": "Reported",
|
||||
"discussions.post.following": "Following",
|
||||
"discussions.post.follow": "Follow",
|
||||
"discussions.post.followed": "Followed",
|
||||
"discussions.post.notFollowed": "Not Followed",
|
||||
"discussions.post.answered": "Answered",
|
||||
"discussions.post.unFollow": "Unfollow",
|
||||
"discussions.post.like": "Like",
|
||||
"discussions.post.removeLike": "Unlike",
|
||||
"discussions.post.liked": "liked",
|
||||
"discussions.post.likes": "likes",
|
||||
"discussions.post.viewActivity": "View activity",
|
||||
"discussions.post.activity": "Activity",
|
||||
"discussions.post.closed": "Post closed for responses and comments",
|
||||
"discussions.post.relatedTo": "Related to",
|
||||
"discussions.editor.delete.post.title": "Delete post",
|
||||
"discussions.editor.delete.post.description": "Are you sure you want to permanently delete this post?",
|
||||
"discussions.post.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.editor.report.post.title": "Report inappropriate content?",
|
||||
"discussions.editor.report.post.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.post.closePostModal.title": "Close post",
|
||||
"discussions.post.closePostModal.text": "Enter a reason for closing this post. This will only be displayed to other moderators.",
|
||||
"discussions.post.closePostModal.reasonCodeInput": "Reason",
|
||||
@@ -167,16 +194,12 @@
|
||||
"discussions.post.editedBy": "Edited by",
|
||||
"discussions.post.editReason": "Reason",
|
||||
"discussions.post.postWithoutPreview": "No preview available",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} reported",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.post.follow.description": "you are following this post",
|
||||
"discussions.post.unfollow.description": "you are not following this post",
|
||||
"discussions.topics.sort.message": "Sorted by {sortBy}",
|
||||
"discussions.topics.sort.lastActivity": "Recent activity",
|
||||
"discussions.topics.sort.commentCount": "Most activity",
|
||||
"discussions.topics.sort.courseStructure": "Course Structure",
|
||||
"discussions.topics.find.label": "Search topics",
|
||||
"discussions.topics.archived.label": "Archived",
|
||||
"discussions.topics.unnamed.label": "Unnamed category",
|
||||
"discussions.subtopics.unnamed.label": "Unnamed subcategory"
|
||||
}
|
||||
@@ -28,6 +28,11 @@
|
||||
"discussions.editor.delete.response.description": "Êtes-vous sûr de vouloir supprimer définitivement cette réponse ?",
|
||||
"discussions.editor.delete.comment.title": "Supprimer le commentaire",
|
||||
"discussions.editor.delete.comment.description": "Êtes-vous sûr de vouloir supprimer définitivement ce commentaire ?",
|
||||
"discussions.delete.confirmation.button.delete": "Supprimer",
|
||||
"discussions.editor.response.response.title": "Signaler un contenu inapproprié ?",
|
||||
"discussions.editor.response.description": "L'équipe de modération de la discussion examinera ce contenu et prendra les mesures appropriées.",
|
||||
"discussions.editor.report.comment.title": "Signaler un contenu inapproprié ?",
|
||||
"discussions.editor.report.comment.description": "L'équipe de modération de la discussion examinera ce contenu et prendra les mesures appropriées.",
|
||||
"discussions.editor.comments.editReasonCode": "Raison de la modification",
|
||||
"discussions.editor.posts.editReasonCode.error": "Sélectionnez la raison de la modification",
|
||||
"discussions.comment.comments.editedBy": "Édité par",
|
||||
@@ -35,6 +40,20 @@
|
||||
"discussions.post.closedBy": "Message fermé par",
|
||||
"discussion.comment.repliesHeading": "{count} réponses pour la réponse ajoutée",
|
||||
"discussion.comment.time": "il y a {time}",
|
||||
"discussion.thread.notFound": "Sujet introuvable",
|
||||
"discussions.topics.backAlt": "Retour à la liste des sujets",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} signalé",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} signalé précédemment",
|
||||
"discussions.topics.find.label": "Rechercher des sujets",
|
||||
"discussions.topics.unnamed.section.label": "Section sans nom",
|
||||
"discussions.topics.unnamed.subsection.label": "Sous-section sans nom",
|
||||
"discussions.subtopics.unnamed.topic.label": "Sujet sans nom",
|
||||
"discussions.topics.title": "Aucun sujet n'existe",
|
||||
"discussions.topics.createTopic": "Veuillez contacter votre administrateur pour créer un sujet",
|
||||
"discussions.topics.nothing": "Rien ici encore",
|
||||
"discussions.topics.archived.label": "Archivé",
|
||||
"discussions.learner.reported": "{reported} signalé",
|
||||
"discussions.learner.previouslyReported": "{previouslyReported} signalé précédemment",
|
||||
"discussions.learner.lastLogin": "Dernier actif {lastActiveTime}",
|
||||
@@ -44,22 +63,22 @@
|
||||
"discussions.learner.mostActivity": "La plupart des activités",
|
||||
"discussions.learner.reportedActivity": "Activité signalée",
|
||||
"discussions.learner.recentActivity": "Activité récente",
|
||||
"discussions.learner.sortFilterStatus": "All learners sorted by {sort, select,\n flagged {reported activity}\n activity {most activity}\n other {{sort}}\n }",
|
||||
"discussions.learner.sortFilterStatus": "Tous les apprenants triés pas {sort, select,\n flagged {reported activity}\n activity {most activity}\n other {{sort}}\n }",
|
||||
"discussion.learner.allActivity": "Toutes les activités",
|
||||
"discussion.learner.posts": "Posts",
|
||||
"discussions.actions.button.alt": "Menu Actions",
|
||||
"discussions.actions.copylink": "Copier le lien",
|
||||
"discussions.actions.unpin": "Détacher",
|
||||
"discussions.confirmation.button.confirm": "Confirmer",
|
||||
"discussions.actions.close": "Fermer",
|
||||
"discussions.actions.reopen": "Rouvrir",
|
||||
"discussions.actions.report": "Signaler",
|
||||
"discussions.actions.unreport": "Ne pas signaler",
|
||||
"discussions.actions.endorse": "Approuver",
|
||||
"discussions.actions.unendorse": "Ne plus approuver",
|
||||
"discussions.actions.markAnswered": "Mark as answered",
|
||||
"discussions.actions.markAnswered": "Marquer comme répondu",
|
||||
"discussions.actions.unMarkAnswered": "Décocher comme répondu",
|
||||
"discussions.delete.confirmation.button.cancel": "Annuler",
|
||||
"discussions.delete.confirmation.button.delete": "Supprimer",
|
||||
"discussions.modal.confirmation.button.cancel": "Annuler",
|
||||
"discussions.empty.allTopics": "Toutes les activités de discussion pour ces sujets apparaîtront ici.",
|
||||
"discussions.empty.allPosts": "Toutes les activités de discussion pour votre cours s'afficheront ici.",
|
||||
"discussions.empty.myPosts": "Les publications avec lesquelles vous avez interagi s'afficheront ici.",
|
||||
@@ -80,7 +99,7 @@
|
||||
"discussion.banner.welcomeMessage": "🎉 Bienvenue dans la nouvelle expérience améliorée de discussions !",
|
||||
"discussion.banner.learnMore": "En savoir plus",
|
||||
"discussion.banner.shareFeedback": "Partager vos commentaires",
|
||||
"discussion.blackoutBanner.information": "Les dates d'interdiction sont actuellement actives. La publication dans les discussions n'est pas disponible pour le moment.",
|
||||
"discussion.blackoutBanner.information": "La publication dans les discussions est temporairement désactivée par l'équipe du cours",
|
||||
"discussions.editor.image.warning.message": "Les images dont la largeur ou la hauteur est supérieure à 999 pixels ne seront pas visibles lorsque la publication, la réponse ou le commentaire est affiché à l'aide de discussions de cours en ligne",
|
||||
"discussions.editor.image.warning.title": "Avertissement!",
|
||||
"discussions.editor.image.warning.dismiss": "Ok",
|
||||
@@ -149,15 +168,23 @@
|
||||
"discussions.post.contentReported": "Signalé",
|
||||
"discussions.post.following": "Suivi",
|
||||
"discussions.post.follow": "Suivre",
|
||||
"discussions.post.followed": "Suivi",
|
||||
"discussions.post.notFollowed": "Non suivi",
|
||||
"discussions.post.answered": "Répondu",
|
||||
"discussions.post.unFollow": "Ne plus suivre",
|
||||
"discussions.post.like": "Aime",
|
||||
"discussions.post.removeLike": "Contrairement à",
|
||||
"discussions.post.liked": "aimé",
|
||||
"discussions.post.likes": "aime",
|
||||
"discussions.post.viewActivity": "Afficher l'activité",
|
||||
"discussions.post.activity": "Activité",
|
||||
"discussions.post.closed": "Message fermé pour réponses et commentaires",
|
||||
"discussions.post.relatedTo": "Relative à",
|
||||
"discussions.editor.delete.post.title": "Supprimer le message",
|
||||
"discussions.editor.delete.post.description": "Êtes-vous sûr de vouloir supprimer définitivement ce message ?",
|
||||
"discussions.post.delete.confirmation.button.delete": "Supprimer",
|
||||
"discussions.editor.report.post.title": "Signaler un contenu inapproprié ?",
|
||||
"discussions.editor.report.post.description": "L'équipe de modération de la discussion examinera ce contenu et prendra les mesures appropriées.",
|
||||
"discussions.post.closePostModal.title": "Fermer le message",
|
||||
"discussions.post.closePostModal.text": "Entrez une raison pour fermer ce message. Cela ne sera affiché qu'aux autres modérateurs.",
|
||||
"discussions.post.closePostModal.reasonCodeInput": "Raison",
|
||||
@@ -167,16 +194,12 @@
|
||||
"discussions.post.editedBy": "Édité par",
|
||||
"discussions.post.editReason": "Raison",
|
||||
"discussions.post.postWithoutPreview": "Aucun aperçu disponible",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} signalé",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} signalé précédemment",
|
||||
"discussions.post.follow.description": "vous suivez ce post",
|
||||
"discussions.post.unfollow.description": "vous ne suivez pas ce post",
|
||||
"discussions.topics.sort.message": "Trié par {sortBy}",
|
||||
"discussions.topics.sort.lastActivity": "Activité récente",
|
||||
"discussions.topics.sort.commentCount": "La plupart des activités",
|
||||
"discussions.topics.sort.courseStructure": "Structure du cours",
|
||||
"discussions.topics.find.label": "Rechercher des sujets",
|
||||
"discussions.topics.archived.label": "Archivé",
|
||||
"discussions.topics.unnamed.label": "Catégorie sans nom",
|
||||
"discussions.subtopics.unnamed.label": "Sous-catégorie sans nom"
|
||||
}
|
||||
@@ -28,6 +28,11 @@
|
||||
"discussions.editor.delete.response.description": "Are you sure you want to permanently delete this response?",
|
||||
"discussions.editor.delete.comment.title": "Delete comment",
|
||||
"discussions.editor.delete.comment.description": "Are you sure you want to permanently delete this comment?",
|
||||
"discussions.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.editor.response.response.title": "Report inappropriate content?",
|
||||
"discussions.editor.response.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.editor.report.comment.title": "Report inappropriate content?",
|
||||
"discussions.editor.report.comment.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.editor.comments.editReasonCode": "Reason for editing",
|
||||
"discussions.editor.posts.editReasonCode.error": "Select reason for editing",
|
||||
"discussions.comment.comments.editedBy": "Edited by",
|
||||
@@ -35,6 +40,20 @@
|
||||
"discussions.post.closedBy": "Post closed by",
|
||||
"discussion.comment.repliesHeading": "{count} replies for the response added",
|
||||
"discussion.comment.time": "{time} ago",
|
||||
"discussion.thread.notFound": "Thread not found",
|
||||
"discussions.topics.backAlt": "Back to topics list",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} reported",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.topics.find.label": "Search topics",
|
||||
"discussions.topics.unnamed.section.label": "Unnamed Section",
|
||||
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
|
||||
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
|
||||
"discussions.topics.title": "No topic exists",
|
||||
"discussions.topics.createTopic": "Please contact you admin to create a topic",
|
||||
"discussions.topics.nothing": "Nothing here yet",
|
||||
"discussions.topics.archived.label": "Archived",
|
||||
"discussions.learner.reported": "{reported} reported",
|
||||
"discussions.learner.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.learner.lastLogin": "Last active {lastActiveTime}",
|
||||
@@ -50,6 +69,7 @@
|
||||
"discussions.actions.button.alt": "Actions menu",
|
||||
"discussions.actions.copylink": "Copy link",
|
||||
"discussions.actions.unpin": "Unpin",
|
||||
"discussions.confirmation.button.confirm": "Confirm",
|
||||
"discussions.actions.close": "Close",
|
||||
"discussions.actions.reopen": "Reopen",
|
||||
"discussions.actions.report": "Report",
|
||||
@@ -58,8 +78,7 @@
|
||||
"discussions.actions.unendorse": "Unendorse",
|
||||
"discussions.actions.markAnswered": "Mark as answered",
|
||||
"discussions.actions.unMarkAnswered": "Unmark as answered",
|
||||
"discussions.delete.confirmation.button.cancel": "Cancel",
|
||||
"discussions.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.modal.confirmation.button.cancel": "Cancel",
|
||||
"discussions.empty.allTopics": "All discussion activity for these topics will show up here.",
|
||||
"discussions.empty.allPosts": "All discussion activity for your course will show up here.",
|
||||
"discussions.empty.myPosts": "Posts you've interacted with will show up here.",
|
||||
@@ -80,7 +99,7 @@
|
||||
"discussion.banner.welcomeMessage": "🎉 Welcome to the new and improved discussions experience!",
|
||||
"discussion.banner.learnMore": "Learn more",
|
||||
"discussion.banner.shareFeedback": "Share feedback",
|
||||
"discussion.blackoutBanner.information": "Blackout dates are currently active. Posting in discussions is unavailable at this time.",
|
||||
"discussion.blackoutBanner.information": "Posting in discussions is temporarily disabled by the course team",
|
||||
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
|
||||
"discussions.editor.image.warning.title": "Warning!",
|
||||
"discussions.editor.image.warning.dismiss": "Ok",
|
||||
@@ -149,15 +168,23 @@
|
||||
"discussions.post.contentReported": "Reported",
|
||||
"discussions.post.following": "Following",
|
||||
"discussions.post.follow": "Follow",
|
||||
"discussions.post.followed": "Followed",
|
||||
"discussions.post.notFollowed": "Not Followed",
|
||||
"discussions.post.answered": "Answered",
|
||||
"discussions.post.unFollow": "Unfollow",
|
||||
"discussions.post.like": "Like",
|
||||
"discussions.post.removeLike": "Unlike",
|
||||
"discussions.post.liked": "liked",
|
||||
"discussions.post.likes": "likes",
|
||||
"discussions.post.viewActivity": "View activity",
|
||||
"discussions.post.activity": "Activity",
|
||||
"discussions.post.closed": "Post closed for responses and comments",
|
||||
"discussions.post.relatedTo": "Related to",
|
||||
"discussions.editor.delete.post.title": "Delete post",
|
||||
"discussions.editor.delete.post.description": "Are you sure you want to permanently delete this post?",
|
||||
"discussions.post.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.editor.report.post.title": "Report inappropriate content?",
|
||||
"discussions.editor.report.post.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.post.closePostModal.title": "Close post",
|
||||
"discussions.post.closePostModal.text": "Enter a reason for closing this post. This will only be displayed to other moderators.",
|
||||
"discussions.post.closePostModal.reasonCodeInput": "Reason",
|
||||
@@ -167,16 +194,12 @@
|
||||
"discussions.post.editedBy": "Edited by",
|
||||
"discussions.post.editReason": "Reason",
|
||||
"discussions.post.postWithoutPreview": "No preview available",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} reported",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.post.follow.description": "you are following this post",
|
||||
"discussions.post.unfollow.description": "you are not following this post",
|
||||
"discussions.topics.sort.message": "Sorted by {sortBy}",
|
||||
"discussions.topics.sort.lastActivity": "Recent activity",
|
||||
"discussions.topics.sort.commentCount": "Most activity",
|
||||
"discussions.topics.sort.courseStructure": "Course Structure",
|
||||
"discussions.topics.find.label": "Search topics",
|
||||
"discussions.topics.archived.label": "Archived",
|
||||
"discussions.topics.unnamed.label": "Unnamed category",
|
||||
"discussions.subtopics.unnamed.label": "Unnamed subcategory"
|
||||
}
|
||||
@@ -28,6 +28,11 @@
|
||||
"discussions.editor.delete.response.description": "Sei sicuro di voler eliminare definitivamente questa risposta?",
|
||||
"discussions.editor.delete.comment.title": "Elimina commento",
|
||||
"discussions.editor.delete.comment.description": "Sei sicuro di voler eliminare definitivamente questo commento?",
|
||||
"discussions.delete.confirmation.button.delete": "Cancella",
|
||||
"discussions.editor.response.response.title": "Report inappropriate content?",
|
||||
"discussions.editor.response.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.editor.report.comment.title": "Report inappropriate content?",
|
||||
"discussions.editor.report.comment.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.editor.comments.editReasonCode": "Motivo della modifica",
|
||||
"discussions.editor.posts.editReasonCode.error": "Seleziona il motivo per la modifica",
|
||||
"discussions.comment.comments.editedBy": "A cura di",
|
||||
@@ -35,6 +40,20 @@
|
||||
"discussions.post.closedBy": "Post chiuso da",
|
||||
"discussion.comment.repliesHeading": "{count} risposte per la risposta aggiunta",
|
||||
"discussion.comment.time": "{time} fa",
|
||||
"discussion.thread.notFound": "Thread not found",
|
||||
"discussions.topics.backAlt": "Back to topics list",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} reported",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.topics.find.label": "Search topics",
|
||||
"discussions.topics.unnamed.section.label": "Unnamed Section",
|
||||
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
|
||||
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
|
||||
"discussions.topics.title": "No topic exists",
|
||||
"discussions.topics.createTopic": "Please contact you admin to create a topic",
|
||||
"discussions.topics.nothing": "Nothing here yet",
|
||||
"discussions.topics.archived.label": "Archiviati",
|
||||
"discussions.learner.reported": "{reported} segnalato",
|
||||
"discussions.learner.previouslyReported": "{previouslyReported} segnalato in precedenza",
|
||||
"discussions.learner.lastLogin": "Ultimo attivo {lastActiveTime}",
|
||||
@@ -50,6 +69,7 @@
|
||||
"discussions.actions.button.alt": "Menù Azioni",
|
||||
"discussions.actions.copylink": "Copia link",
|
||||
"discussions.actions.unpin": "Sblocca ",
|
||||
"discussions.confirmation.button.confirm": "Confirm",
|
||||
"discussions.actions.close": "Chiudi",
|
||||
"discussions.actions.reopen": "Riaprire",
|
||||
"discussions.actions.report": "Segnala",
|
||||
@@ -58,8 +78,7 @@
|
||||
"discussions.actions.unendorse": "Annulla promozione ",
|
||||
"discussions.actions.markAnswered": "Mark as answered",
|
||||
"discussions.actions.unMarkAnswered": "Deseleziona come risposta",
|
||||
"discussions.delete.confirmation.button.cancel": "Annulla",
|
||||
"discussions.delete.confirmation.button.delete": "Cancella",
|
||||
"discussions.modal.confirmation.button.cancel": "Cancel",
|
||||
"discussions.empty.allTopics": "Tutte le attività di discussione per questi argomenti verranno visualizzate qui.",
|
||||
"discussions.empty.allPosts": "Tutte le attività di discussione per il tuo corso verranno visualizzate qui.",
|
||||
"discussions.empty.myPosts": "I post con cui hai interagito verranno visualizzati qui.",
|
||||
@@ -80,7 +99,7 @@
|
||||
"discussion.banner.welcomeMessage": "🎉 Benvenuto nella nuova e avanzata esperienza di discussione!",
|
||||
"discussion.banner.learnMore": "Per saperne di più",
|
||||
"discussion.banner.shareFeedback": "Condividi feedback",
|
||||
"discussion.blackoutBanner.information": "Blackout dates are currently active. Posting in discussions is unavailable at this time.",
|
||||
"discussion.blackoutBanner.information": "Posting in discussions is temporarily disabled by the course team",
|
||||
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
|
||||
"discussions.editor.image.warning.title": "Warning!",
|
||||
"discussions.editor.image.warning.dismiss": "Ok",
|
||||
@@ -149,15 +168,23 @@
|
||||
"discussions.post.contentReported": "Segnalato ",
|
||||
"discussions.post.following": "Seguente",
|
||||
"discussions.post.follow": "Segui ",
|
||||
"discussions.post.followed": "Followed",
|
||||
"discussions.post.notFollowed": "Not Followed",
|
||||
"discussions.post.answered": "Risposto",
|
||||
"discussions.post.unFollow": "Annulla Segui ",
|
||||
"discussions.post.like": "Piace",
|
||||
"discussions.post.removeLike": "A differenza di",
|
||||
"discussions.post.liked": "liked",
|
||||
"discussions.post.likes": "likes",
|
||||
"discussions.post.viewActivity": "Visualizza attività",
|
||||
"discussions.post.activity": "Activity",
|
||||
"discussions.post.closed": "Post chiuso per risposte e commenti",
|
||||
"discussions.post.relatedTo": "Related to",
|
||||
"discussions.editor.delete.post.title": "Elimina messaggio",
|
||||
"discussions.editor.delete.post.description": "Sei sicuro di voler eliminare definitivamente questo post?",
|
||||
"discussions.post.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.editor.report.post.title": "Report inappropriate content?",
|
||||
"discussions.editor.report.post.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.post.closePostModal.title": "Chiudi messaggio",
|
||||
"discussions.post.closePostModal.text": "Inserisci un motivo per chiudere questo post. Questo verrà mostrato solo agli altri moderatori.",
|
||||
"discussions.post.closePostModal.reasonCodeInput": "Motivo ",
|
||||
@@ -167,16 +194,12 @@
|
||||
"discussions.post.editedBy": "A cura di",
|
||||
"discussions.post.editReason": "Motivo ",
|
||||
"discussions.post.postWithoutPreview": "nessuna anteprima disponibile",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} reported",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.post.follow.description": "you are following this post",
|
||||
"discussions.post.unfollow.description": "you are not following this post",
|
||||
"discussions.topics.sort.message": "Ordinato per {sortBy}",
|
||||
"discussions.topics.sort.lastActivity": "Attività Recente",
|
||||
"discussions.topics.sort.commentCount": "La maggior parte delle attività",
|
||||
"discussions.topics.sort.courseStructure": "Struttura Corso",
|
||||
"discussions.topics.find.label": "Search topics",
|
||||
"discussions.topics.archived.label": "Archiviati",
|
||||
"discussions.topics.unnamed.label": "Unnamed category",
|
||||
"discussions.subtopics.unnamed.label": "Unnamed subcategory"
|
||||
}
|
||||
@@ -28,6 +28,11 @@
|
||||
"discussions.editor.delete.response.description": "Czy na pewno chcesz trwale usunąć tę odpowiedź?",
|
||||
"discussions.editor.delete.comment.title": "Usuń komentarz",
|
||||
"discussions.editor.delete.comment.description": "Czy na pewno chcesz trwale usunąć ten komentarz?",
|
||||
"discussions.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.editor.response.response.title": "Report inappropriate content?",
|
||||
"discussions.editor.response.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.editor.report.comment.title": "Report inappropriate content?",
|
||||
"discussions.editor.report.comment.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.editor.comments.editReasonCode": "Reason for editing",
|
||||
"discussions.editor.posts.editReasonCode.error": "Wybierz powód edycji",
|
||||
"discussions.comment.comments.editedBy": "Edytowany przez",
|
||||
@@ -35,6 +40,20 @@
|
||||
"discussions.post.closedBy": "Post zamknięty przez",
|
||||
"discussion.comment.repliesHeading": "{count} replies for the response added",
|
||||
"discussion.comment.time": "{time} ago",
|
||||
"discussion.thread.notFound": "Thread not found",
|
||||
"discussions.topics.backAlt": "Back to topics list",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} reported",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.topics.find.label": "Search topics",
|
||||
"discussions.topics.unnamed.section.label": "Unnamed Section",
|
||||
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
|
||||
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
|
||||
"discussions.topics.title": "No topic exists",
|
||||
"discussions.topics.createTopic": "Please contact you admin to create a topic",
|
||||
"discussions.topics.nothing": "Nothing here yet",
|
||||
"discussions.topics.archived.label": "Archived",
|
||||
"discussions.learner.reported": "{reported} zgłoszone",
|
||||
"discussions.learner.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.learner.lastLogin": "Ostatnia aktywność {lastActiveTime}",
|
||||
@@ -50,6 +69,7 @@
|
||||
"discussions.actions.button.alt": "Menu czynności",
|
||||
"discussions.actions.copylink": "Copy link",
|
||||
"discussions.actions.unpin": "Unpin",
|
||||
"discussions.confirmation.button.confirm": "Confirm",
|
||||
"discussions.actions.close": "Close",
|
||||
"discussions.actions.reopen": "Otwórz ponownie",
|
||||
"discussions.actions.report": "Report",
|
||||
@@ -58,8 +78,7 @@
|
||||
"discussions.actions.unendorse": "Unendorse",
|
||||
"discussions.actions.markAnswered": "Mark as answered",
|
||||
"discussions.actions.unMarkAnswered": "Nie oznaczaj jako odpowiedziane",
|
||||
"discussions.delete.confirmation.button.cancel": "Cancel",
|
||||
"discussions.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.modal.confirmation.button.cancel": "Cancel",
|
||||
"discussions.empty.allTopics": "Wszystkie dyskusje dotyczące tych tematów pojawią się tutaj.",
|
||||
"discussions.empty.allPosts": "All discussion activity for your course will show up here.",
|
||||
"discussions.empty.myPosts": "Posts you've interacted with will show up here.",
|
||||
@@ -80,7 +99,7 @@
|
||||
"discussion.banner.welcomeMessage": "🎉 Welcome to the new and improved discussions experience!",
|
||||
"discussion.banner.learnMore": "Learn more",
|
||||
"discussion.banner.shareFeedback": "Share feedback",
|
||||
"discussion.blackoutBanner.information": "Blackout dates are currently active. Posting in discussions is unavailable at this time.",
|
||||
"discussion.blackoutBanner.information": "Posting in discussions is temporarily disabled by the course team",
|
||||
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
|
||||
"discussions.editor.image.warning.title": "Warning!",
|
||||
"discussions.editor.image.warning.dismiss": "Ok",
|
||||
@@ -149,15 +168,23 @@
|
||||
"discussions.post.contentReported": "Reported",
|
||||
"discussions.post.following": "Following",
|
||||
"discussions.post.follow": "Follow",
|
||||
"discussions.post.followed": "Followed",
|
||||
"discussions.post.notFollowed": "Not Followed",
|
||||
"discussions.post.answered": "Answered",
|
||||
"discussions.post.unFollow": "Unfollow",
|
||||
"discussions.post.like": "Like",
|
||||
"discussions.post.removeLike": "Unlike",
|
||||
"discussions.post.liked": "liked",
|
||||
"discussions.post.likes": "likes",
|
||||
"discussions.post.viewActivity": "Zobacz aktywność",
|
||||
"discussions.post.activity": "Activity",
|
||||
"discussions.post.closed": "Post closed for responses and comments",
|
||||
"discussions.post.relatedTo": "Related to",
|
||||
"discussions.editor.delete.post.title": "Usuń post",
|
||||
"discussions.editor.delete.post.description": "Czy na pewno chcesz trwale usunąć ten wpis?",
|
||||
"discussions.post.delete.confirmation.button.delete": "Delete",
|
||||
"discussions.editor.report.post.title": "Report inappropriate content?",
|
||||
"discussions.editor.report.post.description": "The discussion moderation team will review this content and take appropriate action.",
|
||||
"discussions.post.closePostModal.title": "Close post",
|
||||
"discussions.post.closePostModal.text": "Enter a reason for closing this post. This will only be displayed to other moderators.",
|
||||
"discussions.post.closePostModal.reasonCodeInput": "Reason",
|
||||
@@ -167,16 +194,12 @@
|
||||
"discussions.post.editedBy": "Edytowany przez",
|
||||
"discussions.post.editReason": "Reason",
|
||||
"discussions.post.postWithoutPreview": "Podgląd niedostępny",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} reported",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.post.follow.description": "you are following this post",
|
||||
"discussions.post.unfollow.description": "you are not following this post",
|
||||
"discussions.topics.sort.message": "Posortowane według {sortBy}",
|
||||
"discussions.topics.sort.lastActivity": "Ostatnia aktywność",
|
||||
"discussions.topics.sort.commentCount": "Most activity",
|
||||
"discussions.topics.sort.courseStructure": "Course Structure",
|
||||
"discussions.topics.find.label": "Search topics",
|
||||
"discussions.topics.archived.label": "Archived",
|
||||
"discussions.topics.unnamed.label": "Unnamed category",
|
||||
"discussions.subtopics.unnamed.label": "Unnamed subcategory"
|
||||
}
|
||||
@@ -4,18 +4,18 @@
|
||||
"discussions.comments.comment.addResponse": "Bir cevap ekle",
|
||||
"discussions.comments.comment.addComment": "Bir yorum ekle",
|
||||
"discussions.comments.comment.abuseFlaggedMessage": "Personelin incelemesi için bildirilen içerik",
|
||||
"discussions.actions.back.alt": "Back to list",
|
||||
"discussions.actions.back.alt": "Listeye dön",
|
||||
"discussions.comments.comment.responseCount": "{num, plural,\n =0 {No responses}\n one {Showing # response}\n other {Showing # responses}\n }",
|
||||
"discussions.comments.comment.endorsedResponseCount": "{num, plural,\n =0 {No endorsed responses}\n one {Showing # endorsed response}\n other {Showing # endorsed responses}\n }",
|
||||
"discussions.comments.comment.loadMoreComments": "Daha fazla yorum yükle",
|
||||
"discussions.comments.comment.loadMoreResponses": "Daha fazla yanıt yükle",
|
||||
"discussions.comments.comment.visibility": "This post is visible to {group, select,\n null {Everyone}\n other {{group}}\n }.",
|
||||
"discussions.comments.comment.postedTime": "{postType, select,\n discussion {Discussion}\n question {Question}\n other {{postType}}\n } posted {relativeTime} by",
|
||||
"discussions.comments.comment.commentTime": "Posted {relativeTime}",
|
||||
"discussions.comments.comment.commentTime": "{relativeTime} önce gönderildi",
|
||||
"discussions.comments.comment.answer": "Cevap",
|
||||
"discussions.comments.comment.answeredlabel": "Marked as answered by",
|
||||
"discussions.comments.comment.endorsed": "Endorsed",
|
||||
"discussions.comments.comment.endorsedlabel": "Endorsed by",
|
||||
"discussions.comments.comment.answeredlabel": "Yanıtlandı olarak işaretleyen ",
|
||||
"discussions.comments.comment.endorsed": "Doğrulandı",
|
||||
"discussions.comments.comment.endorsedlabel": "Doğrulayan",
|
||||
"discussions.actions.label": "Eylemler menüsü",
|
||||
"discussions.actions.edit": "Düzenle",
|
||||
"discussions.actions.pin": "İşaretle",
|
||||
@@ -28,13 +28,32 @@
|
||||
"discussions.editor.delete.response.description": "Bu yanıtı kalıcı olarak silmek istediğinizden emin misiniz?",
|
||||
"discussions.editor.delete.comment.title": "Yorumu sil",
|
||||
"discussions.editor.delete.comment.description": "Bu yorumu kalıcı olarak silmek istediğinizden emin misiniz?",
|
||||
"discussions.delete.confirmation.button.delete": "Sil",
|
||||
"discussions.editor.response.response.title": "Uygunsuz içerik mi raporlayacaksınız?",
|
||||
"discussions.editor.response.description": "Tartışma yöneticileri bu içeriği inceleyecek ve uygun işlemi yapacaktır.",
|
||||
"discussions.editor.report.comment.title": "Uygunsuz içerik mi raporlayacaksınız?",
|
||||
"discussions.editor.report.comment.description": "Tartışma yöneticileri bu içeriği inceleyecek ve uygun işlemi yapacaktır.",
|
||||
"discussions.editor.comments.editReasonCode": "Düzenleme nedeni",
|
||||
"discussions.editor.posts.editReasonCode.error": "Düzenleme nedenini seçin",
|
||||
"discussions.comment.comments.editedBy": "Düzenleyen",
|
||||
"discussions.comment.comments.reason": "Gerekçe",
|
||||
"discussions.post.closedBy": "Post closed by",
|
||||
"discussion.comment.repliesHeading": "{count} replies for the response added",
|
||||
"discussion.comment.time": "{time} ago",
|
||||
"discussions.post.closedBy": "Gönderiyi kapatan ",
|
||||
"discussion.comment.repliesHeading": "Eklenen yanıt için {count} yanıt",
|
||||
"discussion.comment.time": "{time} önce",
|
||||
"discussion.thread.notFound": "Tartışma zinciri bulunamadı",
|
||||
"discussions.topics.backAlt": "Back to topics list",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} rapor edildi",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} ileti rapor edildi",
|
||||
"discussions.topics.find.label": "Konuları ara",
|
||||
"discussions.topics.unnamed.section.label": "Unnamed Section",
|
||||
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
|
||||
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
|
||||
"discussions.topics.title": "No topic exists",
|
||||
"discussions.topics.createTopic": "Please contact you admin to create a topic",
|
||||
"discussions.topics.nothing": "Nothing here yet",
|
||||
"discussions.topics.archived.label": "Arşivlenmiş",
|
||||
"discussions.learner.reported": "{reported} rapor edildi",
|
||||
"discussions.learner.previouslyReported": "{previouslyReported} daha önce rapor edildi",
|
||||
"discussions.learner.lastLogin": "Son etkinlik {lastActiveTime}",
|
||||
@@ -43,23 +62,23 @@
|
||||
"discussions.learner.activityForLearner": "{username} için etkinlik",
|
||||
"discussions.learner.mostActivity": "En çok etkinlik",
|
||||
"discussions.learner.reportedActivity": "Rapor edilen etkinlik",
|
||||
"discussions.learner.recentActivity": "Recent activity",
|
||||
"discussions.learner.recentActivity": "Son etkinlik",
|
||||
"discussions.learner.sortFilterStatus": "All learners sorted by {sort, select,\n flagged {reported activity}\n activity {most activity}\n other {{sort}}\n }",
|
||||
"discussion.learner.allActivity": "All activity",
|
||||
"discussion.learner.allActivity": "Tüm etkinlikler",
|
||||
"discussion.learner.posts": "Gönderiler",
|
||||
"discussions.actions.button.alt": "Eylemler menüsü",
|
||||
"discussions.actions.copylink": "Copy link",
|
||||
"discussions.actions.copylink": "Bağlantıyı kopyala",
|
||||
"discussions.actions.unpin": "İşareti kaldır",
|
||||
"discussions.confirmation.button.confirm": "Onayla",
|
||||
"discussions.actions.close": "Kapat",
|
||||
"discussions.actions.reopen": "Yeniden aç",
|
||||
"discussions.actions.report": "Raporla",
|
||||
"discussions.actions.unreport": "Bildirme",
|
||||
"discussions.actions.endorse": "Destekle",
|
||||
"discussions.actions.unendorse": "Destekleme",
|
||||
"discussions.actions.markAnswered": "Mark as answered",
|
||||
"discussions.actions.markAnswered": "Cevaplandı olarak işaretle",
|
||||
"discussions.actions.unMarkAnswered": "Cevaplandı olarak işaretini kaldır",
|
||||
"discussions.delete.confirmation.button.cancel": "İptal",
|
||||
"discussions.delete.confirmation.button.delete": "Sil",
|
||||
"discussions.modal.confirmation.button.cancel": "İptal",
|
||||
"discussions.empty.allTopics": "Bu konularla ilgili tüm tartışma etkinlikleri burada gösterilecektir.",
|
||||
"discussions.empty.allPosts": "Dersiniz için tüm tartışma etkinlikleri burada gösterilecektir.",
|
||||
"discussions.empty.myPosts": "Etkileşimde bulunduğunuz gönderiler burada gösterilecektir.",
|
||||
@@ -68,22 +87,22 @@
|
||||
"discussions.empty.noPostSelected": "Gönderi seçilmedi",
|
||||
"discussions.empty.noTopicSelected": "Konu seçilmedi",
|
||||
"discussions.sidebar.noResultsFound": "Sonuç bulunamadı",
|
||||
"discussions.sidebar.differentKeywords": "Try searching different keywords",
|
||||
"discussions.sidebar.differentKeywords": "Farklı anahtar kelimelerle aramayı dene",
|
||||
"discussions.sidebar.removeKeywords": "Farklı anahtar kelimeler aramayı veya bazı filtreleri kaldırmayı deneyin",
|
||||
"discussions.sidebar.removeKeywordsOnly": "Try searching different keywords",
|
||||
"discussions.sidebar.removeKeywordsOnly": "Farklı anahtar kelimelerle aramayı dene",
|
||||
"discussions.sidebar.removeFilters": "Bazı filtreleri kaldırmayı deneyin",
|
||||
"discussions.empty.iconAlt": "Boş",
|
||||
"discussions.authors.label.staff": "Personel",
|
||||
"discussions.authors.label.ta": "TA",
|
||||
"discussions.learner.loadMostPosts": "Daha fazla ileti yükle",
|
||||
"discussions.post.anonymous.author": "anonim",
|
||||
"discussion.banner.welcomeMessage": "🎉 Welcome to the new and improved discussions experience!",
|
||||
"discussion.banner.welcomeMessage": "🎉 Yeni ve geliştirilmiş tartışma deneyimine hoş geldiniz!",
|
||||
"discussion.banner.learnMore": "Daha fazlasını öğren",
|
||||
"discussion.banner.shareFeedback": "Share feedback",
|
||||
"discussion.blackoutBanner.information": "Blackout dates are currently active. Posting in discussions is unavailable at this time.",
|
||||
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
|
||||
"discussions.editor.image.warning.title": "Warning!",
|
||||
"discussions.editor.image.warning.dismiss": "Ok",
|
||||
"discussion.banner.shareFeedback": "Geri bildirim paylaş",
|
||||
"discussion.blackoutBanner.information": "Posting in discussions is temporarily disabled by the course team",
|
||||
"discussions.editor.image.warning.message": "Genişliği veya yüksekliği 999 pikselden büyük olan resimler, çevrimiçi ders tartışmalarında yer alan gönderi, yanıt veya yorumlarda görüntülenemez.",
|
||||
"discussions.editor.image.warning.title": "Uyarı!",
|
||||
"discussions.editor.image.warning.dismiss": "Tamam",
|
||||
"discussions.navigation.breadcrumbMenu.allTopics": "Konular",
|
||||
"discussions.navigation.breadcrumbMenu.showAll": "Tümünü göster",
|
||||
"discussions.navigation.navigationBar.allPosts": "Tüm iletiler",
|
||||
@@ -94,9 +113,9 @@
|
||||
"discussions.posts.actionBar.searchAllPosts": "Tüm gönderilerde ara",
|
||||
"discussions.posts.actionBar.search": "{page, select,\n topics {Search topics}\n posts {Search all posts}\n learners {Search learners}\n myPosts {Search all posts}\n other {{page}}\n }",
|
||||
"discussions.actionBar.searchInfo": "\"{text}\" için {count} sonuç gösteriliyor",
|
||||
"discussions.actionBar.searchRewriteInfo": "No results found for \"{searchString}\". Showing {count} results for \"{textSearchRewrite}\".",
|
||||
"discussions.actionBar.searchRewriteInfo": "\"{searchString}\" için sonuç bulunamadı. \"{textSearchRewrite}\" için {count} sonuç gösteriliyor.",
|
||||
"discussions.actionBar.searchInfoSearching": "Aranıyor...",
|
||||
"discussions.actionBar.clearSearch": "Clear results",
|
||||
"discussions.actionBar.clearSearch": "Sonuçları temizle",
|
||||
"discussion.posts.actionBar.add": "Bir ileti ekle",
|
||||
"discussion.posts.actionBar.close": "Kapat",
|
||||
"discussions.post.editor.type": "Gönderi türü",
|
||||
@@ -109,13 +128,13 @@
|
||||
"discussions.post.editor.discussionType": "Forum",
|
||||
"discussions.post.editor.discussionDescription": "Fikirleri paylaşın ve sohbetler başlatın",
|
||||
"discussions.post.editor.topicArea": "Başlık alanı",
|
||||
"discussions.post.editor.topicAreaDescription": "Add your post to a relevant topic to help others find it.",
|
||||
"discussions.post.editor.topicAreaDescription": "Başkalarının bulmasına yardımcı olmak için gönderinizi ilgili bir konuya ekleyin.",
|
||||
"discussions.post.editor.cohortVisibility": "Kohort görünürlüğü",
|
||||
"discussions.post.editor.cohortVisibilityAllLearners": "Tüm öğrenciler",
|
||||
"discussions.post.editor.title": "Post title",
|
||||
"discussions.post.editor.title": "İleti başlığı",
|
||||
"discussions.post.editor.titleDescription": "Katılıma teşvik etmek için, açık ve tanıtıcı bir başlık ekleyin.",
|
||||
"discussions.post.editor.title.error": "Post title cannot be empty.",
|
||||
"discussions.post.editor.content.error": "Post content cannot be empty.",
|
||||
"discussions.post.editor.title.error": "Gönderi başlığı boş olamaz.",
|
||||
"discussions.post.editor.content.error": "Gönderi içeriği boş olamaz.",
|
||||
"discussions.post.editor.questionText": "Sorunuz veya fikriniz (gerekli)",
|
||||
"discussions.post.editor.preview": "Önizleme",
|
||||
"discussions.post.editor.followPost": "Bu iletiyi takip edin",
|
||||
@@ -123,8 +142,8 @@
|
||||
"discussions.post.editor.anonymousToPeersPost": "Akranlarına anonim olarak gönder",
|
||||
"discussions.editor.posts.editReasonCode": "Düzenleme nedeni",
|
||||
"discussions.editor.posts.showPreview.button": "Önizlemeyi Göster",
|
||||
"discussions.topic.noName.label": "Unnamed category",
|
||||
"discussions.subtopic.noName.label": "Unnamed subcategory",
|
||||
"discussions.topic.noName.label": "İsimsiz kategori",
|
||||
"discussions.subtopic.noName.label": "İsimsiz alt kategori",
|
||||
"discussions.posts.filter.showALl": "Tümünü göster",
|
||||
"discussions.posts.filter.discussions": "Forumlar",
|
||||
"discussions.posts.filter.questions": "Sorular",
|
||||
@@ -134,7 +153,7 @@
|
||||
"discussions.posts.status.filter.following": "Takip ediliyor",
|
||||
"discussions.posts.status.filter.reported": "Rapor edildi",
|
||||
"discussions.posts.status.filter.unanswered": "Cevaplanmamış",
|
||||
"discussions.posts.status.filter.unresponded": "Not responded",
|
||||
"discussions.posts.status.filter.unresponded": "Yanıtlanmamış",
|
||||
"discussions.posts.filter.myPosts": "Gönderilerim",
|
||||
"discussions.posts.filter.myDiscussions": "Tartışmalarım",
|
||||
"discussions.posts.filter.myQuestions": "Sorularım",
|
||||
@@ -145,19 +164,27 @@
|
||||
"discussions.posts.sort-filter.sortFilterStatus": "{own, select,\n false {All}\n true {Own}\n other {{own}}\n } {status, select,\n statusAll {}\n statusUnread {unread}\n statusFollowing {followed}\n statusReported {reported}\n statusUnanswered {unanswered}\n statusUnresponded {unresponded}\n other {{status}}\n } {type, select,\n discussion {discussions}\n question {questions}\n all {posts}\n other {{type}}\n } {cohortType, select,\n all {}\n group {in {cohort}}\n other {{cohortType}}\n } sorted by {sort, select,\n lastActivityAt {recent activity}\n commentCount {most activity}\n voteCount {most likes}\n other {{sort}}\n }",
|
||||
"discussions.post.author.anonymous": "anonim",
|
||||
"discussions.post.lastResponse": "Son yanıt {time}",
|
||||
"discussions.post.postedOn": "Posted {time} by {author} {authorLabel}",
|
||||
"discussions.post.postedOn": "{author} {authorLabel} tarafından {time} önce gönderildi",
|
||||
"discussions.post.contentReported": "Rapor edildi",
|
||||
"discussions.post.following": "Takip ediliyor",
|
||||
"discussions.post.follow": "Takip et",
|
||||
"discussions.post.followed": "Followed",
|
||||
"discussions.post.notFollowed": "Not Followed",
|
||||
"discussions.post.answered": "Yanıtlandı",
|
||||
"discussions.post.unFollow": "Takibi bırak",
|
||||
"discussions.post.like": "Beğen",
|
||||
"discussions.post.removeLike": "Unlike",
|
||||
"discussions.post.removeLike": "Beğenmeme",
|
||||
"discussions.post.liked": "liked",
|
||||
"discussions.post.likes": "likes",
|
||||
"discussions.post.viewActivity": "Etkinliği görüntüle",
|
||||
"discussions.post.activity": "Activity",
|
||||
"discussions.post.closed": "Yanıtlar ve yorumlar için gönderi kapatıldı",
|
||||
"discussions.post.relatedTo": "Related to",
|
||||
"discussions.post.relatedTo": "Bunun ile ilgili",
|
||||
"discussions.editor.delete.post.title": "Gönderiyi sil",
|
||||
"discussions.editor.delete.post.description": "Bu gönderiyi kalıcı olarak silmek istediğinizden emin misiniz?",
|
||||
"discussions.post.delete.confirmation.button.delete": "Sil",
|
||||
"discussions.editor.report.post.title": "Uygunsuz içerik mi raporlayacaksınız?",
|
||||
"discussions.editor.report.post.description": "Tartışma yöneticileri bu içeriği inceleyecek ve uygun işlemi yapacaktır.",
|
||||
"discussions.post.closePostModal.title": "Gönderiyi kapat",
|
||||
"discussions.post.closePostModal.text": "Bu gönderiyi kapatmak için bir neden girin. Bu sadece diğer moderatörlere gösterilecektir.",
|
||||
"discussions.post.closePostModal.reasonCodeInput": "Gerekçe",
|
||||
@@ -167,16 +194,12 @@
|
||||
"discussions.post.editedBy": "Düzenleyen",
|
||||
"discussions.post.editReason": "Gerekçe",
|
||||
"discussions.post.postWithoutPreview": "Önizleme yok",
|
||||
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
|
||||
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
|
||||
"discussions.topics.reported": "{reported} reported",
|
||||
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
|
||||
"discussions.post.follow.description": "you are following this post",
|
||||
"discussions.post.unfollow.description": "you are not following this post",
|
||||
"discussions.topics.sort.message": "{sortBy} ölçütüne göre sıralandı",
|
||||
"discussions.topics.sort.lastActivity": "Son etkinlik",
|
||||
"discussions.topics.sort.commentCount": "En çok etkinlik",
|
||||
"discussions.topics.sort.courseStructure": "Ders Yapısı",
|
||||
"discussions.topics.find.label": "Search topics",
|
||||
"discussions.topics.archived.label": "Arşivlenmiş",
|
||||
"discussions.topics.unnamed.label": "Unnamed category",
|
||||
"discussions.subtopics.unnamed.label": "Unnamed subcategory"
|
||||
"discussions.topics.unnamed.label": "İsimsiz kategori",
|
||||
"discussions.subtopics.unnamed.label": "İsimsiz alt kategori"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user