[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:
parent
62290759e9
commit
a71d78df6e
27 changed files with 828 additions and 538 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -124,8 +124,6 @@ a.header-link {
|
|||
}
|
||||
|
||||
.comments-container {
|
||||
width: 800px;
|
||||
max-width: 98%;
|
||||
padding-top: 10px;
|
||||
margin: auto;
|
||||
margin-bottom: 100px;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
You’ll 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"
|
||||
>
|
||||
You’ll 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"
|
||||
>
|
||||
You’ll 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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from './CommentSubscription';
|
||||
export * from './commentSubscriptionUtilities';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 shouldn’t
|
||||
interrupt the user experience, and they don’t require user input to
|
||||
disappear.
|
||||
</p>
|
||||
<p>
|
||||
A Snackbar can contain a single action. Because they disappear
|
||||
automatically, the action shouldn’t 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'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>
|
||||
<Snackbar lifespan="3000" pollingTime="300" />
|
||||
</pre>
|
||||
<h4>Adding a Snackbar Item</h4>
|
||||
<pre>
|
||||
addSnackbarItem({ text: 'Action 1', handler: event =>
|
||||
{ console.log('Action 1 clicked') } })
|
||||
</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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export * from './Snackbar';
|
||||
export * from './SnackbarItem';
|
||||
export * from './SnackbarPoller';
|
||||
|
|
|
|||
15
app/javascript/icons/CogIcon.jsx
Normal file
15
app/javascript/icons/CogIcon.jsx
Normal 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>
|
||||
);
|
||||
12
app/javascript/icons/__stories__/CogIcon.stories.jsx
Normal file
12
app/javascript/icons/__stories__/CogIcon.stories.jsx
Normal 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',
|
||||
};
|
||||
11
app/javascript/icons/__tests__/CogIcon.test.jsx
Normal file
11
app/javascript/icons/__tests__/CogIcon.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
`;
|
||||
1
app/javascript/icons/index.js
Normal file
1
app/javascript/icons/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './CogIcon';
|
||||
55
app/javascript/packs/articlePage.jsx
Normal file
55
app/javascript/packs/articlePage.jsx
Normal 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>';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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" %>
|
||||
|
|
|
|||
|
|
@ -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 %> 🔥
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<div id="snack-zone"></div>
|
||||
<% if Rails.env.production? %>
|
||||
<%= javascript_include_tag "hello-dev.js", defer: true %>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
<%= render "layouts/styles" %>
|
||||
<style>
|
||||
.home {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin: auto;
|
||||
max-width: 1250px;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue