feat: clickable username in post summery card (#201)
* feat: clickable post username * feat: fetch learner posts based on learner name
This commit is contained in:
@@ -65,7 +65,7 @@ function AlertBanner({
|
||||
<div className="d-flex align-items-center">
|
||||
{intl.formatMessage(messages.editedBy)}
|
||||
<span className="ml-1 mr-3">
|
||||
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
|
||||
<AuthorLabel author={content.lastEdit.editorUsername} />
|
||||
</span>
|
||||
{intl.formatMessage(messages.reason)}: {content.lastEdit.reason}
|
||||
</div>
|
||||
@@ -76,7 +76,7 @@ function AlertBanner({
|
||||
<div className="d-flex align-items-center">
|
||||
{intl.formatMessage(messages.closedBy)}
|
||||
<span className="ml-1 ">
|
||||
<AuthorLabel author={content.closedBy} linkToProfile />
|
||||
<AuthorLabel author={content.closedBy} />
|
||||
</span>
|
||||
<span className="mx-1" />
|
||||
{intl.formatMessage(messages.reason)}: {content.closeReason}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { Institution, School } from '@edx/paragon/icons';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
import messages from '../messages';
|
||||
import { discussionsPath } from '../utils';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
function AuthorLabel({
|
||||
intl,
|
||||
@@ -17,6 +21,8 @@ function AuthorLabel({
|
||||
linkToProfile,
|
||||
labelColor,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const { courseId } = useContext(DiscussionContext);
|
||||
let icon = null;
|
||||
let authorLabelMessage = null;
|
||||
|
||||
@@ -33,8 +39,12 @@ function AuthorLabel({
|
||||
const className = classNames('d-flex align-items-center', labelColor);
|
||||
|
||||
const labelContents = (
|
||||
<>
|
||||
<span className={`mr-1 font-size-14 font-style-normal font-family-inter ${fontWeight}`} role="heading" aria-level="2">
|
||||
<div className={className}>
|
||||
<span
|
||||
className={`mr-1 font-size-14 font-style-normal font-family-inter ${fontWeight}`}
|
||||
role="heading"
|
||||
aria-level="2"
|
||||
>
|
||||
{capitalize(author)}
|
||||
</span>
|
||||
{icon && (
|
||||
@@ -54,16 +64,25 @@ function AuthorLabel({
|
||||
{authorLabelMessage}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
return linkToProfile
|
||||
? React.createElement('a', { href: '#nowhere', className }, labelContents)
|
||||
: React.createElement('div', { className }, labelContents);
|
||||
? (
|
||||
<Link
|
||||
data-testid="learner-posts-link"
|
||||
to={discussionsPath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })(location)}
|
||||
className="text-decoration-none"
|
||||
style={{ width: 'fit-content' }}
|
||||
>
|
||||
{labelContents}
|
||||
</Link>
|
||||
)
|
||||
: <>{labelContents}</>;
|
||||
}
|
||||
|
||||
AuthorLabel.propTypes = {
|
||||
intl: intlShape,
|
||||
intl: intlShape.isRequired,
|
||||
author: PropTypes.string.isRequired,
|
||||
authorLabel: PropTypes.string,
|
||||
linkToProfile: PropTypes.bool,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../store';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { fetchConfigSuccess } from '../data/slices';
|
||||
import { threadsApiUrl } from '../posts/data/api';
|
||||
import DiscussionSidebar from './DiscussionSidebar';
|
||||
@@ -27,9 +28,11 @@ function renderComponent(displaySidebar = true, location = `/${courseId}/`) {
|
||||
<IntlProvider locale="en">
|
||||
<ResponsiveContext.Provider value={{ width: 1280 }}>
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={[location]}>
|
||||
<DiscussionSidebar displaySidebar={displaySidebar} />
|
||||
</MemoryRouter>
|
||||
<DiscussionContext.Provider value={{ courseId }}>
|
||||
<MemoryRouter initialEntries={[location]}>
|
||||
<DiscussionSidebar displaySidebar={displaySidebar} />
|
||||
</MemoryRouter>
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</ResponsiveContext.Provider>
|
||||
</IntlProvider>,
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import NoResults from '../posts/NoResults';
|
||||
import { PostLink } from '../posts/post';
|
||||
import { discussionsPath } from '../utils';
|
||||
import { selectLearnerProfile } from './data/selectors';
|
||||
import { fetchUserPosts } from './data/thunks';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -33,14 +32,13 @@ function LearnerPostsView({ intl }) {
|
||||
const loadingStatus = useSelector(threadsLoadingStatus());
|
||||
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
|
||||
const nextPage = useSelector(selectThreadNextPage());
|
||||
const { id: userId } = useSelector(selectLearnerProfile(username));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUserPosts(courseId, username, userId));
|
||||
dispatch(fetchUserPosts(courseId, username));
|
||||
}, [courseId, username]);
|
||||
|
||||
const loadMorePosts = () => (
|
||||
dispatch(fetchUserPosts(courseId, username, userId, {
|
||||
dispatch(fetchUserPosts(courseId, username, {
|
||||
page: nextPage,
|
||||
}))
|
||||
);
|
||||
@@ -57,11 +55,11 @@ function LearnerPostsView({ intl }) {
|
||||
return (
|
||||
<React.Fragment key={post.id}>
|
||||
<div className="p-1 bg-light-400" />
|
||||
<PostLink post={post} key={post.id} isSelected={checkIsSelected} />
|
||||
<PostLink post={post} key={post.id} isSelected={checkIsSelected} learnerTab />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return (<PostLink post={post} key={post.id} isSelected={checkIsSelected} />);
|
||||
return (<PostLink post={post} key={post.id} isSelected={checkIsSelected} learnerTab />);
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -89,10 +87,10 @@ function LearnerPostsView({ intl }) {
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
nextPage && (
|
||||
<Button onClick={() => loadMorePosts()} variant="primary" size="md">
|
||||
{intl.formatMessage(messages.loadMore)}
|
||||
</Button>
|
||||
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Button onClick={() => loadMorePosts()} variant="primary" size="md">
|
||||
{intl.formatMessage(messages.loadMore)}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,7 @@ function LearnersView({ intl }) {
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
nextPage && (
|
||||
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Button onClick={() => loadPage()} variant="primary" size="md">
|
||||
{intl.formatMessage(messages.loadMore)}
|
||||
</Button>
|
||||
|
||||
@@ -45,10 +45,10 @@ export async function getUserProfiles(usernames) {
|
||||
* pagination: {count, num_pages, next, previous}
|
||||
* }
|
||||
*/
|
||||
export async function getUserPosts(courseId, userId, { page }) {
|
||||
export async function getUserPosts(courseId, username, { page }) {
|
||||
const learnerPostsApiUrl = `${coursesApiUrl}${courseId}/learner/`;
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(learnerPostsApiUrl, { params: { user_id: userId, page } });
|
||||
.get(learnerPostsApiUrl, { params: { username, page } });
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -25,5 +25,3 @@ export const selectLearner = (username) => createSelector(
|
||||
[selectAllLearners],
|
||||
learners => learners.find(l => l.username === username) || {},
|
||||
);
|
||||
|
||||
export const selectLearnerProfile = (username) => state => state.learners.learnerProfiles[username] || {};
|
||||
|
||||
@@ -59,16 +59,16 @@ export function fetchLearners(courseId, {
|
||||
* redux state
|
||||
*
|
||||
* @param {string} courseId Course ID of the course eg., course-v1:X+Y+Z
|
||||
* @param {string} userId userId of the learner
|
||||
* @param {string} username name of the learner
|
||||
* @param page
|
||||
* @returns a promise that will update the state with the learner's posts
|
||||
*/
|
||||
export function fetchUserPosts(courseId, username, userId, { page = 1 } = {}) {
|
||||
export function fetchUserPosts(courseId, username, { page = 1 } = {}) {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch(fetchLearnerThreadsRequest({ courseId, author: username }));
|
||||
|
||||
const data = await getUserPosts(courseId, userId, { page });
|
||||
const data = await getUserPosts(courseId, username, { page });
|
||||
const normalisedData = normaliseThreads(camelCaseObject(data));
|
||||
|
||||
dispatch(fetchThreadsSuccess({ ...normalisedData, page, author: username }));
|
||||
|
||||
@@ -79,7 +79,7 @@ function PostsList({ posts, topics, intl }) {
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
nextPage && (
|
||||
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Button onClick={() => loadThreads(topics, nextPage)} variant="primary" size="md">
|
||||
{intl.formatMessage(messages.loadMorePosts)}
|
||||
</Button>
|
||||
|
||||
@@ -43,6 +43,7 @@ const threadsSlice = createSlice({
|
||||
if (state.author !== payload.author) {
|
||||
state.pages = [];
|
||||
state.author = payload.author;
|
||||
state.totalThreads = null;
|
||||
}
|
||||
state.status = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ function PostLink({
|
||||
post,
|
||||
isSelected,
|
||||
intl,
|
||||
learnerTab,
|
||||
}) {
|
||||
const {
|
||||
page,
|
||||
@@ -72,7 +73,7 @@ function PostLink({
|
||||
className={
|
||||
classNames('text-truncate font-weight-500 font-size-14 text-primary-500 font-style-normal font-family-inter',
|
||||
{ 'font-weight-bolder': !post.read })
|
||||
}
|
||||
}
|
||||
>
|
||||
{post.title}
|
||||
</div>
|
||||
@@ -100,6 +101,7 @@ function PostLink({
|
||||
author={post.author || intl.formatMessage(messages.anonymous)}
|
||||
authorLabel={post.authorLabel}
|
||||
labelColor={authorLabelColor && `text-${authorLabelColor}`}
|
||||
linkToProfile={!learnerTab && post.author}
|
||||
/>
|
||||
<div
|
||||
className="text-truncate text-primary-500 font-weight-normal font-size-14 font-style-normal font-family-inter"
|
||||
@@ -120,6 +122,11 @@ PostLink.propTypes = {
|
||||
post: postShape.isRequired,
|
||||
isSelected: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
learnerTab: PropTypes.bool,
|
||||
};
|
||||
|
||||
PostLink.defaultProps = {
|
||||
learnerTab: false,
|
||||
};
|
||||
|
||||
export default injectIntl(PostLink);
|
||||
|
||||
@@ -7,15 +7,19 @@ import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeStore } from '../../../store';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import PostLink from './PostLink';
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
let store;
|
||||
|
||||
function renderComponent(post) {
|
||||
function renderComponent(post, learnerTab = false) {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<PostLink post={post} key={post.id} isSelected={() => true} />
|
||||
<DiscussionContext.Provider value={{ courseId }}>
|
||||
<PostLink post={post} key={post.id} isSelected={() => true} learnerTab={learnerTab} />
|
||||
</DiscussionContext.Provider>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
@@ -65,3 +69,30 @@ describe('PostFooter', () => {
|
||||
expect(screen.getByTestId('reported-post')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Post username', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it.each([
|
||||
true,
|
||||
false,
|
||||
])('is a clickable link %s', (leanerTab) => {
|
||||
renderComponent(mockPost, leanerTab);
|
||||
|
||||
if (leanerTab) {
|
||||
expect(screen.queryByTestId('learner-posts-link')).not.toBeInTheDocument();
|
||||
} else {
|
||||
expect(screen.queryByTestId('learner-posts-link')).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user