Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/components/SpamWarningBanner.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';

import { PageBanner } from '@openedx/paragon';

import { useIntl } from '@edx/frontend-platform/i18n';

import messages from '../discussions/messages';

const SPAM_WARNING_DISMISSED_KEY = 'discussions.spamWarningDismissed';

const SpamWarningBanner = ({ className = '' }) => {
const intl = useIntl();
const [isDismissed, setIsDismissed] = useState(false);

useEffect(() => {
try {
const dismissed = localStorage.getItem(SPAM_WARNING_DISMISSED_KEY);
setIsDismissed(dismissed === 'true');
} catch (e) {
setIsDismissed(false);
}
}, []);

const handleDismiss = () => {
try {
localStorage.setItem(SPAM_WARNING_DISMISSED_KEY, 'true');
setIsDismissed(true);
} catch (e) {
setIsDismissed(true);
}
};

if (isDismissed) {
return null;
}

return (
<PageBanner
variant="warning"
show={!isDismissed}
dismissible={false}
className={`spam-warning-banner ${className}`}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
width: '100%',
}}
>
<span style={{ textAlign: 'left', display: 'block' }}>
<strong>{intl.formatMessage(messages.spamWarningHeading)}:</strong>{' '}
{(() => {
const msg = intl.formatMessage(messages.spamWarningMessage);
const boldText = 'never invite you to join external groups or ask for personal or financial information';
const idx = msg.indexOf(boldText);
if (idx === -1) {
return msg;
}
return (
<>
{msg.slice(0, idx)}
<strong>{boldText}</strong>
{msg.slice(idx + boldText.length)}
</>
);
})()}
</span>
<button
type="button"
onClick={handleDismiss}
className="spam-warning-close-btn"
aria-label="Close warning"
>
×
</button>
</div>
</PageBanner>
);
};

SpamWarningBanner.propTypes = {
className: PropTypes.string,
};

export default SpamWarningBanner;
106 changes: 106 additions & 0 deletions src/components/SpamWarningBanner.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { act } from 'react';

import { fireEvent, render, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';

import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';

import { initializeStore } from '../store';
import SpamWarningBanner from './SpamWarningBanner';

const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
Object.defineProperty(window, 'localStorage', { value: localStorageMock });

let store;

function renderComponent(props = {}) {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<SpamWarningBanner {...props} />
</AppProvider>
</IntlProvider>,
);
return wrapper.container;
}

describe('SpamWarningBanner', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: ['Student'],
},
});
store = initializeStore();
localStorageMock.getItem.mockClear();
localStorageMock.setItem.mockClear();
});

afterEach(() => {
jest.clearAllMocks();
});

it('renders banner when not dismissed', () => {
localStorageMock.getItem.mockReturnValue(null);

renderComponent();

expect(screen.getByText('Reminder:')).toBeInTheDocument();
expect(localStorageMock.getItem).toHaveBeenCalledWith('discussions.spamWarningDismissed');
});

it('does not render banner when previously dismissed', () => {
localStorageMock.getItem.mockReturnValue('true');

renderComponent();

expect(screen.queryByText('Reminder:')).not.toBeInTheDocument();
});

it('dismisses banner when close button is clicked', () => {
localStorageMock.getItem.mockReturnValue(null);

renderComponent();

expect(screen.getByText('Reminder:')).toBeInTheDocument();

const closeButton = screen.getByRole('button', { name: /close warning/i });
act(() => {
fireEvent.click(closeButton);
});

expect(localStorageMock.setItem).toHaveBeenCalledWith('discussions.spamWarningDismissed', 'true');

act(() => {
expect(screen.queryByText('Reminder:')).not.toBeInTheDocument();
});
});

it('persists dismissal state across page reloads', () => {
localStorageMock.getItem.mockReturnValue('true');

renderComponent();

expect(screen.queryByText('Reminder:')).not.toBeInTheDocument();

expect(localStorageMock.getItem).toHaveBeenCalledWith('discussions.spamWarningDismissed');
});

it('applies custom className when provided', () => {
localStorageMock.getItem.mockReturnValue(null);

renderComponent({ className: 'custom-test-class' });

const bannerElement = document.querySelector('.spam-warning-banner.custom-test-class');
expect(bannerElement).toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions src/components/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar';
export { default as Search } from './Search';
export { default as SpamWarningBanner } from './SpamWarningBanner';
export { default as Spinner } from './Spinner';
export { default as TinyMCEEditor } from './TinyMCEEditor';
export { default as TopicStats } from './TopicStats';
2 changes: 2 additions & 0 deletions src/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export const ContentActions = {
CHANGE_TOPIC: 'topic_id',
CHANGE_TYPE: 'type',
VOTE: 'voted',
DELETE_COURSE_POSTS: 'delete-course-posts',
DELETE_ORG_POSTS: 'delete-org-posts',
};

/**
Expand Down
5 changes: 5 additions & 0 deletions src/discussions/common/AlertBanner.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const AlertBanner = ({
closeReason,
editByLabel,
closedByLabel,
postData,
}) => {
const intl = useIntl();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
Expand Down Expand Up @@ -53,6 +54,7 @@ const AlertBanner = ({
authorLabel={editByLabel}
labelColor={editByLabelColor && `text-${editByLabelColor}`}
reason={lastEdit.reason}
postData={postData}
/>
)}
{closed && (
Expand All @@ -62,6 +64,7 @@ const AlertBanner = ({
authorLabel={closedByLabel}
labelColor={closedByLabelColor && `text-${closedByLabelColor}`}
reason={closeReason}
postData={postData}
/>
)}
</>
Expand All @@ -82,6 +85,7 @@ AlertBanner.propTypes = {
editorUsername: PropTypes.string,
reason: PropTypes.string,
}),
postData: PropTypes.shape({}),
};

AlertBanner.defaultProps = {
Expand All @@ -92,6 +96,7 @@ AlertBanner.defaultProps = {
closeReason: undefined,
editByLabel: undefined,
lastEdit: {},
postData: null,
};

export default React.memo(AlertBanner);
4 changes: 4 additions & 0 deletions src/discussions/common/AlertBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const AlertBar = ({
authorLabel,
labelColor,
reason,
postData,
}) => {
const intl = useIntl();

Expand All @@ -28,6 +29,7 @@ const AlertBar = ({
labelColor={labelColor}
linkToProfile
postOrComment
postData={postData}
/>
</span>
<span
Expand All @@ -48,6 +50,7 @@ AlertBar.propTypes = {
authorLabel: PropTypes.string,
labelColor: PropTypes.string,
reason: PropTypes.string,
postData: PropTypes.shape({}),
};

AlertBar.defaultProps = {
Expand All @@ -56,6 +59,7 @@ AlertBar.defaultProps = {
authorLabel: '',
labelColor: '',
reason: '',
postData: null,
};

export default React.memo(AlertBar);
Loading