From 1d267f5db6f21e283bd5ae4ce888c59456078bfb Mon Sep 17 00:00:00 2001 From: Eldar Amantay Date: Mon, 25 Jun 2018 23:01:56 +0200 Subject: [PATCH] ISSUE-73: UI support for DeleteMe request Closes #73 * Add the API post request deleteMe * Add a trigger button * Add an utils/email module with the template and window opener * Add a dropdown component * Add a button component * Hide the user-id text and a logout bout in the dropdown * Add a noop empty function to pass a default handler callback to components --- web/app/common/api.js | 7 ++ .../__column/auth-panel__column.scss | 2 - .../__sign-out/auth-panel__sign-out.scss | 3 + .../auth-panel/__tests__/auth-panel.test.js | 2 +- .../__user-id/auth-panel__user-id.jsx | 8 ++ .../__user-id/auth-panel__user-id.scss | 2 + .../__username/auth-panel__username.scss | 3 - web/app/components/auth-panel/auth-panel.jsx | 35 ++++++--- web/app/components/auth-panel/index.js | 3 +- .../button/_focused/button_focused.scss | 3 + .../components/button/_kind/button_kind.scss | 19 +++++ web/app/components/button/button.jsx | 69 +++++++++++++++++ web/app/components/button/button.scss | 6 ++ web/app/components/button/index.js | 5 ++ .../dropdown/__content/dropdown__content.scss | 14 ++++ .../dropdown/__item/dropdown__item.jsx | 8 ++ .../dropdown/__item/dropdown__item.scss | 14 ++++ .../dropdown/__items/dropdown__items.scss | 3 + .../dropdown/__title/dropdown__title.scss | 12 +++ .../dropdown/_active/dropdown_active.scss | 5 ++ web/app/components/dropdown/dropdown.jsx | 76 +++++++++++++++++++ web/app/components/dropdown/dropdown.scss | 4 + web/app/components/dropdown/index.js | 10 +++ web/app/testUtils/mockStyles.js | 1 + web/app/utils/email.js | 35 +++++++++ web/app/utils/noop.js | 1 + web/package.json | 5 +- 27 files changed, 335 insertions(+), 20 deletions(-) create mode 100644 web/app/components/auth-panel/__sign-out/auth-panel__sign-out.scss create mode 100644 web/app/components/auth-panel/__user-id/auth-panel__user-id.jsx delete mode 100644 web/app/components/auth-panel/__username/auth-panel__username.scss create mode 100644 web/app/components/button/_focused/button_focused.scss create mode 100644 web/app/components/button/_kind/button_kind.scss create mode 100644 web/app/components/button/button.jsx create mode 100644 web/app/components/button/button.scss create mode 100644 web/app/components/button/index.js create mode 100644 web/app/components/dropdown/__content/dropdown__content.scss create mode 100644 web/app/components/dropdown/__item/dropdown__item.jsx create mode 100644 web/app/components/dropdown/__item/dropdown__item.scss create mode 100644 web/app/components/dropdown/__items/dropdown__items.scss create mode 100644 web/app/components/dropdown/__title/dropdown__title.scss create mode 100644 web/app/components/dropdown/_active/dropdown_active.scss create mode 100644 web/app/components/dropdown/dropdown.jsx create mode 100644 web/app/components/dropdown/dropdown.scss create mode 100644 web/app/components/dropdown/index.js create mode 100644 web/app/testUtils/mockStyles.js create mode 100644 web/app/utils/email.js create mode 100644 web/app/utils/noop.js diff --git a/web/app/common/api.js b/web/app/common/api.js index d79faf2e..8534874b 100644 --- a/web/app/common/api.js +++ b/web/app/common/api.js @@ -69,6 +69,13 @@ export const getUser = () => withCredentials: true, }); +/* GDPR */ + +export const deleteMe = () => + fetcher.post({ + url: `/deleteme?site=${siteId}`, + }); + /* admin */ export const pinComment = ({ id, url }) => fetcher.put({ diff --git a/web/app/components/auth-panel/__column/auth-panel__column.scss b/web/app/components/auth-panel/__column/auth-panel__column.scss index fa430522..8706f23b 100644 --- a/web/app/components/auth-panel/__column/auth-panel__column.scss +++ b/web/app/components/auth-panel/__column/auth-panel__column.scss @@ -1,8 +1,6 @@ .auth-panel__column { &:nth-child(1) { - overflow: hidden; font-weight: 700; - text-overflow: ellipsis; } &:nth-child(2) { diff --git a/web/app/components/auth-panel/__sign-out/auth-panel__sign-out.scss b/web/app/components/auth-panel/__sign-out/auth-panel__sign-out.scss new file mode 100644 index 00000000..17f5627c --- /dev/null +++ b/web/app/components/auth-panel/__sign-out/auth-panel__sign-out.scss @@ -0,0 +1,3 @@ +.auth-panel__sign-out { + margin-left: 5px; +} diff --git a/web/app/components/auth-panel/__tests__/auth-panel.test.js b/web/app/components/auth-panel/__tests__/auth-panel.test.js index ce8f1bb7..8e55f00a 100644 --- a/web/app/components/auth-panel/__tests__/auth-panel.test.js +++ b/web/app/components/auth-panel/__tests__/auth-panel.test.js @@ -48,7 +48,7 @@ describe('', () => { const userInfo = authPanelColumn[0]; - expect(userInfo.textContent).toEqual(expect.stringContaining('You signed in as John. Sign out?')); + expect(userInfo.textContent).toEqual(expect.stringContaining('You signed in as John')); }); }); describe('For admin user', () => { diff --git a/web/app/components/auth-panel/__user-id/auth-panel__user-id.jsx b/web/app/components/auth-panel/__user-id/auth-panel__user-id.jsx new file mode 100644 index 00000000..f04859f5 --- /dev/null +++ b/web/app/components/auth-panel/__user-id/auth-panel__user-id.jsx @@ -0,0 +1,8 @@ +/** @jsx h */ +import { h } from 'preact'; + +export default ({ id }) => ( +
+ {id} +
+); diff --git a/web/app/components/auth-panel/__user-id/auth-panel__user-id.scss b/web/app/components/auth-panel/__user-id/auth-panel__user-id.scss index ed32e370..b33fd069 100644 --- a/web/app/components/auth-panel/__user-id/auth-panel__user-id.scss +++ b/web/app/components/auth-panel/__user-id/auth-panel__user-id.scss @@ -1,3 +1,5 @@ .auth-panel__user-id { + overflow: hidden; + text-overflow: ellipsis; color: #888; } diff --git a/web/app/components/auth-panel/__username/auth-panel__username.scss b/web/app/components/auth-panel/__username/auth-panel__username.scss deleted file mode 100644 index 0c53cf67..00000000 --- a/web/app/components/auth-panel/__username/auth-panel__username.scss +++ /dev/null @@ -1,3 +0,0 @@ -.auth-panel__username { - cursor: default; -} diff --git a/web/app/components/auth-panel/auth-panel.jsx b/web/app/components/auth-panel/auth-panel.jsx index a2560f8c..a3657625 100644 --- a/web/app/components/auth-panel/auth-panel.jsx +++ b/web/app/components/auth-panel/auth-panel.jsx @@ -1,14 +1,17 @@ /** @jsx h */ import { h, Component } from 'preact'; +import UserId from './__user-id/auth-panel__user-id'; +import Dropdown, { DropdownItem } from 'components/dropdown'; +import Button from 'components/button'; import { PROVIDER_NAMES } from 'common/constants'; +import { requestDeletion } from 'utils/email'; import { getHandleClickProps } from 'common/accessibility'; export default class AuthPanel extends Component { constructor(props) { super(props); - this.toggleUserId = this.toggleUserId.bind(this); this.toggleBlockedVisibility = this.toggleBlockedVisibility.bind(this); this.toggleCommentsAvailability = this.toggleCommentsAvailability.bind(this); this.onSortChange = this.onSortChange.bind(this); @@ -20,10 +23,6 @@ export default class AuthPanel extends Component { } } - toggleUserId() { - this.setState({ isUserIdVisible: !this.state.isUserIdVisible }); - } - toggleBlockedVisibility() { if (!this.state.isBlockedVisible) { if (this.props.onBlockedUsersShow) this.props.onBlockedUsersShow(); @@ -44,7 +43,12 @@ export default class AuthPanel extends Component { } } - render(props, { isUserIdVisible, isBlockedVisible }) { + getUserTitle() { + const { user } = this.props; + return {user.name}; + } + + render(props, { isBlockedVisible }) { const { user, providers = [], sort, isCommentsDisabled } = props; const sortArray = getSortArray(sort); @@ -55,13 +59,20 @@ export default class AuthPanel extends Component { {loggedIn && (
You signed in as{' '} - - {user.name} - - {isUserIdVisible && ({user.id})}.{' '} - + + + + + + + + + {' '} +
)} diff --git a/web/app/components/auth-panel/index.js b/web/app/components/auth-panel/index.js index 6482d63b..5a8a50a1 100644 --- a/web/app/components/auth-panel/index.js +++ b/web/app/components/auth-panel/index.js @@ -7,7 +7,8 @@ require('./__pseudo-link/auth-panel__pseudo-link.scss'); require('./__select/auth-panel__select.scss'); require('./__select-label/auth-panel__select-label.scss'); require('./__sort/auth-panel__sort.scss'); -require('./__username/auth-panel__username.scss'); + require('./__user-id/auth-panel__user-id.scss'); +require('./__sign-out/auth-panel__sign-out.scss'); require('./_logged-in/auth-panel_logged-in.scss'); diff --git a/web/app/components/button/_focused/button_focused.scss b/web/app/components/button/_focused/button_focused.scss new file mode 100644 index 00000000..098f02aa --- /dev/null +++ b/web/app/components/button/_focused/button_focused.scss @@ -0,0 +1,3 @@ +.button_focused:not(.button_clicked) { + outline-width: 5px; +} diff --git a/web/app/components/button/_kind/button_kind.scss b/web/app/components/button/_kind/button_kind.scss new file mode 100644 index 00000000..ecd0a88e --- /dev/null +++ b/web/app/components/button/_kind/button_kind.scss @@ -0,0 +1,19 @@ +.button_kind_text { + border: none; + background: transparent; + padding: 0; +} + +.button_kind_link { + border: none; + background: transparent; + padding: 0; + + font-weight: 700; + white-space: nowrap; + color: #0aa; + + &:hover { + color: #06c5c5; + } +} diff --git a/web/app/components/button/button.jsx b/web/app/components/button/button.jsx new file mode 100644 index 00000000..e1f9fab9 --- /dev/null +++ b/web/app/components/button/button.jsx @@ -0,0 +1,69 @@ +/** @jsx h */ +import { Component, h } from 'preact'; +import noop from '../../utils/noop'; + +export default class Button extends Component { + constructor(props) { + super(props); + + this.state = { + isClicked: false, + isFocused: false, + }; + + this.onMouseDown = this.onMouseDown.bind(this); + this.onFocus = this.onFocus.bind(this); + this.onBlur = this.onBlur.bind(this); + } + + onMouseDown() { + this.setState({ + isClicked: true, + }); + } + + onClick(e) { + this.props.onClick(e); + } + + onBlur(e) { + this.setState({ + isClicked: false, + isFocused: false, + }); + + this.props.onBlur(e); + } + + onFocus(e) { + this.setState({ + isFocused: true, + }); + + this.props.onFocus(e); + } + + render(props, state) { + const { children, mix, mods, ...rest } = props; + const { isClicked, isFocused } = state; + + return ( + + ); + } +} + +Button.defaultProps = { + type: 'button', + onClick: noop, + onBlur: noop, + onFocus: noop, +}; diff --git a/web/app/components/button/button.scss b/web/app/components/button/button.scss new file mode 100644 index 00000000..cba7381d --- /dev/null +++ b/web/app/components/button/button.scss @@ -0,0 +1,6 @@ +.button { + cursor: pointer; + outline-width: 0; + font-size: inherit; + font-family: inherit; +} diff --git a/web/app/components/button/index.js b/web/app/components/button/index.js new file mode 100644 index 00000000..ff37104a --- /dev/null +++ b/web/app/components/button/index.js @@ -0,0 +1,5 @@ +export { default } from './button'; + +require('./button.scss'); +require('./_kind/button_kind.scss'); +require('./_focused/button_focused.scss'); diff --git a/web/app/components/dropdown/__content/dropdown__content.scss b/web/app/components/dropdown/__content/dropdown__content.scss new file mode 100644 index 00000000..590bcb20 --- /dev/null +++ b/web/app/components/dropdown/__content/dropdown__content.scss @@ -0,0 +1,14 @@ +.dropdown__content { + position: absolute; + z-index: 20; + outline-width: 0; + display: none; + top: 100%; + left: 50%; + transform: translate(-50%, 5px); + width: 170px; + background-color: #fff; + border: 2px solid #259c9a; + border-radius: 3px; + padding: 0 0 5px; +} diff --git a/web/app/components/dropdown/__item/dropdown__item.jsx b/web/app/components/dropdown/__item/dropdown__item.jsx new file mode 100644 index 00000000..5abd8331 --- /dev/null +++ b/web/app/components/dropdown/__item/dropdown__item.jsx @@ -0,0 +1,8 @@ +/** @jsx h */ +import { h } from 'preact'; + +export default function DropdownItem(props) { + const { children, separator = false, mix, mods } = props; + + return
{children}
; +} diff --git a/web/app/components/dropdown/__item/dropdown__item.scss b/web/app/components/dropdown/__item/dropdown__item.scss new file mode 100644 index 00000000..87787660 --- /dev/null +++ b/web/app/components/dropdown/__item/dropdown__item.scss @@ -0,0 +1,14 @@ +.dropdown__item { + a, button { + display: block; + width: 100%; + text-align: left; + padding: 5px 15px; + } + + &_separator { + border-bottom: 1px solid #259c9a; + margin-bottom: 5px; + padding: 5px 15px; + } +} diff --git a/web/app/components/dropdown/__items/dropdown__items.scss b/web/app/components/dropdown/__items/dropdown__items.scss new file mode 100644 index 00000000..8e2ca0cd --- /dev/null +++ b/web/app/components/dropdown/__items/dropdown__items.scss @@ -0,0 +1,3 @@ +.dropdown__items { + padding: 5px 0; +} diff --git a/web/app/components/dropdown/__title/dropdown__title.scss b/web/app/components/dropdown/__title/dropdown__title.scss new file mode 100644 index 00000000..aaa7b97d --- /dev/null +++ b/web/app/components/dropdown/__title/dropdown__title.scss @@ -0,0 +1,12 @@ +.dropdown__title { + border: none; + background: none; + font-weight: bold; + padding: 0; + margin: 0; + + &::after { + content: '\25BE'; + margin-left: 2px; + } +} diff --git a/web/app/components/dropdown/_active/dropdown_active.scss b/web/app/components/dropdown/_active/dropdown_active.scss new file mode 100644 index 00000000..5a3e604b --- /dev/null +++ b/web/app/components/dropdown/_active/dropdown_active.scss @@ -0,0 +1,5 @@ +.dropdown_active { + .dropdown__content { + display: block; + } +} diff --git a/web/app/components/dropdown/dropdown.jsx b/web/app/components/dropdown/dropdown.jsx new file mode 100644 index 00000000..439d11df --- /dev/null +++ b/web/app/components/dropdown/dropdown.jsx @@ -0,0 +1,76 @@ +/** @jsx h */ +import { Component, h } from 'preact'; + +import Button from 'components/button'; + +export default class Dropdown extends Component { + constructor(props) { + super(props); + + this.state = { + isActive: props.isActive || false, + }; + + this.onTitleClick = this.onTitleClick.bind(this); + this.onOutsideClick = this.onOutsideClick.bind(this); + } + + onTitleClick() { + this.setState({ + isActive: !this.state.isActive, + }); + + if (this.props.onTitleClick) { + this.props.onTitleClick(); + } + } + + onOutsideClick(e) { + if (!this.rootNode.contains(e.target)) { + if (this.state.isActive) { + this.setState({ + isActive: false, + }); + } + } + } + + componentDidMount() { + document.addEventListener('click', this.onOutsideClick); + + if (parent) { + parent.document.addEventListener('click', this.onOutsideClick); + } + } + + componentWillUnmount() { + document.removeEventListener('click', this.onOutsideClick); + + if (parent) { + parent.document.removeEventListener('click', this.onOutsideClick); + } + } + + render(props, { isActive }) { + const { title, heading, children, mix, mods } = props; + + return ( +
(this.rootNode = r)}> + + +
+ {heading &&
{heading}
} +
{children}
+
+
+ ); + } +} diff --git a/web/app/components/dropdown/dropdown.scss b/web/app/components/dropdown/dropdown.scss new file mode 100644 index 00000000..7969bc0d --- /dev/null +++ b/web/app/components/dropdown/dropdown.scss @@ -0,0 +1,4 @@ +.dropdown { + display: inline-block; + position: relative; +} diff --git a/web/app/components/dropdown/index.js b/web/app/components/dropdown/index.js new file mode 100644 index 00000000..d9f285ee --- /dev/null +++ b/web/app/components/dropdown/index.js @@ -0,0 +1,10 @@ +export { default } from './dropdown'; +export { default as DropdownItem } from './__item/dropdown__item'; + +require('./dropdown.scss'); +require('./_active/dropdown_active.scss'); + +require('./__item/dropdown__item.scss'); +require('./__items/dropdown__items.scss'); +require('./__title/dropdown__title.scss'); +require('./__content/dropdown__content.scss'); diff --git a/web/app/testUtils/mockStyles.js b/web/app/testUtils/mockStyles.js new file mode 100644 index 00000000..f053ebf7 --- /dev/null +++ b/web/app/testUtils/mockStyles.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/web/app/utils/email.js b/web/app/utils/email.js new file mode 100644 index 00000000..a980c834 --- /dev/null +++ b/web/app/utils/email.js @@ -0,0 +1,35 @@ +import store from '../common/store'; +import { siteId } from '../common/settings'; +import { deleteMe } from '../common/api'; + +// The right line breaks code in the body of inline email +// should be not just %0A, but %0D%0A +// see: https://www.ietf.org/rfc/rfc2368.txt +const LINE_BREAK_CODE = '%0D%0A'; + +export function getDeleteInformationMessage(userId, siteId, link) { + const subject = encodeURIComponent("Request to delete user's information"); + const message = encodeURIComponent(`Request to delete all information about ${userId} from remark42 on ${siteId} + +[you can provide the reason for removal request, optional] + +=== DO NOT REMOVE THE TEXT BELOW THIS LINE === + +site: ${siteId} +user: ${userId} +link: ${link} +`).replace('%0A', LINE_BREAK_CODE); + + return { + subject, + message, + }; +} + +export function requestDeletion() { + deleteMe().then(data => { + const email = store.get('config').admin_email; + const { subject, message } = getDeleteInformationMessage(data.user_id, siteId, data.link); + window.open(`mailto:${email}?subject=${subject}&body=${message}`); + }); +} diff --git a/web/app/utils/noop.js b/web/app/utils/noop.js new file mode 100644 index 00000000..ca6a7447 --- /dev/null +++ b/web/app/utils/noop.js @@ -0,0 +1 @@ +export default function noop() {} diff --git a/web/package.json b/web/package.json index d8c261b0..a49abbea 100644 --- a/web/package.json +++ b/web/package.json @@ -83,7 +83,10 @@ ], "testMatch": [ "/**/*.test.js" - ] + ], + "moduleNameMapper": { + "\\.scss$": "/app/testUtils/mockStyles.js" + } }, "engines": { "node": ">=8"