[deploy] Add comment subscription component to article page (#7205)

* wip

* Right aligned the comment subscription panel.

* Cleaned up the comment area on the article page so it uses crayon margin classes.

* Updated tests

* Removed old UI for comment subscriptions.

* Made Discussion text bigger and bolder.

* Fixed some layout issues as per PR comments.

* Fixed some layout issues as per PR comments.

* Updated Storybook stories, tests and snapshots

* wip

* Everything works now, just need to integrate snackbar.

* Removed setTimeout I was debugging with.

* Using the new utilities/http/request utility now.

* Added the <SnackbarPoller />

* Now when unsubscribed, the subscription type resets to all comments.

* Removed overflow hidden from .home CSS class so comment subscription component displays properly.

* Now unsuccessful subscribes/unsubscribes error properly.

* Changed button text from Done to Subscribe

* Merged the <SnackbarPoller /> component into the <Snackbar /> component.

* Fixed a propType issue.

* Fixed a test.

* Removed snackbar tests for now. Need to figure out polling in tests.

* Now comment subscription component is only loaded for logged on users.

* Added a comment.

* Updated some storybook stories.

* Fixed a small formatting issue.

* Reduced snackbar item lifespans to 3 seconds.

* Extracted <CogIcon /> to it's own file because other features are going to need it.

* Added some Storybook stories and tests for the <CogIcon /> component.

* Revert "Extracted <CogIcon /> to it's own file because other features are going to need it."

This reverts commit b30406a50e491c53c3dce5be03bcdf8112b043df.

* Put back <CogIcon /> component.

* Added some error handling if the component doesn't load.

* Moving some things around.

* Rename the article pack file.

* Changed wording from "article" to "post".

* Fixed false negative CSS issue. It was a dangling div tag.

* Add the option to add an optional close button to snackbar items.

* Fixed z-index of subscription type options panel so it is always visible on mobile.

* Reworked the comment subsciption utilities a bit.

* Added test for comment subscription utilities.

* Fixed a broken test from a refactor.

* Added more tests for comment subscription utiltities.

* Fixed comments footer bottom padding.

* Fixed issue with stale find in comment subscription test.

* Update app/javascript/packs/articlePage.jsx

Co-authored-by: ludwiczakpawel <ludwiczakpawel@gmail.com>

* Update app/assets/stylesheets/article-show.scss

Co-authored-by: ludwiczakpawel <ludwiczakpawel@gmail.com>

* Changed close button wording to Dismiss.

* Fixed padding to use utility variable instead.

* Added missing import for SASS file.

Co-authored-by: ludwiczakpawel <ludwiczakpawel@gmail.com>
This commit is contained in:
Nick Taylor 2020-05-05 12:50:30 -04:00 committed by GitHub
parent 62290759e9
commit a71d78df6e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 828 additions and 538 deletions

View file

@ -1,5 +1,6 @@
@import 'variables';
@import 'mixins';
@import 'config/import';
.unpublished {
background: $red;
@ -1308,6 +1309,7 @@ article {
max-width: 98%;
margin: auto;
margin-top: -80px;
padding-bottom: $su-6;
text-align: center;
.full-discussion-button {

View file

@ -124,8 +124,6 @@ a.header-link {
}
.comments-container {
width: 800px;
max-width: 98%;
padding-top: 10px;
margin: auto;
margin-bottom: 100px;

View file

@ -8,28 +8,42 @@ import {
RadioButton,
} from '@crayons';
const COMMENT_SUBSCRIPTION_TYPE = {
export const COMMENT_SUBSCRIPTION_TYPE = Object.freeze({
ALL: 'all_comments',
TOP: 'top_level_comments',
AUTHOR: 'only_author_comments',
};
NOT_SUBSCRIBED: 'not_subscribed',
});
export class CommentSubscription extends Component {
state = {
showOptions: false,
commentSubscriptionType: COMMENT_SUBSCRIPTION_TYPE.ALL,
subscribed: false,
};
constructor(props) {
const { subscriptionType } = props;
super(props);
componentDidMount() {
window.addEventListener('scroll', this.dropdownPlacementHandler);
const subscribed =
subscriptionType &&
(subscriptionType.length > 0 && subscriptionType) !==
COMMENT_SUBSCRIPTION_TYPE.NOT_SUBSCRIBED;
const initialState = {
subscriptionType: subscribed
? subscriptionType
: COMMENT_SUBSCRIPTION_TYPE.ALL,
subscribed,
showOptions: false,
};
this.state = initialState;
}
componentDidUpdate() {
const { showOptions } = this.state;
if (showOptions) {
window.addEventListener('scroll', this.dropdownPlacementHandler);
this.dropdownPlacementHandler();
} else {
window.removeEventListener('scroll', this.dropdownPlacementHandler);
}
}
@ -57,13 +71,17 @@ export class CommentSubscription extends Component {
commentSubscriptionClick = (event) => {
this.setState({
commentSubscriptionType: event.target.value,
subscriptionType: event.target.value,
});
};
render() {
const { showOptions, commentSubscriptionType, subscribed } = this.state;
const { onSubscribe, onUnsubscribe } = this.props;
const { showOptions, subscriptionType, subscribed } = this.state;
const {
onSubscribe,
onUnsubscribe,
positionType = 'relative',
} = this.props;
const CogIcon = () => (
<svg
@ -80,7 +98,7 @@ export class CommentSubscription extends Component {
);
return (
<div className="relative">
<div className={positionType}>
<ButtonGroup
ref={(element) => {
this.buttonGroupElement = element;
@ -90,9 +108,12 @@ export class CommentSubscription extends Component {
variant="outlined"
onClick={(_event) => {
if (subscribed) {
onUnsubscribe();
onUnsubscribe(COMMENT_SUBSCRIPTION_TYPE.NOT_SUBSCRIBED);
this.setState({
subscriptionType: COMMENT_SUBSCRIPTION_TYPE.ALL,
});
} else {
onSubscribe(commentSubscriptionType);
onSubscribe(subscriptionType);
}
this.setState({ subscribed: !subscribed });
@ -113,7 +134,13 @@ export class CommentSubscription extends Component {
</ButtonGroup>
{subscribed && (
<Dropdown
className={showOptions ? 'inline-block w-full' : null}
className={
showOptions
? `inline-block z-30 right-4 left-4 s:right-0 s:left-auto${
positionType === 'relative' ? ' w-full' : ''
}`
: null
}
ref={(element) => {
this.dropdownElement = element;
}}
@ -123,10 +150,8 @@ export class CommentSubscription extends Component {
<RadioButton
id="subscribe-all"
name="subscribe_comments"
value="all_comments"
checked={
commentSubscriptionType === COMMENT_SUBSCRIPTION_TYPE.ALL
}
value={COMMENT_SUBSCRIPTION_TYPE.ALL}
checked={subscriptionType === COMMENT_SUBSCRIPTION_TYPE.ALL}
onClick={this.commentSubscriptionClick}
/>
<label htmlFor="subscribe-all" className="crayons-field__label">
@ -141,11 +166,9 @@ export class CommentSubscription extends Component {
<RadioButton
id="subscribe-toplevel"
name="subscribe_comments"
value="top_level_comments"
value={COMMENT_SUBSCRIPTION_TYPE.TOP}
onClick={this.commentSubscriptionClick}
checked={
commentSubscriptionType === COMMENT_SUBSCRIPTION_TYPE.TOP
}
checked={subscriptionType === COMMENT_SUBSCRIPTION_TYPE.TOP}
/>
<label
htmlFor="subscribe-toplevel"
@ -163,10 +186,10 @@ export class CommentSubscription extends Component {
<RadioButton
id="subscribe-author"
name="subscribe_comments"
value="only_author_comments"
value={COMMENT_SUBSCRIPTION_TYPE.AUTHOR}
onClick={this.commentSubscriptionClick}
checked={
commentSubscriptionType === COMMENT_SUBSCRIPTION_TYPE.AUTHOR
subscriptionType === COMMENT_SUBSCRIPTION_TYPE.AUTHOR
}
/>
<label
@ -186,7 +209,7 @@ export class CommentSubscription extends Component {
className="w-100"
onClick={(_event) => {
this.setState((prevState) => {
onSubscribe(prevState.commentSubscriptionType);
onSubscribe(prevState.subscriptionType);
return { ...prevState, showOptions: false };
});
@ -204,6 +227,10 @@ export class CommentSubscription extends Component {
CommentSubscription.displayName = 'CommentSubscription';
CommentSubscription.propTypes = {
positionType: PropTypes.oneOf(['absolute', 'relative', 'static']).isRequired,
onSubscribe: PropTypes.func.isRequired,
onUnsubscribe: PropTypes.func.isRequired,
subscriptionType: PropTypes.oneOf(
Object.entries(COMMENT_SUBSCRIPTION_TYPE).map(([, value]) => value),
).isRequired,
};

View file

@ -1,14 +1,62 @@
import { h } from 'preact';
import { action } from '@storybook/addon-actions';
import { CommentSubscription } from '../CommentSubscription';
import { withKnobs, select } from '@storybook/addon-knobs/react';
import {
CommentSubscription,
COMMENT_SUBSCRIPTION_TYPE,
} from '../CommentSubscription';
export default {
title: 'App Components/Comment Subscription',
decorators: [withKnobs],
};
export const Default = () => (
const commonProps = {
onSubscribe: action('subscribed'),
onUnsubscribe: action('unsubscribed'),
};
export const Unsubscribed = () => (
<CommentSubscription
onSubscribe={action('subscribed')}
onUnsubscribe={action('unsubscribed')}
{...commonProps}
subscriptionType={select(
'subscriptionType',
COMMENT_SUBSCRIPTION_TYPE,
COMMENT_SUBSCRIPTION_TYPE.NOT_SUBSCRIBED,
)}
/>
);
Unsubscribed.story = {
name: 'unsubscribed',
};
export const Subscribed = () => (
<CommentSubscription
{...commonProps}
subscriptionType={select(
'subscriptionType',
COMMENT_SUBSCRIPTION_TYPE,
COMMENT_SUBSCRIPTION_TYPE.ALL,
)}
/>
);
Subscribed.story = {
name: 'subscribed',
};
export const SubscribedButNotDefault = () => (
<CommentSubscription
{...commonProps}
subscriptionType={select(
'subscriptionType',
COMMENT_SUBSCRIPTION_TYPE,
COMMENT_SUBSCRIPTION_TYPE.AUTHOR,
)}
/>
);
SubscribedButNotDefault.story = {
name: 'subscribed (with comment type other than the default',
};

View file

@ -1,7 +1,10 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { deep } from 'preact-render-spy';
import { CommentSubscription } from '../CommentSubscription';
import {
CommentSubscription,
COMMENT_SUBSCRIPTION_TYPE,
} from '../CommentSubscription';
describe('<CommentSubscription />', () => {
it('should render as a plain subscribe button if not currently subscribed', () => {
@ -10,7 +13,17 @@ describe('<CommentSubscription />', () => {
expect(tree).toMatchSnapshot();
});
it('should subscribe to all comments by default', () => {
it('should render as subscribed with the given subscription type', () => {
const tree = render(
<CommentSubscription
subscriptionType={COMMENT_SUBSCRIPTION_TYPE.AUTHOR}
/>,
);
expect(tree).toMatchSnapshot();
});
it('should subscribe when the subscribe button is pressed', () => {
const onSubscribe = jest.fn();
const wrapper = deep(<CommentSubscription onSubscribe={onSubscribe} />);
wrapper.find('ButtonGroup').find('Button').first().simulate('click');
@ -18,7 +31,7 @@ describe('<CommentSubscription />', () => {
expect(onSubscribe).toHaveBeenCalled();
});
it('should unsubscribe from all comments', () => {
it('should unsubscribe from all comments when the Unsubscribe button is pressed', () => {
const onSubscribe = jest.fn();
const onUnsubscribe = jest.fn();
const wrapper = deep(
@ -35,21 +48,23 @@ describe('<CommentSubscription />', () => {
expect(onUnsubscribe).toHaveBeenCalled();
});
it('should show subscription options once subscribed if cog icon is clicked', () => {
it('should show subscription options once subscribed if the cog button is clicked', () => {
const onSubscribe = jest.fn();
const onUnsubscribe = jest.fn();
const wrapper = deep(
<CommentSubscription
subscriptionType={COMMENT_SUBSCRIPTION_TYPE.AUTHOR}
onSubscribe={onSubscribe}
onUnsubscribe={onUnsubscribe}
/>,
);
wrapper.find('ButtonGroup').find('Button').first().simulate('click'); // Subscribe button
wrapper.find('ButtonGroup').find('Button').last().simulate('click'); // Cog icon button
const dropdown = wrapper.find('Dropdown');
expect(dropdown.attr('className')).toEqual('inline-block w-full');
expect(dropdown.attr('className')).toEqual(
'inline-block z-30 right-4 left-4 s:right-0 s:left-auto w-full',
);
// 3 options for comment subscription
expect(dropdown.find('RadioButton').length).toEqual(3);
@ -58,7 +73,26 @@ describe('<CommentSubscription />', () => {
expect(dropdown.find('Button').length).toEqual(1);
});
it('should hide subscription options once subscribed if cog icon is clicked and the subscriptions options panel is open', () => {
it('should not have full width for options when positionType is anything but "relative"', () => {
const onSubscribe = jest.fn();
const onUnsubscribe = jest.fn();
const wrapper = deep(
<CommentSubscription
onSubscribe={onSubscribe}
onUnsubscribe={onUnsubscribe}
positionType="static"
subscriptionType={COMMENT_SUBSCRIPTION_TYPE.AUTHOR}
/>,
);
wrapper.find('ButtonGroup').find('Button').last().simulate('click'); // Cog icon button to open subscription options panel
expect(wrapper.find('Dropdown').attr('className')).toEqual(
'inline-block z-30 right-4 left-4 s:right-0 s:left-auto',
);
});
it('should hide subscription options when the Done button is clicked', () => {
const onSubscribe = jest.fn();
const onUnsubscribe = jest.fn();
const wrapper = deep(
@ -77,44 +111,45 @@ describe('<CommentSubscription />', () => {
});
it('should update comment subscription when the done button is clicked in the subscription options panel', () => {
let commentType;
const onSubscribe = jest.fn((commentSubscriptionType) => {
commentType = commentSubscriptionType;
});
const onSubscribe = jest.fn();
const onUnsubscribe = jest.fn();
const ONLY_AUTHOR_COMMENTS = 'only_author_comments';
const wrapper = deep(
<CommentSubscription
subscriptionType={COMMENT_SUBSCRIPTION_TYPE.TOP}
onSubscribe={onSubscribe}
onUnsubscribe={onUnsubscribe}
/>,
);
wrapper.find('ButtonGroup').find('button').first().simulate('click'); // Subscribe button
wrapper.find('ButtonGroup').find('button').last().simulate('click'); // Subscribe button
expect(commentType).toEqual('all_comments');
expect(wrapper.state('subscriptionType')).toEqual(
COMMENT_SUBSCRIPTION_TYPE.TOP,
);
wrapper.find('ButtonGroup').find('Button').last().simulate('click'); // Cog icon button
const dropdown = wrapper.find('Dropdown');
// Select the author comments only.
const authorCommentsOnlyRadioButton = dropdown.find('RadioButton').last();
const authorCommentsOnlyRadioButton = wrapper
.find('Dropdown')
.find('RadioButton')
.last();
expect(authorCommentsOnlyRadioButton.attr('value')).toEqual(
ONLY_AUTHOR_COMMENTS,
COMMENT_SUBSCRIPTION_TYPE.AUTHOR,
);
authorCommentsOnlyRadioButton.simulate('click', {
target: { value: ONLY_AUTHOR_COMMENTS },
target: { value: COMMENT_SUBSCRIPTION_TYPE.AUTHOR },
});
const done = dropdown.find('Button');
const done = wrapper.find('Dropdown').find('Button');
done.simulate('click');
expect(commentType).toEqual(ONLY_AUTHOR_COMMENTS);
expect(wrapper.state('subscriptionType')).toEqual(
COMMENT_SUBSCRIPTION_TYPE.AUTHOR,
);
// Called once by the initial subscribe and
// a second time by clicking on the Done button.
expect(onSubscribe).toHaveBeenCalledTimes(2);
expect(onSubscribe).toHaveBeenCalled();
});
});

View file

@ -18,3 +18,134 @@ exports[`<CommentSubscription /> should render as a plain subscribe button if no
</div>
</div>
`;
exports[`<CommentSubscription /> should render as subscribed with the given subscription type 1`] = `
<div
class="relative"
>
<div
class="crayons-btn-group"
>
<button
class="crayons-btn crayons-btn--outlined"
disabled={false}
onClick={[Function]}
type="button"
>
Unsubscribe
</button>
<button
class="crayons-btn crayons-btn--outlined crayons-btn--icon"
disabled={false}
onClick={[Function]}
type="button"
>
<svg
aria-labelledby="ai2ols8ka2ohfp0z568lj68ic2du21s"
class="crayons-icon"
height="24"
role="img"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title
id="ai2ols8ka2ohfp0z568lj68ic2du21s"
>
Preferences
</title>
<path
d="M12 1l9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 110-8 4 4 0 010 8zm0-2a2 2 0 100-4 2 2 0 000 4z"
/>
</svg>
</button>
</div>
<div
class="crayons-dropdown"
>
<div
class="crayons-fields mb-5"
>
<div
class="crayons-field crayons-field--radio"
>
<input
checked={false}
class="crayons-radio"
id="subscribe-all"
name="subscribe_comments"
onClick={[Function]}
type="radio"
value="all_comments"
/>
<label
class="crayons-field__label"
htmlFor="subscribe-all"
>
All comments
<p
class="crayons-field__description"
>
Youll receive notifications for all new comments.
</p>
</label>
</div>
<div
class="crayons-field crayons-field--radio"
>
<input
checked={false}
class="crayons-radio"
id="subscribe-toplevel"
name="subscribe_comments"
onClick={[Function]}
type="radio"
value="top_level_comments"
/>
<label
class="crayons-field__label"
htmlFor="subscribe-toplevel"
>
Top-level comments
<p
class="crayons-field__description"
>
Youll receive notifications only for all new top-level comments.
</p>
</label>
</div>
<div
class="crayons-field crayons-field--radio"
>
<input
checked={true}
class="crayons-radio"
id="subscribe-author"
name="subscribe_comments"
onClick={[Function]}
type="radio"
value="only_author_comments"
/>
<label
class="crayons-field__label"
htmlFor="subscribe-author"
>
Post author comments
<p
class="crayons-field__description"
>
Youll receive notifications only if post author sends a new comment.
</p>
</label>
</div>
</div>
<button
class="crayons-btn w-100"
disabled={false}
onClick={[Function]}
type="button"
>
Done
</button>
</div>
</div>
`;

View file

@ -0,0 +1,114 @@
import fetch from 'jest-fetch-mock';
import {
getCommentSubscriptionStatus,
setCommentSubscriptionStatus,
} from '../commentSubscriptionUtilities';
/* global globalThis */
describe('Comment Subscription Utilities', () => {
const csrfToken = 'this-is-a-csrf-token';
const articleID = 26; // Just a random article ID.
beforeAll(() => {
globalThis.fetch = fetch;
globalThis.getCsrfToken = async () => csrfToken;
});
afterAll(() => {
delete globalThis.fetch;
delete globalThis.getCsrfToken;
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('getCommentSubscriptionStatus', () => {
it('should return a subscription status', async () => {
const response = '{ "config": "all_comments" }';
fetch.mockResponse(response);
const subscriptionStatus = await getCommentSubscriptionStatus(articleID);
expect(subscriptionStatus).toEqual(JSON.parse(response));
});
it('should return a friendly error message when it fails', async () => {
fetch.mockResponse('<html>...some error page</html>');
const subscriptionStatus = await getCommentSubscriptionStatus(articleID);
expect(subscriptionStatus instanceof Error).toEqual(true);
expect(subscriptionStatus.message).toEqual(
'An error occurred, please try again',
);
});
});
describe('setCommentSubscriptionStatus', () => {
it('should unsubscribe', async () => {
fetch.mockResponse('false');
const subscriptionStatusMessage = await setCommentSubscriptionStatus(
articleID,
'not_subscribed',
);
expect(subscriptionStatusMessage).toEqual(
'You have been unsubscribed from comments for this post',
);
});
it('should subscribe', async () => {
fetch.mockResponse(true);
const subscriptionStatusMessage = await setCommentSubscriptionStatus(
articleID,
'all_comments',
);
expect(subscriptionStatusMessage).toEqual(
'You have been subscribed to all comments',
);
});
it('should return a friendly error message if HTML is returned', async () => {
fetch.mockResponse('<html><body>error</body></html>');
const subscriptionStatusMessage = await setCommentSubscriptionStatus(
articleID,
'all_comments',
);
expect(subscriptionStatusMessage).toEqual(
'An error occurred, please try again',
);
});
it('should return a friendly error message if an error occurs', async () => {
fetch.mockReject(() => new Error('oh no'));
const subscriptionStatusMessage = await setCommentSubscriptionStatus(
articleID,
'all_comments',
);
expect(subscriptionStatusMessage).toEqual(
'An error occurred, please try again',
);
});
it('should return a friendly error message if an HTTP status object is returned', async () => {
fetch.mockResponse('{"status":405,"error":"Method Not Allowed"}');
const subscriptionStatusMessage = await setCommentSubscriptionStatus(
articleID,
'all_comments',
);
expect(subscriptionStatusMessage).toEqual(
'An error occurred, please try again',
);
});
});
});

View file

@ -0,0 +1,69 @@
import { request } from '../utilities/http/request';
/**
* Gets the comment subscription status for a given article.
*
* @param {number} articleId
*
* @returns {string} The subscription status.
*/
export async function getCommentSubscriptionStatus(articleId) {
try {
const response = await request(
`/notification_subscriptions/Article/${articleId}`,
);
const subscriptionStatus = await response.json();
return subscriptionStatus;
} catch (error) {
return new Error('An error occurred, please try again');
}
}
/**
* Set's the subscription status for a given article.
*
* @param {number} articleId
* @param {string} subscriptionType
*
* @returns {string} A friendly message in regards to subscription status.
*/
export async function setCommentSubscriptionStatus(
articleId,
subscriptionType,
) {
let message;
try {
const response = await request(
`/notification_subscriptions/Article/${articleId}`,
{
method: 'POST',
body: JSON.stringify({ config: subscriptionType }),
},
);
// true means you're subscribed, false means unsubscribed
const subscribed = await response.json();
if (typeof subscribed !== 'boolean') {
message = 'An error occurred, please try again';
return message;
}
message = 'You have been unsubscribed from comments for this post';
if (subscribed) {
message = `You have been subscribed to ${subscriptionType.replace(
/_/g,
' ',
)}`;
}
} catch (error) {
message = 'An error occurred, please try again';
}
return message;
}

View file

@ -1 +1,2 @@
export * from './CommentSubscription';
export * from './commentSubscriptionUtilities';

View file

@ -1,15 +1,178 @@
import { h } from 'preact';
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import { snackbarItemProps } from './SnackbarItem';
import { SnackbarItem } from './SnackbarItem';
export const Snackbar = ({ children = [] }) => (
<div className={children.length > 0 ? 'crayons-snackbar' : 'hidden'}>
{children}
</div>
);
let snackbarItems = [];
Snackbar.displayName = 'Snackbar';
export function addSnackbarItem(snackbarItem) {
if (!Array.isArray(snackbarItem.actions)) {
snackbarItem.actions = []; // eslint-disable-line no-param-reassign
}
snackbarItems.push(snackbarItem);
}
export class Snackbar extends Component {
state = {
snacks: [],
};
pollingId;
paused = false;
pauseLifespan;
resumeLifespan;
componentDidMount() {
this.initializePolling();
}
componentDidUpdate() {
if (!this.pauseLifespan) {
this.pauseLifespan = (_event) => {
this.paused = true;
};
this.resumeLifespan = (event) => {
event.stopPropagation();
this.paused = false;
};
this.element.addEventListener('mouseover', this.pauseLifespan);
this.element.addEventListener('mouseout', this.resumeLifespan, true);
}
}
componentWillUnmount() {
if (this.element) {
this.element.removeEventListener('mouseover', this.pauseLifespan);
this.element.addEventListener('mouseout', this.resumeLifespan);
}
}
initializePolling() {
const { pollingTime, lifespan } = this.props;
this.pollingId = setInterval(() => {
if (snackbarItems.length > 0) {
// Need to add the lifespan to snackbar items because each second that goes by, we
// decrease the lifespan until it is no more.
const newSnacks = snackbarItems.map((snackbarItem) => ({
...snackbarItem,
lifespan,
}));
snackbarItems = [];
this.updateSnackbarItems(newSnacks);
// Start the lifespan countdowns for each new snackbar item.
newSnacks.forEach((snack) => {
// eslint-disable-next-line no-param-reassign
snack.lifespanTimeoutId = setTimeout(() => {
this.decreaseLifespan(snack);
}, 1000);
if (snack.addCloseButton) {
// Adds an optional close button if addDefaultACtion is true.
snack.actions.push({
text: 'Dismiss',
handler: () => {
this.setState((prevState) => {
return {
prevState,
snacks: prevState.snacks.filter(
(potentialSnackToFilterOut) =>
potentialSnackToFilterOut !== snack,
),
};
});
},
});
}
});
}
}, pollingTime);
}
updateSnackbarItems(newSnacks) {
this.setState((prevState) => {
let updatedSnacks = [...prevState.snacks, ...newSnacks];
if (updatedSnacks.length > 3) {
const snacksToBeDiscarded = updatedSnacks.slice(
0,
updatedSnacks.length - 3,
);
snacksToBeDiscarded.forEach(({ lifespanTimeoutId }) => {
clearTimeout(lifespanTimeoutId);
});
updatedSnacks = updatedSnacks.slice(updatedSnacks.length - 3);
}
return { ...prevState, snacks: updatedSnacks };
});
}
decreaseLifespan(snack) {
/* eslint-disable no-param-reassign */
if (!this.paused && snack.lifespan === 0) {
clearTimeout(snack.lifespanTimeoutId);
this.setState((prevState) => {
const snacks = prevState.snacks.filter(
(currentSnack) => currentSnack !== snack,
);
return {
...prevState,
snacks,
};
});
return;
}
if (!this.paused) {
snack.lifespan -= 1;
}
snack.lifespanTimeoutId = setTimeout(() => {
this.decreaseLifespan(snack);
}, 1000);
/* eslint-enable no-param-reassign */
}
render() {
const { snacks } = this.state;
return (
<div
className={snacks.length > 0 ? 'crayons-snackbar' : 'hidden'}
ref={(element) => {
this.element = element;
}}
>
{snacks.map(({ message, actions = [] }) => (
<SnackbarItem message={message} actions={actions} />
))}
</div>
);
}
}
Snackbar.defaultProps = {
lifespan: 5,
pollingTime: 300,
};
Snackbar.displayName = 'SnackbarPoller';
Snackbar.propTypes = {
children: PropTypes.arrayOf(snackbarItemProps).isRequired,
lifespan: PropTypes.number,
pollingTime: PropTypes.number,
};

View file

@ -1,155 +0,0 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import { Snackbar } from './Snackbar';
import { SnackbarItem } from './SnackbarItem';
let snackbarItems = [];
export function addSnackbarItem(snackbarItem) {
snackbarItems.push(snackbarItem);
}
export class SnackbarPoller extends Component {
state = {
snacks: [],
};
pollingId;
paused = false;
pauseLifespan;
resumeLifespan;
componentDidMount() {
this.initializePolling();
}
componentDidUpdate() {
if (!this.pauseLifespan) {
this.pauseLifespan = (_event) => {
this.paused = true;
};
this.resumeLifespan = (event) => {
event.stopPropagation();
this.paused = false;
};
this.element.base.addEventListener('mouseover', this.pauseLifespan);
this.element.base.addEventListener('mouseout', this.resumeLifespan, true);
}
}
componentWillUnmount() {
if (this.element) {
this.element.base.removeEventListener('mouseover', this.pauseLifespan);
this.element.base.addEventListener('mouseout', this.resumeLifespan);
}
}
initializePolling() {
const { pollingTime, lifespan } = this.props;
this.pollingId = setInterval(() => {
if (snackbarItems.length > 0) {
// Need to add the lifespan to snackbar items because each second that goes by, we
// decrease the lifespan until it is no more.
const newSnacks = snackbarItems.map((snackbarItem) => ({
...snackbarItem,
lifespan,
}));
newSnacks.forEach((snack) => {
// eslint-disable-next-line no-param-reassign
snack.lifespanTimeoutId = setTimeout(() => {
this.decreaseLifespan(snack);
}, 1000);
});
snackbarItems = [];
this.updateSnackbarItems(newSnacks);
}
}, pollingTime);
}
updateSnackbarItems(newSnacks) {
this.setState((prevState) => {
let updatedSnacks = [...prevState.snacks, ...newSnacks];
if (updatedSnacks.length > 3) {
const snacksToBeDiscarded = updatedSnacks.slice(
0,
updatedSnacks.length - 3,
);
snacksToBeDiscarded.forEach(({ lifespanTimeoutId }) => {
clearTimeout(lifespanTimeoutId);
});
updatedSnacks = updatedSnacks.slice(updatedSnacks.length - 3);
}
return { ...prevState, snacks: updatedSnacks };
});
}
decreaseLifespan(snack) {
/* eslint-disable no-param-reassign */
if (!this.paused && snack.lifespan === 0) {
clearTimeout(snack.lifespanTimeoutId);
this.setState((prevState) => {
const snacks = prevState.snacks.filter(
(currentSnack) => currentSnack !== snack,
);
return {
...prevState,
snacks,
};
});
return;
}
if (!this.paused) {
snack.lifespan -= 1;
}
snack.lifespanTimeoutId = setTimeout(() => {
this.decreaseLifespan(snack);
}, 1000);
/* eslint-enable no-param-reassign */
}
render() {
const { snacks } = this.state;
return (
<Snackbar
ref={(element) => {
this.element = element;
}}
>
{snacks.map(({ message, actions = [] }) => (
<SnackbarItem message={message} actions={actions} />
))}
</Snackbar>
);
}
}
SnackbarPoller.defaultProps = {
lifespan: 5,
pollingTime: 300,
};
SnackbarPoller.displayName = 'SnackbarPoller';
SnackbarPoller.propTypes = {
lifespan: PropTypes.number,
pollingTime: PropTypes.number,
};

View file

@ -1,104 +1,50 @@
import { h } from 'preact';
import { action } from '@storybook/addon-actions';
import { Snackbar, SnackbarItem } from '..';
import faker from 'faker';
import { number } from '@storybook/addon-knobs';
import { Snackbar, addSnackbarItem } from '..';
export default {
title: 'App Components/Snackbar',
title: 'App Components/Snackbar/Snackbar',
};
export const Description = () => (
<div className="container">
<div className="body">
<h2>Snackbars</h2>
<p>
Snackbars inform users of a process that an app has performed. They
appear temporarily, towards the bottom of the screen. They shouldnt
interrupt the user experience, and they dont require user input to
disappear.
</p>
<p>
A Snackbar can contain a single action. Because they disappear
automatically, the action shouldnt be Dismiss or Cancel.
</p>
<p>
A Snackbar disappears after 5000ms by default. Countdown will be paused
when user mouse over the snackbar.
</p>
<p>
Snackbars can be stacked on top of each other if there&apos;s more of
them. New ones show up at the bottom of snackbar. We can display maximum
3 snackbars at a time.
</p>
</div>
<div>
<h3>Usage</h3>
<p>
The Snackbar component has a default lifespan of 5000ms if no
{' '}
<code>lifespan</code>
{' '}
prop is provided. It also has a default polling
time of 300ms to check for new Snackbar items if no
{' '}
<code>pollingTime</code>
{' '}
prop is provided.
</p>
<pre>
&lt;Snackbar lifespan=&quot;3000&quot; pollingTime=&quot;300&quot; /&gt;
</pre>
<h4>Adding a Snackbar Item</h4>
<pre>
addSnackbarItem(&#123; text: &apos;Action 1&apos;, handler: event =&gt;
&#123; console.log&#40;&apos;Action 1 clicked&apos;&#41; &#125; &#125;)
</pre>
</div>
</div>
);
export const SimulateAddingSnackbarItems = () => {
addSnackbarItem({
message: faker.hacker.phrase(),
actions: [
{ text: faker.lorem.word(), handler: action('action 1 clicked') },
],
});
Description.story = {
name: 'description',
};
addSnackbarItem({
message: faker.hacker.phrase(),
actions: [
{ text: faker.lorem.word(), handler: action('action 2 clicked') },
],
});
export const OneSnackbarItem = () => (
<Snackbar>
<SnackbarItem message="File uploaded successfully" />
</Snackbar>
);
addSnackbarItem({
message: faker.hacker.phrase(),
actions: [
{ text: faker.lorem.word(), handler: action('action 3 clicked') },
],
});
OneSnackbarItem.story = {
name: 'one snackbar item',
};
export const MultipleSnackbarItems = () => {
const snackbarItems = [
{
message: 'File uploaded successfully',
},
{
message: 'Unable to save file',
actions: [
{ text: 'Retry', handler: action('save file retry') },
{ text: 'Abort', handler: action('abort file save') },
],
},
{
message: 'There was a network error',
actions: [{ text: 'Retry', handler: action('retry network') }],
},
];
addSnackbarItem({
message: faker.hacker.phrase(),
actions: [
{ text: faker.lorem.word(), handler: action('action 3 clicked') },
],
});
return (
<Snackbar>
{snackbarItems.map(({ message, actions = [] }) => (
<SnackbarItem message={message} actions={actions} />
))}
</Snackbar>
<Snackbar
lifespan={number('lifespan', 5)}
pollingTime={number('pollingTime', 300)}
/>
);
};
MultipleSnackbarItems.story = {
name: 'multiple snackbar items',
SimulateAddingSnackbarItems.story = {
name: 'simulating adding multiple snackbar items',
};

View file

@ -1,50 +0,0 @@
import { h } from 'preact';
import { action } from '@storybook/addon-actions';
import faker from 'faker';
import { number } from '@storybook/addon-knobs';
import { SnackbarPoller, addSnackbarItem } from '..';
export default {
title: 'App Components/Snackbar/SnackbarPoller',
};
export const SimulateAddingSnackbarItems = () => {
addSnackbarItem({
message: faker.hacker.phrase(),
actions: [
{ text: faker.lorem.word(), handler: action('action 1 clicked') },
],
});
addSnackbarItem({
message: faker.hacker.phrase(),
actions: [
{ text: faker.lorem.word(), handler: action('action 2 clicked') },
],
});
addSnackbarItem({
message: faker.hacker.phrase(),
actions: [
{ text: faker.lorem.word(), handler: action('action 3 clicked') },
],
});
addSnackbarItem({
message: faker.hacker.phrase(),
actions: [
{ text: faker.lorem.word(), handler: action('action 3 clicked') },
],
});
return (
<SnackbarPoller
lifespan={number('lifespan', 5)}
pollingTime={number('pollingTime', 300)}
/>
);
};
SimulateAddingSnackbarItems.story = {
name: 'simulating adding multiple snackbar items',
};

View file

@ -1,46 +0,0 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { Snackbar, SnackbarItem } from '..';
describe('<Snackbar />', () => {
it('should render with one snackbar item', () => {
const tree = render(
<Snackbar>
<SnackbarItem message="File uploaded successfully" />
</Snackbar>,
);
expect(tree).toMatchSnapshot();
});
it('should render with multiple snackbar items', () => {
const snackbarItems = [
{
message: 'File uploaded successfully',
},
{
message: 'Unable to save file',
actions: [
{ text: 'Retry', handler: jest.fn() },
{ text: 'Abort', handler: jest.fn() },
],
},
{
message: 'There was a network error',
actions: [{ text: 'Retry', handler: jest.fn() }],
},
];
const tree = render(
<Snackbar>
{snackbarItems.map(({ message, actions = [] }) => (
<SnackbarItem message={message} actions={actions} />
))}
</Snackbar>,
);
expect(tree).toMatchSnapshot();
});
});

View file

@ -1,89 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Snackbar /> should render with multiple snackbar items 1`] = `
<div
class="crayons-snackbar"
>
<div
class="crayons-snackbar__item flex"
>
<div
class="crayons-snackbar__body"
>
File uploaded successfully
</div>
<div
class="crayons-snackbar__actions"
/>
</div>
<div
class="crayons-snackbar__item flex"
>
<div
class="crayons-snackbar__body"
>
Unable to save file
</div>
<div
class="crayons-snackbar__actions"
>
<button
class="crayons-btn crayons-btn--ghost-success crayons-btn--inverted"
disabled={false}
onClick={[MockFunction]}
type="button"
>
Retry
</button>
<button
class="crayons-btn crayons-btn--ghost-success crayons-btn--inverted"
disabled={false}
onClick={[MockFunction]}
type="button"
>
Abort
</button>
</div>
</div>
<div
class="crayons-snackbar__item flex"
>
<div
class="crayons-snackbar__body"
>
There was a network error
</div>
<div
class="crayons-snackbar__actions"
>
<button
class="crayons-btn crayons-btn--ghost-success crayons-btn--inverted"
disabled={false}
onClick={[MockFunction]}
type="button"
>
Retry
</button>
</div>
</div>
</div>
`;
exports[`<Snackbar /> should render with one snackbar item 1`] = `
<div
class="crayons-snackbar"
>
<div
class="crayons-snackbar__item flex"
>
<div
class="crayons-snackbar__body"
>
File uploaded successfully
</div>
<div
class="crayons-snackbar__actions"
/>
</div>
</div>
`;

View file

@ -1,3 +1,2 @@
export * from './Snackbar';
export * from './SnackbarItem';
export * from './SnackbarPoller';

View file

@ -0,0 +1,15 @@
import { h } from 'preact';
export const CogIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
role="img"
aria-labelledby="ai2ols8ka2ohfp0z568lj68ic2du21s"
className="crayons-icon"
>
<title id="ai2ols8ka2ohfp0z568lj68ic2du21s">Preferences</title>
<path d="M12 1l9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 110-8 4 4 0 010 8zm0-2a2 2 0 100-4 2 2 0 000 4z" />
</svg>
);

View file

@ -0,0 +1,12 @@
import { h } from 'preact';
import { CogIcon } from '../CogIcon';
export default {
title: 'Components/Icons',
};
export const Default = () => <CogIcon />;
Default.story = {
name: 'cog icon',
};

View file

@ -0,0 +1,11 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import { CogIcon } from '../CogIcon';
describe('<CommentSubscription />', () => {
it('should render', () => {
const tree = render(<CogIcon />);
expect(tree).toMatchSnapshot();
});
});

View file

@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<CommentSubscription /> should render 1`] = `
<svg
aria-labelledby="ai2ols8ka2ohfp0z568lj68ic2du21s"
class="crayons-icon"
height="24"
role="img"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title
id="ai2ols8ka2ohfp0z568lj68ic2du21s"
>
Preferences
</title>
<path
d="M12 1l9.5 5.5v11L12 23l-9.5-5.5v-11L12 1zm0 2.311L4.5 7.653v8.694l7.5 4.342 7.5-4.342V7.653L12 3.311zM12 16a4 4 0 110-8 4 4 0 010 8zm0-2a2 2 0 100-4 2 2 0 000 4z"
/>
</svg>
`;

View file

@ -0,0 +1 @@
export * from './CogIcon';

View file

@ -0,0 +1,55 @@
import { h, render } from 'preact';
import { Snackbar, addSnackbarItem } from '../Snackbar';
// The Snackbar for the article page
const snackZone = document.getElementById('snack-zone');
render(<Snackbar lifespan="3" />, snackZone, snackZone.firstElementChild);
const userDataIntervalID = setInterval(async () => {
const { user = null, userStatus } = document.body.dataset;
if (userStatus === 'logged-out') {
// User is not logged on so nothing dynamic to add to the page.
clearInterval(userDataIntervalID);
return;
}
if (userStatus === 'logged-in' && user !== null) {
// Load the comment subscription button for logged on users.
clearInterval(userDataIntervalID);
const root = document.querySelector('#comment-subscription');
try {
const {
getCommentSubscriptionStatus,
setCommentSubscriptionStatus,
CommentSubscription,
} = await import('../CommentSubscription');
const { articleId } = document.querySelector('#article-body').dataset;
const { config: subscriptionType } = await getCommentSubscriptionStatus(
articleId,
);
const subscriptionRequestHandler = async (type) => {
const message = await setCommentSubscriptionStatus(articleId, type);
addSnackbarItem({ message, addCloseButton: true });
};
render(
<CommentSubscription
subscriptionType={subscriptionType}
positionType="static"
onSubscribe={subscriptionRequestHandler}
onUnsubscribe={subscriptionRequestHandler}
/>,
root,
root.firstElementChild,
);
} catch (e) {
document.querySelector('#comment-subscription').innerHTML =
'<p className="color-accent-danger">Unable to load Comment Subscription component.<br />Try refreshing the page.</p>';
}
}
});

View file

@ -41,26 +41,6 @@
</button>
<div class="dropdown-content">
<div>
<% if user_signed_in? %>
<div class="dropdown-link-row notification-subscriptions-area-row"
id="notification-subscriptions-area" data-notifiable-id="<%= @article.id %>" data-notifiable-type="Article">
<div class="notification-subscriptions-area-header">🔔 Notification Subscriptions
<button for="subscribe_notifications" id="notification-subscription-label_not_subscribed" class="notification-subscription-label notification-subscription-label-unsubscribe" data-payload="not_subscribed" name="unsubscribe">
Unsubscribe
</button>
</div>
<button for="subscribe_notifications" id="notification-subscription-label_all_comments" class="notification-subscription-label" data-payload="all_comments">
All Comments <span class="selected-emoji">✅</span>
</button>
<button for="subscribe_notifications" id="notification-subscription-label_top_level_comments" class="notification-subscription-label" data-payload="top_level_comments">
Top Level Comments <span class="selected-emoji">✅</span>
</button>
<button for="subscribe_notifications" id="notification-subscription-label_only_author_comments" class="notification-subscription-label" data-payload="only_author_comments">
Post Author Comments <span class="selected-emoji">✅</span>
</button>
<%= javascript_packs_with_chunks_tag "notificationSubscriptionHandler", defer: true %>
</div>
<% end %>
<div class="dropdown-link-row">
<clipboard-copy for="article-copy-link-input" aria-live="polite" aria-controls="article-copy-link-announcer">
<input type="text" id="article-copy-link-input" value="<%= article_url(@article) %>" readonly />

View file

@ -1,29 +1,31 @@
<% cache("whole-comment-area-#{@article.id}-#{@article.last_comment_at}-#{@article.show_comments}", expires_in: 2.hours) do %>
<div id="comments" data-updated-at="<%= Time.current %>">
<% if @article.show_comments %>
<div class="comments-container-container">
<div
class="comments-container"
id="comments-container"
data-commentable-id="<%= @article.id %>"
data-commentable-type="Article">
<%= render "/comments/form",
commentable: @article,
commentable_type: "Article" %>
<div class="comment-trees" id="comment-trees-container">
<% if @article.comments_count > 0 %>
<% Comment.tree_for(@article, @comments_to_show_count).each do |comment, sub_comments| %>
<% cache ["comment_root_cached_tree", comment] do %>
<%= tree_for(comment, sub_comments, @article) %>
<% end %>
<div class="relative flex justify-between items-center mx-2 s:mx-7 mb-2">
<span class="fs-2xl fw-bold">Discussion</span>
<div id="comment-subscription"></div>
</div>
<div
class="comments-container mx-2 s:mx-7"
id="comments-container"
data-commentable-id="<%= @article.id %>"
data-commentable-type="Article">
<%= render "/comments/form",
commentable: @article,
commentable_type: "Article" %>
<div class="comment-trees" id="comment-trees-container">
<% if @article.comments_count > 0 %>
<% Comment.tree_for(@article, @comments_to_show_count).each do |comment, sub_comments| %>
<% cache ["comment_root_cached_tree", comment] do %>
<%= tree_for(comment, sub_comments, @article) %>
<% end %>
<% end %>
</div>
</div>
<div class="show-comments-footer">
<%= render "articles/comments_actions" %>
<% end %>
</div>
</div>
<div class="show-comments-footer">
<%= render "articles/comments_actions" %>
</div>
<% end %>
</div>
<% end %>

View file

@ -1,7 +1,7 @@
<% title @article.title %>
<%= render "shared/webcomponents_loader_script" %>
<%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", defer: true %>
<%= javascript_packs_with_chunks_tag "clipboardCopy", "webShare", "articlePage", defer: true %>
<% cache("content-related-optional-scripts-#{@article.id}-#{@article.updated_at}-#{internal_navigation?}-#{user_signed_in?}", expires_in: 30.hours) do %>
<% if @article.processed_html.include? "ltag_gist-liquid-tag" %>

View file

@ -2,11 +2,12 @@
<div id="footer-container" class="container <%= "centered-footer" unless current_page.include?("stories-show") %>">
<div class="inner-footer-container">
<a href="/">Home</a> <a href="/about">About</a> <a href="/privacy">Privacy Policy</a>
<a href="/terms">Terms of Use</a> <a href="/contact">Contact</a> <a href="/code-of-conduct">Code of Conduct</a><br/>
<a href="/terms">Terms of Use</a> <a href="/contact">Contact</a> <a href="/code-of-conduct">Code of Conduct</a><br />
<%= community_qualified_name %> copyright <%= copyright_notice %>&nbsp; 🔥
</div>
</div>
</footer>
<div id="snack-zone"></div>
<% if Rails.env.production? %>
<%= javascript_include_tag "hello-dev.js", defer: true %>
<% end %>

View file

@ -11,7 +11,6 @@
<%= render "layouts/styles" %>
<style>
.home {
overflow: hidden;
position: relative;
margin: auto;
max-width: 1250px;